Skip to main content

Permissions

Status: proposed design, not yet implemented. This describes the planned V2 permission system (see docs/docs/roadmap/detailed-roadmap.md — permissions is the prerequisite unlock for the MCP server and conversational assistant). Nothing in this document is live in the codebase today. It's written here rather than in plans/ because it's intended to become the reference doc once built, not just a reasoning trail — but until it's implemented, treat every code sample as a proposal, not current behavior.

This document reflects three deliberate architecture pivots. (1) Earlier drafts proposed one dedicated, foreign-key-constrained join table per relationship type — replaced with a single, generalized permission_grant table, because the resource graph's future shape wasn't known. (2) That generalized table turned out to hide the resource hierarchy from the system entirely, so a grant on a Company couldn't propagate down to its Teams - nothing declared which resource types contained which others. Fixed by splitting storage in two: real, FK-constrained structural tables for the hierarchy (section 2), and a generalized grant table that attaches scopes to a structural relationship rather than connecting two raw resources directly. (3) The resulting permission object was moved entirely behind GrantService - no other service reads it directly, only two methods (section 3). If you're looking for any earlier design, it's in this file's git history, not here.

This document is about authorization — what a given principal is allowed to do. It's a different concern from authorization.md, which (despite the filename) is actually about authentication — cookie-based sessions, how a request proves who it's from. This document assumes that question is already answered and covers what happens next: given a known principal, what can they see or do.

1. API security: no cross-resource joins, ever​

Rule: no API in this system may join, populate, or resolve a foreign key into another resource's data, at any depth. An endpoint returns its own resource's fields plus foreign key IDs as plain strings — never the referenced object, expanded or embedded.

Why this is a permission rule, not just a style rule: every resource is responsible for its own authorization check, and only its own. If a child resource's API were allowed to join in its parent's data, the parent's fields would be returned to a caller whose permissions were only ever checked against the child — the parent's own authorization gate never ran. Once that's allowed once, "does this user have access to X" no longer has one answer; it depends on which path was used to reach X. Banning joins entirely removes that ambiguity structurally, not by convention: a resource's API has no code path capable of returning another resource's data, so there's nothing to review for later.

Concrete example: UserIntegrationContent has a foreign key to UserIntegration, which can be shared into a team via a Collection — UserIntegration → Collection → Team, not owned by a team directly.

  • GET /v1/user-integration-content/:id returns userIntegrationId as a plain string. It does not return the integration's name, which collections it's in, or anything else about it — even though that data is one lookup away and would be convenient to include.
  • To find out anything about that integration, the caller makes a separate call: GET /v1/user-integration/:id. That call re-checks permissions independently — against the integration's own authorization logic, not "well, they could already see a piece of its content."
  • This holds at every hop. Reading a Collection never returns its Teams' fields inline; reading a Team never returns its Company's fields inline.

Accepted cost: a caller walking content → integration → collection → team → company makes five separate, independently-authorized requests instead of one joined query. That's the price of the guarantee that access never travels across a foreign key implicitly.

This rule holds regardless of how relationships are stored (section 2) — it's about API surface, not schema.

2. Storage: real structural relationships, plus a generalized grant table attached to them​

Why this is a second pivot, not a rename​

The previous draft of this section put everything - both the resource hierarchy (Team belongs to a Company, an Integration belongs to a Collection) and the flexible access layer (who currently has what scope) - into one generalized permission_grant table, keyed by raw (subjectType, subjectId, resourceType, resourceId). That solved the schema-proliferation problem, but it created a new one: nothing in the system declared which resource types contain which others, so a broad grant on a Company couldn't propagate down to the Teams and Collections inside it - there was no graph to walk, only disconnected facts. See docs/docs/what-we-picked/why-generalized-permissions.md for the fuller reasoning trail; this section supersedes its storage description.

The fix separates two things that got conflated:

  • Structure - which resource types contain or compose which others. This is close to the stable core of what Yew is; it doesn't change often. Modeled as real, FK-constrained join tables again, so it's an actual traversable graph, not a claim.
  • Access - who currently has what scope. This is the genuinely uncertain, evolving part. Stays generalized, but now attaches to a structural relationship instead of connecting two raw resources directly.

Structural join tables - real foreign keys, no scopes​

TableRepresentsKey fields
company_userA user is a member of a company.id, userId → user.id, companyId → company.id
team_userA user is a member of a team.id, userId → user.id, teamId → team.id
collection_teamA collection is assigned to (shared with) a team.id, teamId → team.id, collectionId → collection.id
collection_user_integrationAn integration is placed in a collection.id, collectionId → collection.id, userIntegrationId → user_integration.id
user_integration_embedding_configAn embedding config produces embeddings for an integration.Already exists, unchanged - see section on ownership below.

None of these carry a scopes column - they only assert that a relationship exists, with real, database-enforced foreign keys and standard soft-delete (deletedAt). What a relationship grants is a separate concern, attached via the grant table below.

This is also what makes hierarchy propagation possible at all: Company → Team → Collection → UserIntegration is now an actual chain GrantService can walk with real joins (team.companyId, then collection_team, then collection_user_integration) to compute, for example, everything a company-wide grant should cover - not something it has to be told about resource-by-resource.

DirectGrant is the one deliberate exception, kept polymorphic on purpose:

TableRepresentsKey fields
direct_grantA one-off relationship with no natural group or hierarchy underneath it - sharing one integration with a contractor on no team, the case that started this whole line of the design.id, userId → user.id, resourceType, resourceId

Every other relationship in the system has an obvious structural home; this one deliberately doesn't, which is exactly why it's the one place a raw polymorphic reference is still the right call rather than a compromise.

The grant table - a scope attached to a structural relationship​

FieldTypeNotes
iduuid
joinTypestringWhich structural table joinId points into - "companyUser", "teamUser", "collectionTeam", "collectionUserIntegration", "directGrant".
joinIduuidThe specific row's id in that table.
scopestringOne scope per row, not an array - see "Why one scope per row" below.
provenancestring"manual" today; a future "synced:google-workspace" etc.
createdAt/updatedAt/deletedAttimestampsStandard soft-delete. createdAt already records when a scope was granted; a separate grantedAt would be redundant.

Unique constraint: one active row per (joinType, joinId, scope) where deletedAt IS NULL.

Notice what's not here anymore: no subjectType/subjectId at all. Who a grant applies to is entirely implied by whichever structural row joinId references - a teamUser row's subject is inherently a user, by construction, because that's what the team_user table's own foreign key says. This also closes a real gap the previous draft had: it never formally added "team" as a valid grant subject even though an example in this document already depended on one (a team being granted access to a collection) - that inconsistency can't happen anymore, because subjects were never a thing the grant table declared in the first place.

Why one scope per row​

An array can only tell you that a set of scopes changed at some point - it can't tell you which scope was added or removed, or when. One row per scope makes every grant and every revocation its own auditable fact: soft-deleting a single row records exactly when that one scope was removed, independent of any other scope still active on the same relationship. This is also most of the way toward the "audit logs (all actions logged)" requirement already planned for V4 - a side effect of choosing this shape now, not separate work later.

Referential integrity, restated​

The structural tables now have real, database-enforced foreign keys - that part of the original integrity concern is fully resolved, not just mitigated. The grant table still has one polymorphic field (joinType), so it still can't have a single database-level foreign key for joinId - but what it needs to validate is narrower and safer than before: only "does a row of this specific type and id exist," a single lookup against a known table, not "does an arbitrary resource of an arbitrary type exist across the whole system."

  • A code-level registry (extending the already-planned central scope registry) defines which scopes are valid for each joinType - team.manage is valid for a teamUser grant, a nonsensical scope for that relationship type is rejected at write time.
  • GrantService is the only thing that ever reads or writes the structural tables and the grant table. No other service touches them directly.
  • GrantService still performs, at write time, what a cross-table foreign key can't: confirming joinId actually resolves to a real row of the stated joinType, confirming the scope is legal for that relationship type. Concentrated in one service, not duplicated - narrower work than the previous draft required, since the structural side no longer needs it at all.

Propagation: which scopes travel down the hierarchy​

Structure being real and traversable makes propagation possible; it doesn't automatically decide which scopes should use it. Not every scope granted at a company level obviously belongs on every integration underneath it. Default assumption, not yet fully settled: scopes marked inheritable in the scope registry propagate downward through the real structural chain (company → team → collection → integration); scopes that don't make sense below their own level (e.g. company.billing) are marked non-inheritable and stay put. This needs to be an explicit property of each scope in the registry, not an implicit assumption GrantService makes on its own - see open questions.

Ownership is not part of this system​

Not everything becomes a grant. There's a real distinction between ownership (a fixed, structural, 1:many property of a resource - who created it, who's billed for it) and access (a flexible, evolving, many-to-many relationship - who can currently see or use it). Ownership stays exactly what it was in the earlier design: conventional, FK-constrained columns, unaffected by this pivot.

UserIntegrationEntity and EmbeddingConfigEntity only have userId in the codebase today - confirmed by reading both entities directly. EmbeddingConfigEntity ownership moves to embedding_config.companyId (an embedding config usually represents a centrally-billed provider credential, not something an individual employee owns). UserIntegrationEntity needs three nullable fields to cover both integration-authorization models:

  1. Self-service, individually authorized - personal Gmail, personal Notion. The credential can only be created by the account holder.
  2. Admin-provisioned, domain-wide - Google Workspace via domain-wide delegation, a company Slack workspace. One admin action authorizes access to many people's data; concretely, one UserIntegration row per known domain member, not one row for the whole domain.
  • userId - set when this row is known to belong to a specific Yew account.
  • companyId - set when the company holds administrative/revocation authority over the credential (self-service: null; admin-provisioned: set).
  • externalUserId - set when the provider type has an inherent external-identity concept (Gmail: every message has a mailbox owner; FTP: a login credential isn't a person) - independent of the other two.
userIdcompanyIdexternalUserIdMeaning
setnullsetSelf-service Gmail - individual owns it entirely
setnullnullSelf-service FTP - individual owns it, no external identity concept
nullsetsetDomain-wide Workspace row for an employee not yet matched to a Yew account
setsetsetDomain-wide Workspace row matched to a known Yew user
nullsetnullCompany-managed resource with no per-person identity at all
nullnull-Invalid - no owner, must be rejected

Still open: when an unmatched domain-wide row (userId: null) is later matched, does userId get backfilled onto that same row (preserving its content's history)? Backfilling in place is the obvious answer; the actual matching mechanism isn't designed yet.

Also unaffected by this pivot: team.companyId, collection.companyId, and team.parentTeamId (the self-referencing, one-level-capped nesting field) all remain ordinary foreign keys - they're structural ownership, the same category as UserIntegration.companyId, not the flexible access layer.

UserIntegrationEmbeddingConfigEntity (the existing table attaching an embedding config to an integration - already built, not a gap) stays a conventional FK-constrained join table too, for the same reason: it's a fixed composition relationship ("this config produces embeddings for this integration"), not an access relationship that needs to flex as the org grows.

Cascade deletes - real FKs handle half of this now, not all of it​

The structural tables having real foreign keys is a genuine improvement here, but it comes with a trap worth naming explicitly: Postgres-level ON DELETE CASCADE on the structural tables would silently remove, say, a team_user row when its Team is deleted - without ever running application code. If that happened, any grant row pointing at that team_user.id (via the polymorphic joinId) would be orphaned, because nothing triggered GrantService to clean it up. Deliberate decision: don't use ON DELETE CASCADE on the structural tables, even though it's available. Structural deletion stays application-level, so grant cleanup can never be silently bypassed by a database-level cascade happening outside GrantService's knowledge.

Concretely: every resource-owning service's delete path explicitly deletes its own structural rows and calls GrantService to purge any grant rows pointing at them, in one transaction:

  • UserService.deleteOne() → delete this user's company_user/team_user rows
  • CompanyService.deleteOne() → delete this company's company_user, team, collection rows
  • TeamService.deleteOne() → delete this team's team_user, collection_team rows
  • CollectionService.deleteOne() → delete this collection's collection_team, collection_user_integration rows
  • UserIntegrationService.deleteOne() → delete this integration's collection_user_integration rows, DirectGrant rows referencing it
  • EmbeddingConfigService.deleteOne() → delete user_integration_embedding_config rows referencing it

Six known call sites, same as before - each one now also responsible for its own structural rows, not just grants. Bounded and auditable; the risk is still "did someone add a seventh resource type and forget the hook," not an open-ended one.

New/modified APIs​

Structural relationships and scopes are managed by two different kinds of API now, matching the two different kinds of table:

APINotes
CompanyUserController, TeamUserController, CollectionTeamController, CollectionUserIntegrationControllerThin CRUD on the structural tables - add/remove a relationship. No scope logic at all; that's GrantController's job, not theirs. This is what keeps these from becoming the "N near-identical implementations" problem the earlier draft was trying to avoid - each one only does trivial structural CRUD.
GrantController (/v1/grant)The one centralized API for scopes - create/read/delete, filterable by joinType/joinId. Every scope-validation rule lives here exactly once, regardless of which structural table the grant is attached to.
DirectGrantController (/v1/direct-grant)CRUD on DirectGrant rows - the one-off, no-hierarchy case. Scopes on these still go through GrantController (joinType: "directGrant").
CompanyController (/v1/company)Standard CRUD on the resource's own fields (name).
TeamController (/v1/team)Standard CRUD; parentTeamId optional on create.
CollectionController (/v1/collection)Standard CRUD, company-owned.
UserIntegrationController (existing)Add companyId/externalUserId to create/update DTOs (userId already exists).
EmbeddingConfigController (existing)userId → companyId (ownership change, not an addition).
A "my permissions" endpoint (e.g. GET /v1/me/permissions)The frontend needs some way to read the current user's own effective scopes to render UI conditionally - nothing else provides that.

Gaps this raised, and where they stand​

  • Company bootstrap - resolved. Every signup creates a company, always, no exception. A self-hosted/homelab user is simply the sole member and admin of a one-person company - this is why embedding_config.companyId works uniformly for a homelab user and a thousand-person business with no special case.
  • Orphaned resources on revocation - still open. If a grant is deleted, what happens to in-flight state (e.g. a search result someone had open)? Likely "nothing retroactive, just stops being visible on the next permission check," but not decided.
  • The object-builder itself - resolved. This is GrantService (section 3) - the single place structural rows and grant rows get resolved into checkable data, and the only thing ever allowed to do so.
  • The user→company ownership handoff - concrete, not abstract, and not actually a grant. "A user gives their integration to the company" is UserIntegrationController setting user_integration.companyId - an ownership change (see below), separate from GrantController entirely. What's still open is what UI/API action triggers it, and whether it's reversible.
  • Join-table naming applies again, differently than before. The earlier alphabetical-ordering rule (CompanyUser vs. UserCompany) is back in play for the structural tables above - company_user and team_user already follow it; collection_team and collection_user_integration were chosen to follow it too (c before t, c before u).
  • Privilege escalation on grant creation - still open, carried over from the previous review. GrantController still needs to check that an actor granting a scope actually holds that scope themselves, not just that they have some broad management scope on the relevant structural row. Not yet designed.

3. The permission object - internal to GrantService, not exposed​

No service other than GrantService ever reads this object. Earlier drafts of this document had the object attached to the request and read directly by every downstream service (teams[], effectiveScopes, etc. inspected inline). That's been tightened: the object is now purely GrantService's own internal cache, built by joining the structural tables (section 2) with the grant rows attached to them, used to answer the two methods below. Every other service interacts with authorization exclusively through those two methods - never by reading permission data itself. This is a stronger version of the same abstraction-boundary principle already in this document: not just "storage can change without touching read logic," but "the read representation can change too," since nothing outside GrantService depends on its shape at all.

This depends on GrantService staying an in-process NestJS provider, not a separately-deployed service. The two-method pattern below is essentially free (function calls) as an in-process dependency; it becomes two real network round-trips per authorization check if GrantService is ever split out as its own service. Worth stating as an explicit assumption now, so it isn't silently violated by an unrelated future infrastructure decision.

GrantService's two methods​

// Single-resource action checks - "can this principal do X to this
// specific thing" (or company-wide, if resourceType/resourceId are
// omitted). Returns a plain boolean, nothing else.
canPerform(
principal: { type: 'user' | 'apiKey'; id: string },
scope: string,
resource?: { type: string; id: string },
): Promise<boolean>

// List/search endpoints - "what can this principal see." Returns a base
// query fragment (an ID allow-list, or an equivalent TypeORM query
// fragment) that the calling service extends with its own additional
// filters (date ranges, text search, pagination) - never replaces.
getAccessQuery(
principal: { type: 'user' | 'apiKey'; id: string },
resourceType: string,
scope: string,
): Promise<{ unrestricted: true } | { unrestricted: false; resourceIds: string[] }>

The return type is deliberately not just resourceIds: string[] - an empty array and "no restriction at all" (company-wide access) are opposite answers, and collapsing them into the same shape would force every caller to special-case "empty means unrestricted, or does it mean no access?" itself. unrestricted makes that an explicit, unambiguous branch instead.

These are two independent methods, not a required sequence. A single-resource action (delete a team) only ever needs canPerform - there's no query to build. A list endpoint only ever needs getAccessQuery - an empty result is the "no access" answer for filtering purposes, with no separate yes/no call needed first.

The internal cache, still worth documenting​

GrantService still needs some internal representation to make repeated canPerform/getAccessQuery calls fast rather than re-joining the structural tables and grant every time. Unlike the durable storage (one scope per row, for audit granularity - see section 2), this cache can denormalize freely, since it's rebuilt from storage and doesn't need to preserve row-level history itself - grouping every scope a principal has on a given resource together is the natural, efficient shape for lookups.

It carries its own version number, so it can be evolved like an internal API - GrantService itself can branch on version if this internal shape changes, though since nothing outside GrantService depends on it anymore, this matters far less than it did when the object was shared.

{
"version": 1,
"principalId": "usr_01h8z9k2",
"principalType": "user", // "user" | "apiKey" — see Principals below
"computedAt": "2026-09-02T02:41:00Z",

// The one exception to the uniform shape below - global scopes have no
// resourceId to key on.
"global": { "scopes": [] },

// Every resource type follows the same shape: an array of
// { resourceId, scopes }, and every key is plural - a user can belong
// to more than one company, more than one team, and so on, so there's
// no singular-object special case to remember. Entries here are
// resolved by walking the structural tables and the grant rows
// attached to them at build time, merged regardless of how access was
// derived (a DirectGrant, a Team/Collection resolution, or a scope
// propagated down from a Company-level grant) - checking code only
// needs "do they have it," never "how did they get it."
"companies": [
{ "resourceId": "cmp_acme", "scopes": ["search.read"] }
],
"teams": [
{ "resourceId": "team_sales", "scopes": ["search.read", "integration.read"] }
],
"userIntegrations": [
{ "resourceId": "ui_finance_drive", "scopes": ["integration.read"] }
],

// Flattened union of global.scopes + every entry's scopes across every
// resource-type array above. The fast "does this principal have X
// anywhere" check.
"effectiveScopes": ["search.read", "integration.read"]
}

Additional resource-type keys (collections, embeddingConfigs) get added the same way, as needed, not all committed up front.

Design decisions worth stating explicitly​

  • Scopes are resource.action strings (e.g. integration.read, team.manage). A central scope registry validates that every scope referenced by a route actually exists, and (extended per section 2) that every scope attached to a grant row is legal for that row's joinType, and whether it's inheritable down the structural hierarchy.
  • Additive only, no explicit deny. The effective permission for a scope is "does it appear anywhere in the object," full stop - highest access wins, matching GitHub's model rather than AWS IAM's layered allow/deny evaluation. Revisit only if a real case for a deny path shows up - don't add it speculatively.
  • provenance on every grant, even though nothing writes anything but "manual" today - this is what makes a future permission-sync integration (mirroring a connected source's own ACLs, the way Glean does) additive later rather than a redesign.
  • Nested teams: parentTeamId exists on every team now, always null. When nesting is turned on, it's capped at one level (a team with a parent cannot itself be a parent) - a cycle would require a third hop the cap makes structurally impossible, so no cycle-detection logic is needed.
  • Not included here on purpose: feature/package entitlements. A separate system, checked at a handful of feature-gate boundaries company-wide, not per-resource per-request like this object.

Principals: users and API keys are checked identically - but this pivot reopens how​

A request is authorized either by a session cookie (a user) or an API key. Both resolve, via an auth-normalization step, to the same { type, id } principal shape canPerform/getAccessQuery accept - only how that principal was resolved differs. An API key is meant to be a first-class principal, grantable access exactly like a user, not a scoped delegation of whichever user created it.

This is a real open question this pivot reintroduces, not a solved detail. In the previous, fully-generalized draft, subjectType being a free string meant "an API key is a principal" cost nothing extra. Now that team_user.userId/company_user.userId are real foreign keys to user.id specifically, an API key - a different entity entirely - cannot literally be a row in those tables as written. This needs one of: a parallel api_key_team/api_key_company table per structural table (doubles the structural table count), a nullable dual-FK on each structural table (userId nullable, apiKeyId nullable, exactly one set - the same pattern already used for UserIntegration ownership in this document), or a unified principal table that User and ApiKey both reference, with structural tables pointing at principal.id instead of user.id directly. Not decided - see open questions. Worth resolving before the MCP server (which authenticates via API key) is built, not discovered while building it.

4. Using GrantService: checking a request​

A few concrete examples of how a route calls the two methods from section 3. No route reads permission data directly - every check is one of these two calls.

Example A: reading a single resource by ID​

GET /v1/user-integration/:id — checking integration.read for a specific integration shared into team_sales via a Collection (the owning user or company granted it, an admin put it in a Collection, the Collection is assigned to team_sales):

const allowed = await grantService.canPerform(
{ type: 'user', id: principalId },
'integration.read',
{ type: 'userIntegration', id: integrationId },
);
if (!allowed) throw new ForbiddenError();

canPerform is responsible for resolving every path that could grant this: a DirectGrant on the integration itself; a grant on a collectionTeam row for a Collection this integration is in, assigned to one of the principal's teams; or an inheritable scope granted higher up - on the principal's companyUser row for the company this integration's Collection ultimately belongs to - propagated down through the real structural chain. The caller doesn't know or care which path matched; that resolution, including the hierarchy walk, is entirely internal to GrantService.

Example B: filtering a list/search endpoint​

SearchService.search() — a query can span content from many integrations. getAccessQuery resolves an ID allow-list before the search runs, never joined into the search query itself (see section 1):

const access = await grantService.getAccessQuery(
{ type: 'user', id: principalId },
'userIntegration',
'search.read',
);
if (!access.unrestricted) {
// pass access.resourceIds to Elasticsearch/Postgres as
// inUserIntegrationIds - the same mechanism search.service.ts already
// uses for the request-level inUserIntegrationIds parameter today.
}
// access.unrestricted === true -> admin-style company-wide access,
// skip the filter entirely rather than resolving a redundant allow-list.

A request can optionally narrow further with an explicit inCollectionIds filter (the finance-team example - searching only the "payroll expense" collection) - the calling service intersects that with resourceIds itself; getAccessQuery doesn't need to know about it.

Example C: an action that changes the underlying grants​

A company admin sharing a collection with team_sales - two steps, one structural (does the relationship exist), one scope-based (what does it grant), matching the two kinds of table from section 2:

// 1. CollectionTeamController: does this structural relationship exist yet?
const allowed = await grantService.canPerform(
{ type: 'user', id: actorId },
'team.manage',
{ type: 'team', id: 'team_sales' },
);
if (!allowed) throw new ForbiddenError();

const collectionTeam = await collectionTeamService.createOne({
teamId: 'team_sales',
collectionId,
});

// 2. GrantController: attach the actual scope to that structural row.
await grantService.grant({
joinType: 'collectionTeam',
joinId: collectionTeam.id,
scope: 'search.read',
});

grant() (and its counterpart revoke()) are the only way any grant row is ever written - internal to GrantService, same as everything else in section 3. On success, GrantService invalidates its own internal cache entries for every affected principal (every member of team_sales, not just the actor) - the delete-then-recompute pattern: a mutation clears the affected principals' cached data and lets it rebuild lazily on their next canPerform/getAccessQuery call, rather than leaving it stale.

5. Open questions, not yet decided​

  • How a User vs. an API key becomes a structural-table row (section 3, "Principals") - parallel tables, a nullable dual-FK, or a unifying principal table. Needs resolving before the MCP server is built.
  • Which scopes propagate down the structural hierarchy and which don't (section 2, "Propagation") - needs to be an explicit, per-scope property in the registry, not an assumption GrantService makes silently.
  • The exact shape/location of the scope-legality registry (which scopes are valid for each joinType, and which are inheritable) - whether it's pure TypeScript config or needs its own introspectable table for tooling/admin UI purposes later.
  • Indexing/performance strategy for the grant table specifically. Every structural table already gets natural indexes on its own foreign keys; grant still needs a composite index on (joinType, joinId) - worth a real indexing plan before this is built, not discovered under load.
  • Where GrantService's internal cache actually lives (in-memory, a table-as-cache pattern, or Redis) and exact invalidation triggers beyond the grant()/revoke() example in section 4.
  • How entitlements (feature/package permissions) actually get checked at request time, and whether that belongs in the same auth-normalization middleware step as this object or a fully separate one - deliberately out of scope for this document, but the seam needs designing before either system is built.
  • The unmatched → matched backfill mechanism for admin-provisioned UserIntegration rows (section 2) - how/when userId actually gets set once someone signs up.
  • Whether the user→company ownership handoff is reversible, and what UI/API action actually triggers setting user_integration.companyId.
  • ACL sync (mirroring a connected source's own ACLs rather than owning permissions independently, the way Glean does) - not designed here. Flagged as worth reconsidering at V5, once SSO/SCIM-scale organization management exists - see docs/docs/roadmap/README.md's V5 section. Not a rejection, just not now.
  • A Terraform provider for these resources (V5) would want a stable, human-meaningful identifier per resource (a slug) for terraform import/idempotent re-apply to work - worth keeping in mind when these entities are actually built.