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 inplans/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_granttable, 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 generalizedgranttable that attaches scopes to a structural relationship rather than connecting two raw resources directly. (3) The resulting permission object was moved entirely behindGrantService- 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/:idreturnsuserIntegrationIdas 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
Collectionnever returns itsTeams' fields inline; reading aTeamnever returns itsCompany'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
| Table | Represents | Key fields |
|---|---|---|
company_user | A user is a member of a company. | id, userId → user.id, companyId → company.id |
team_user | A user is a member of a team. | id, userId → user.id, teamId → team.id |
collection_team | A collection is assigned to (shared with) a team. | id, teamId → team.id, collectionId → collection.id |
collection_user_integration | An integration is placed in a collection. | id, collectionId → collection.id, userIntegrationId → user_integration.id |
user_integration_embedding_config | An 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:
| Table | Represents | Key fields |
|---|---|---|
direct_grant | A 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
| Field | Type | Notes |
|---|---|---|
id | uuid | |
joinType | string | Which structural table joinId points into - "companyUser", "teamUser", "collectionTeam", "collectionUserIntegration", "directGrant". |
joinId | uuid | The specific row's id in that table. |
scope | string | One scope per row, not an array - see "Why one scope per row" below. |
provenance | string | "manual" today; a future "synced:google-workspace" etc. |
createdAt/updatedAt/deletedAt | timestamps | Standard 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.manageis valid for ateamUsergrant, a nonsensical scope for that relationship type is rejected at write time. GrantServiceis the only thing that ever reads or writes the structural tables and thegranttable. No other service touches them directly.GrantServicestill performs, at write time, what a cross-table foreign key can't: confirmingjoinIdactually resolves to a real row of the statedjoinType, 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:
- Self-service, individually authorized - personal Gmail, personal Notion. The credential can only be created by the account holder.
- 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
UserIntegrationrow 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.
userId | companyId | externalUserId | Meaning |
|---|---|---|---|
| set | null | set | Self-service Gmail - individual owns it entirely |
| set | null | null | Self-service FTP - individual owns it, no external identity concept |
| null | set | set | Domain-wide Workspace row for an employee not yet matched to a Yew account |
| set | set | set | Domain-wide Workspace row matched to a known Yew user |
| null | set | null | Company-managed resource with no per-person identity at all |
| null | null | - | 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'scompany_user/team_userrowsCompanyService.deleteOne()→ delete this company'scompany_user,team,collectionrowsTeamService.deleteOne()→ delete this team'steam_user,collection_teamrowsCollectionService.deleteOne()→ delete this collection'scollection_team,collection_user_integrationrowsUserIntegrationService.deleteOne()→ delete this integration'scollection_user_integrationrows,DirectGrantrows referencing itEmbeddingConfigService.deleteOne()→ deleteuser_integration_embedding_configrows 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:
| API | Notes |
|---|---|
CompanyUserController, TeamUserController, CollectionTeamController, CollectionUserIntegrationController | Thin 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.companyIdworks 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 andgrantrows 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
UserIntegrationControllersettinguser_integration.companyId- an ownership change (see below), separate fromGrantControllerentirely. 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 (
CompanyUservs.UserCompany) is back in play for the structural tables above -company_userandteam_useralready follow it;collection_teamandcollection_user_integrationwere chosen to follow it too (cbeforet,cbeforeu). - Privilege escalation on grant creation - still open, carried over
from the previous review.
GrantControllerstill 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.actionstrings (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 agrantrow is legal for that row'sjoinType, 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.
provenanceon 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:
parentTeamIdexists on every team now, alwaysnull. 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
principaltable. 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
GrantServicemakes 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
granttable specifically. Every structural table already gets natural indexes on its own foreign keys;grantstill 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 thegrant()/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
UserIntegrationrows (section 2) - how/whenuserIdactually 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) forterraform import/idempotent re-apply to work - worth keeping in mind when these entities are actually built.