Skip to content

_grant records store the grantee in entityId, the authorship field — move it into GrantContent #57

Description

@cuibonobo

Problem

Stack.grant() writes the grantee into record.entityId — the field that everywhere else means "author of this record":

records.push(
  await this.create(
    `${SYSTEM_TYPES.GRANT}@1`,
    { typeId: g.typeId, actions: g.actions },
    entityId ? { entityId } : {},   // grantee stamped as author
  ),
);

(packages/core/src/stack.ts:696–701)

Three consequences:

  1. 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.
  2. Authorship queries are polluted. 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, where entityId becomes a DID and identity gets load-bearing, records-by vs. records-about is exactly the distinction that must stay crisp.
  3. -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:

const result = await this.stack.query({
  filter: {
    typeId: `${SYSTEM_TYPES.GRANT}@1`,
    ...(this.stack.features.contentFieldQuery && { content: { typeId } }),
  },
});
// ...grantee checked in memory:
if (r.entityId && r.entityId !== this.requesterEntityId) return false;

(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" (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.

Decided direction

  1. GrantContent gains granteeEntityId?: string. Absent = default grant, applies to any authenticated entity (same semantics as today's absent entityId). Becomes a DID string under RFC: identity model — entityId becomes a DID (did:key floor), _entity records become local profiles #49.
  2. In-place change to _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.
  3. 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).
  4. hasGrant reads content.granteeEntityId for the grantee comparison; the in-memory check stays (default-grant reason above).
  5. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions