You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The audit trail is wrong, not just ambiguous. The owner creates a grant for Alice, and the record says Alice authored it — an entity who may never have touched the stack is recorded as the writer. RecordVersion.entityId ("who made this change") inherits the same lie on any future grant edit.
-own semantics degrade.hasGrant's -own check is record?.entityId === requester — for a _grant record itself, "own" resolves to "is the grantee," not "authored it."
The indexing concern (raised and resolved in discussion)
Objection considered: entityId is a native indexed field with first-class query support; content fields are second-class (exact-match top-level scalars, contentFieldQuery-gated). Doesn't apply to grants, for a reason already visible in the code — the permission engine never index-filters grants by entityId today:
It can't use the index: a default grant is expressed as absent entityId, and the query surface has no "= X OR IS NULL" (only parentId supports null-matching). So the grantee comparison is already in-memory; the selective filter is the grant's target typeId, which is already a content field. The relocation regresses nothing on the permission hot path — and that stays true after the move (the default-grant/absent-field problem is unchanged, so the in-memory check remains).
Supporting facts: grants number in the tens at the stated scale (individuals, small cohesive groups); #50's prefetch pulls all _grant@1 records in one cursor-walked query regardless; permission checks run in the server's process against its own adapter, so wire-level content-filter fragility (#56) never touches this path. If an indexed "everything granted to X" lookup is ever wanted, the #46 native adapter can add a generated column on the known content path — an adapter detail, not a spec commitment.
Meanwhile the frequent entityId filtering on ordinary records — the reason the field is indexed — gets more correct after the move, because grants stop appearing in authorship results.
grant() keeps its signature but writes the grantee into content and stops stamping record.entityId. Owner-created grants then carry no entityId, consistent with the spec's "owner records carry no entityId" invariant (which today is violated by every grant).
hasGrant reads content.granteeEntityId for the grantee comparison; the in-memory check stays (default-grant reason above).
No migration/backfill code. No install base; existing dev stacks recreate grants. A one-shot script can exist later if that changes before this lands.
Work items
GrantContent in packages/core/src/types.ts + _grant@1 schema in seedSystemTypes() (optional granteeEntityId)
grant() write path (grantee → content; no author stamp)
hasGrant() read path
Tests: authorship query excludes grants about the entity; default grants still apply to everyone; entity-specific grants still bind; -own actions on ordinary records unaffected
Spec: grants section documents granteeEntityId; note that entityId = author now holds for system records too
Natural companion (separate backlog item, not scoped here): revoke() / listGrants() alongside grant() — listGrants gets simpler once the grantee is a queryable content field.
Refs #49 (entityId → DID; grantee becomes a DID string), #47 (additive-evolution legality of the in-place change), #50 (grant prefetch unchanged), #46 (optional content-path index later), #56 (why wire content-filter gaps don't reach this path).
Problem
Stack.grant()writes the grantee intorecord.entityId— the field that everywhere else means "author of this record":(
packages/core/src/stack.ts:696–701)Three consequences:
RecordVersion.entityId("who made this change") inherits the same lie on any future grant edit.filter: { entityId: 'alice' }— "everything Alice wrote" — returns grants about Alice. Under RFC: identity model — entityId becomes a DID (did:key floor), _entity records become local profiles #49, whereentityIdbecomes a DID and identity gets load-bearing, records-by vs. records-about is exactly the distinction that must stay crisp.-ownsemantics degrade.hasGrant's-owncheck isrecord?.entityId === requester— for a_grantrecord itself, "own" resolves to "is the grantee," not "authored it."The indexing concern (raised and resolved in discussion)
Objection considered:
entityIdis a native indexed field with first-class query support; content fields are second-class (exact-match top-level scalars,contentFieldQuery-gated). Doesn't apply to grants, for a reason already visible in the code — the permission engine never index-filters grants byentityIdtoday:(
packages/core/src/stack.ts:821–833)It can't use the index: a default grant is expressed as absent
entityId, and the query surface has no "= X OR IS NULL" (onlyparentIdsupports null-matching). So the grantee comparison is already in-memory; the selective filter is the grant's targettypeId, which is already a content field. The relocation regresses nothing on the permission hot path — and that stays true after the move (the default-grant/absent-field problem is unchanged, so the in-memory check remains).Supporting facts: grants number in the tens at the stated scale (individuals, small cohesive groups); #50's prefetch pulls all
_grant@1records in one cursor-walked query regardless; permission checks run in the server's process against its own adapter, so wire-level content-filter fragility (#56) never touches this path. If an indexed "everything granted to X" lookup is ever wanted, the #46 native adapter can add a generated column on the known content path — an adapter detail, not a spec commitment.Meanwhile the frequent
entityIdfiltering on ordinary records — the reason the field is indexed — gets more correct after the move, because grants stop appearing in authorship results.Decided direction
GrantContentgainsgranteeEntityId?: string. Absent = default grant, applies to any authenticated entity (same semantics as today's absententityId). Becomes a DID string under RFC: identity model — entityId becomes a DID (did:key floor), _entity records become local profiles #49._grant@1— no version bump. Owner decision: no install base exists yet, so version churn buys nothing. This is also squarely RFC: replace lazy migration with explicit owner-driven migration + additive cross-app evolution #47-legal additive evolution (optional field added within a version; readers ignore unknown fields), so it won't trip the future schema-drift guard.grant()keeps its signature but writes the grantee into content and stops stampingrecord.entityId. Owner-created grants then carry noentityId, consistent with the spec's "owner records carry no entityId" invariant (which today is violated by every grant).hasGrantreadscontent.granteeEntityIdfor the grantee comparison; the in-memory check stays (default-grant reason above).Work items
GrantContentinpackages/core/src/types.ts+_grant@1schema inseedSystemTypes()(optionalgranteeEntityId)grant()write path (grantee → content; no author stamp)hasGrant()read path-ownactions on ordinary records unaffectedgranteeEntityId; note thatentityId= author now holds for system records tooNatural companion (separate backlog item, not scoped here):
revoke()/listGrants()alongsidegrant()—listGrantsgets simpler once the grantee is a queryable content field.Refs #49 (entityId → DID; grantee becomes a DID string), #47 (additive-evolution legality of the in-place change), #50 (grant prefetch unchanged), #46 (optional content-path index later), #56 (why wire content-filter gaps don't reach this path).