Skip to content

feat(core)!: split the authenticated principal from the authoring subject - #156

Merged
cuibonobo merged 21 commits into
mainfrom
claude/issue-139-plan-ggjmzg
Aug 11, 2026
Merged

feat(core)!: split the authenticated principal from the authoring subject#156
cuibonobo merged 21 commits into
mainfrom
claude/issue-139-plan-ggjmzg

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Aug 9, 2026

Copy link
Copy Markdown
Member

Closes #139.

Summary

An app that isn't the owner's fully-trusted agent can now hold its own did:key and be granted the types it needs. Modelling that as "the app is the entity" — the direction #139 originally recorded — would have made -own scoping per-app, which quietly breaks the Schema Commons premise: two apps using the same commons type could not see each other's records without an -any grant, so interop would only be reachable through the bluntest instrument available.

Instead entityId keeps meaning the author — a person or group — and the principal that authenticated is tracked separately. asEntity() takes an optional onBehalfOf naming the subject:

const scoped = stack.asEntity(appDid, { onBehalfOf: bobDid });

The principal governs authority; the subject governs attribution.

The principal decides grant lookup and setting permissions at create time. The subject decides record.entityId, -own matching, record-level permission resolution, and the getAttachment() uploader clause. Two rules sit across both: setPermissions() and _group management are each a privileged verb and an operation on a named Record, so each is asked of both identities. Unconditional owner access splits the same way — it answers what data is reachable for the subject and who may exercise a privileged verb for the principal.

Read in the other direction, an owner principal acting for someone else — what the owner's own server does when it serves a visitor — would otherwise hand that visitor the owner's own powers. So being the owner is never on its own enough under delegation: where a privileged verb has a rule to apply it is applied to both identities, and where it rests on nothing but ownership (hard delete, attachment deletion and GC, unstripped snapshot permissions, the restoreVersion() reference-gate exemption, setting an _app card's binding fields, claiming the owner's own DID on an _entity card, writing a _grant Record, and the owner's exemption from the _attachment@1 create refusal) it is refused outright.

Effective authority is the intersection of both parties' grants, so neither can lend the other reach it lacks — without it, a read-any app delegated to a read-own contributor would hand that contributor the full feed. Intersection also means a mis-issued delegation cannot escalate anyone, which matters because the binding is asserted at token issuance rather than proven per request.

Delegation is opt-in, for apps the owner installs. Software the owner never chose — a visitor's own Micropub-style client — authenticates as its user and is bounded by what people were granted, so nobody has to enumerate every client anyone might run.

Making attribution checkable

Records gain principalId, absent unless delegation actually happened, so its presence is what distinguishes verified app attribution from a self-report. appId itself is typed as the reverse-DNS string it always was rather than a RecordId, and putAttachment() gained the appId parameter it was missing, so attachments stop being the one record kind that cannot carry attribution.

principalId is only worth what the registry behind it is worth, so the check it enables is enforced where the fact is known and the registries that serve it are fenced. The check: find the _app card whose content.did equals the principal, and compare that card's content.appId to the appId being stamped. A mismatch is refused with StackPermissionError at the write, rather than stored for each reader to catch — so a stored appId on a Record carrying principalId is one the registry agreed to. A principal the owner never registered has no card to check against, so its appId stays the bare self-report it is for every undelegated writer; registering the app is what turns it into a checked claim, which is the same act that makes principalId resolvable at all.

AppContent.appId is new and required — it names the software a card is about, which is not the same question as the card Record's own appId, that being whatever wrote the card. Registering a third-party app through an admin console is the ordinary case where the two differ.

Both fields are stamped at create and never restamped, so the check answers which app wrote this Record, not which touched it last. Per-edit app attribution doesn't exist — a version snapshot carries content, associations, permissions and the author's entityId, never appId or principalId — and the spec says so rather than leaving a reader to infer that history covers it.

Three registry fields are therefore bindings — lookup keys something resolves through to reach a name the owner chose, rather than values a card happens to hold:

Field What resolves through it Unique
_entity.did a Record's entityId → who authored it yes
_app.did a Record's principalId → which app wrote it yes
_app.appId a verified principal → the appId it may claim no

Each is immutable once set, which is what stops an existing card being moved onto a value — a card bearing a name the owner trusts, repointed at a key someone else holds. Uniqueness is the separate rule, and it covers the two fields a lookup resolves by: a second card claiming a did already in use would leave "the Record whose did matches" without a single answer, and ambiguity is all an impersonating card needs.

_app.appId is immutable but not unique, and the asymmetry is load-bearing. Nothing resolves a card by it — the cross-check reaches the card through did and only compares — so uniqueness would add no disambiguation, while forbidding a state the stack has to be able to reach. appId is required, so a card for an app's new key necessarily repeats it: a unique appId would make the replacement card that deferred key rotation calls for unwritable, along with the same app registered from a second device, with no way out but hard-deleting the first card and orphaning the principalId on everything it wrote. Both cards are the owner's own to write, _app being ungrantable; what stays refused is moving an existing card onto an appId it did not hold.

_app adds two rules of its own: it is ungrantable, alongside _grant and _config, and both its binding fields are settable only by the owner acting alone, so record-level write shared on a card reaches name and version but not the fields a trust decision reads. That fence reads values, not keys. A write carrying did or appId unchanged is not setting either, so a client that reads a card, edits its name and patches back the whole content object keeps exactly the reach the rule means to leave it — update() being a merge is a calling convention, not a permission boundary. The same reading applies to the reserved owner DID below.

_entity stays grantable and _app does not, deliberately. Naming people is ordinary app work — a contacts app creates and relabels cards — so _entity is fenced by the two binding rules plus a single reserved value. Naming software decides who may speak as what, so it is the owner's call. Both registries resolve a name; only one of them is deciding whether to believe a claim.

The reserved value is the owner's own DID. It is the one _entity binding that feeds back into the stack's own identity: ownerProfile adopts whichever card holds the owner's DID rather than minting a second one, so a card written by a grantee would become the owner's profile, and uniqueness would then make that permanent. A card claiming ownerEntityId is refused to everyone but the owner acting alone, on create and on adoption. Every other DID stays open, which is the reach _entity is grantable for.

Because a binding survives soft deletion — that is what stops an undelete resurrecting a collision — the ownerProfile bootstrap counts a soft-deleted owner card as present, and for the same reason counts one migrated to a later _entity version, since uniqueness spans the whole type family. A probe blind to either would mint a second card the binding rules then refuse, leaving the stack unopenable with ownerProfile.

Containment, at every path that reaches it

A boundary is worth what its weakest path allows, so the gates that let authority leak around the intersection are closed here rather than filed:

  • Create-time permissions is refused with StackPermissionError under delegation, before the write, unless the principal is the owner — otherwise an app denied setPermissions() reached the same outcome a step earlier while authoring. An empty array carries no request and still passes.
  • setPermissions() and _group management ask both identities. Each is privileged, so the principal must hold it; each acts on a named Record, so the subject must be able to reach it. Asking only the principal would let an owner principal carry its subject to every Record in the stack — reshare one the subject cannot read, then read it — and reopen the _app route the binding rules close, record-level write on a card being reachable the same way. Undelegated, the second check asks the same question of the same identity.
  • A _grant Record is only writable by the owner acting alone. Refusing grants on _grant closes one route to authority; record-level write on a grant Record is another, reaching the same escalation by editing what an existing grant confers — its actions, typeId or granteeEntityId — instead of minting a fresh one. A grantee holding write on their own narrow grant could otherwise retarget it at any type with every action, or reassign it to someone else. ScopedStack now refuses every write path to a _grant Record, whatever the Record's own permissions say. The fence is on writes only: getVersions() and getVersion() borrow update()'s gate because history is the recovery surface, but reading how a Record you already reach came to be is not the escalation the fence stops, and a write-holder who cannot audit the grant they hold has lost the recoverability the write bit rests on. restoreVersion() is a write and stays refused. Nothing legitimate is lost: grant() and revoke() live on Stack, never on StackClient.
  • A claimed appId is checked against the registry at the write, not left to each reader, so the one attribution field a client can assert cannot disagree with the verified principal behind it. Applied to create() and putAttachment() alike.
  • Unconditional owner authority requires the owner acting alone. Hard delete, deleteAttachment(), collectAttachmentGarbage(), unstripped snapshot permissions, the restoreVersion() reference-gate exemption, setting an _app card's did or appId, claiming the owner's own DID on an _entity card, writing a _grant Record, and the owner's exemption from the _attachment@1 create refusal all rest on nothing but being the owner, so delegation carries none of them — irreversible verbs, the sharing graph, the authority table, and the decision about who may speak as what are not things an owner principal hands to whoever it is acting for.
  • Attachment bytes run the principal's verb check on _attachment@1 in the uploader clause, which previously resolved on the subject alone — an app holding no grant downloaded any file its subject uploaded, while correctly refused the record describing those bytes. Reaching a file through a readable record stays unmasked: canRead() already intersected against the type the reference lives on.
  • Default grants are people-only. "Any authenticated entity" is about people who turn up, not software the owner installed, so they no longer satisfy the principal side. Never an escalation — the subject still bounded it — but it made "reaches only the types you grant it" false on any stack using them.
  • Ungrantable families are refused again at evaluation. grant() is not the only way a _grant Record gets written — an unscoped Stack, an import, or a server mapping a request body onto Stack can all mint one — so a rule enforced only at the writing helper held only for records that went through it.
  • The wire contract documents entityId and principalId as assigned from the authenticated session and ignored on input. ScopedStack already overrode them; nothing said a server must, and a server honouring the body would make the one field meant to be unforgeable self-reported.

Legibility

Two identities mean every gate must ask which one it is about, and a mistake on the authority side is an escalation rather than a preference — so the code is arranged to make the question hard to answer wrongly. hasGrant()'s two flags mean opposite things per side of the intersection, so subjectAllows() and principalAllows() are its only callers and each names the half it asks about. The identities are named for what they govern: principalEntityId and subjectEntityId, since "requester" reads as whoever is asking and a gate added later would reach for it by default. checkAccess() takes subjectEntityId, which is what it was already being passed. The comments follow the same rule — a gate's doc names the identity it reads, or the request as a whole where both halves apply, and "requester" survives only where there is genuinely no identity to name.

The owner checks have one spelling each per question they ask. ownerActingAlone is the unconditional-authority one, so a verb that rests on ownership reads as such at a glance. The two-sided rules go through a helper asked once per identity — mayReshare(), managesGroup() — rather than two spellings of the same question. The owner-only fences on a named family each get one predicate: requireOwnerForAppIdentity(), requireOwnerForOwnerDid(), requireOwnerForGrantRecord(). Two checks stay principal-only and say why in place, since each is a different question: grant reach for the owner's own software, and create-time permissions on the subject's own record.

The binding rules are two lists rather than one, BINDING_FIELDS and UNIQUE_BINDING_FIELDS, so which fields carry uniqueness is stated where it is enforced instead of being inferred from the immutability list.

Spec

Observable behavior changes throughout. Sections updated in this PR:

  • Access control § Delegation: principal and subject — new. The governance table, intersection semantics, the -own/-any collapse on the principal side, the two rules asked of both identities, the table of verbs delegation never carries, create-time permissions, attachment bytes, and three costs stated plainly: no per-app isolation on a shared type, -own ≈ -any in a personal stack, and an app rostered as a Group admin reaching Group mutation without a _group grant
  • Access control § Type-level grants — grantee may be an app's DID; default grants excluded principal-side; the ungrantable-types list and its enforcement at evaluation, previously undocumented; and the rule that a _grant Record is writable only by the owner acting alone, scoped to writes so its history stays readable
  • Access control § EnforcementasEntity() signature; a scoped create always stamps authorship
  • Access control § Reference-creation gating — the "same capability exercised earlier" argument holds for a requester who genuinely holds setPermissions(), which delegation overrides
  • Identity § App — rewritten. Two postures, AppContent.appId and .did, the two appIds a card carries and which one the cross-check reads, and what attribution can and cannot be trusted for. The owner-only rule on the binding fields is stated as reading values rather than which keys a patch carries, so a full-content update that round-trips them is not a set. The "foundation for future enforcement" sentence is deleted, replaced by a concrete account of what is enforced and where
  • Identity § Attribution and what can be trustedappId grants nothing and is checked against the principal's card at the write, so it is a claim the registry agreed to rather than one each reader must verify; unregistered principals keep the self-report; both fields describe the write that created a Record and are never restamped; per-edit app attribution is absent, snapshots carrying neither field
  • Identity § DID bindings — new. The binding table, immutability across all three fields and uniqueness across the two a lookup resolves by, why _app.appId is exempt from uniqueness and which states that keeps writable, the owner's own DID as the one reserved value and why, why _entity stays grantable while _app does not, and the residual that leaves open
  • Identity § Entity — anything the binding rules count as holding the owner's did counts as present for the ownerProfile bootstrap: a soft-deleted card, and one migrated to a later version
  • Identity § Group — the admin-or-owner rule is asked of both identities under delegation
  • Identity § Deferred: key rotation — carding a new key is a new _entity Record rather than an edit to the old one, did being immutable; the same rule is why an app's replacement card may carry the appId its predecessor already claims
  • Data modelprincipalId on StackRecord and Filter; appId documented as reverse-DNS; and the entityId note corrected, since a scoped create always stamping authorship makes an absent entityId mean an unscoped Stack rather than an owner write
  • Wire format?principalId= query param; ?appId= on POST /attachments, since a binary body has nowhere to carry it inline; and entityId/principalId as server-assigned, which a server built on ScopedStack inherits but one mapping a body onto Stack directly must do itself
  • Attachments — the appId parameter on both putAttachment() signatures, what a scoped upload stamps now that authorship and principal are separate, and the owner's exemption from the direct-create refusal scoped to the owner acting alone
  • Versioning § History access — snapshot permissions are stripped for everyone but the owner acting as itself, delegation included; and "the same access as update()" means the same permission and grant resolution, not the owner-only fences a few families put on mutation
  • Versioning § Restore semantics — restore is a route to a write, not an exemption from what that write is allowed to be; cross-references the binding rules, and scopes the owner's exemption to the undelegated owner

Verification

pnpm run format:check && pnpm run lint && pnpm test && pnpm run build && pnpm run typecheck

All five green. 639 tests in core, 1060 across 8 packages.

Checked by hand:

  • Every gate audited for which identity it should read, in both directions of delegation — an app acting for the owner, and an owner principal acting for someone else. Most stay principal-side, and the work was proving that rather than editing them
  • The four-row intersection table is pinned as four tests, plus the undelegated no-regression case (a record shared by permission with an entity holding no type grant stays readable)
  • Escalation cases have explicit tests from both sides. A delegated app gets no owner bypass, and cannot hard delete, setPermissions, manage a group, or publish at create time, even when its subject is the owner. An owner principal cannot reshare a Record its subject did not author, reach an _app card through one, set a card's binding fields for a subject holding record-level write on it, mint an _attachment@1 card for a file its subject cannot reach, hard delete, delete attachments or run GC, manage a Group its subject doesn't administer, see snapshot permissions, or restore a reference its subject can't reach — while what the subject holds in its own right still works
  • The two owner-principal gates are pinned against the source reverted as well as fixed, so each test fails without its change rather than passing for an unrelated reason. The pre-existing _app case relies on the subject being unable to reach the card at all; the new one gives the subject record-level write, which is the case that leaves the binding rule as the only fence
  • The _grant fence is pinned on each route it closes — widening a grant's actions, retargeting its typeId, reassigning its granteeEntityId, deleting it, and resharing it — from a write-holder and from an owner principal acting for one, alongside the owner-acting-alone case that must keep working. The full verb surface was enumerated by hand to confirm every write path is refused and every read path — get(), query(), getVersions(), getVersion() — is not, with snapshot permissions still stripped in grant history and restoreVersion() still refused. grant()'s own validation is not re-run on update(), so the fence is what stands between record-level write and an arbitrary grant
  • The binding rules are pinned on the states they must leave writable as well as the ones they refuse: a second _app card claiming a registered appId under a new did, a write-holder round-tripping unchanged did/appId in a full-content patch, and a grantee relabelling the owner's own _entity card while sending its did back unchanged. Each was re-run against the source reverted and fails without its change, alongside the refusals they sit beside — did still unique, both fields still immutable, both still owner-only to set
  • The uniqueness scan short-circuits rather than materialising the family, so it is pinned on an adapter without contentFieldQuery with the colliding card past page one
  • The owner-DID reservation is pinned on both routes: minting a fresh card for ownerEntityId, and adopting it onto a card carrying no did yet (the empty-string case the binding rules treat as unset). The owner acting alone still cards their own DID, and a delegated app acting for the owner does not
  • The appId cross-check is pinned on mismatch, on match, on an unregistered principal keeping the self-report, and on an undelegated writer being unaffected — via create() and putAttachment() both. Every one of these new pins was re-run against the source reverted; each fails without its change, and the no-regression cases pass either way
  • Each impersonation route was reproduced first and re-run against the fix: claiming a DID a card already holds, moving a card off the DID it holds, giving a DID to a card that carries none, relabelling a card onto another app's appId, and repointing an _entity card at a key the write-holder controls. Alongside the owner flows that must keep working — adopting a key later, and a write-holder correcting a card's name/version
  • The cross-check is exercised end to end on the case that motivates AppContent.appId: a card registered through an admin console, where the card Record's appId names the console and content.appId names the app being registered
  • A hand-minted _grant Record naming _app or _grant is pinned as conferring nothing, since grant() refuses to write one but is not the only writer
  • The ownerProfile bootstrap is pinned against a soft-deleted owner card and against one migrated to _entity@2, the two cases where the binding reservation and the idempotency probe have to agree about what "already exists" means
  • Two pre-existing did assertions used owner-repointing in their setup, which immutability forbids. Rewritten to pin the stronger invariant, including the rollback case that is still reachable (a card adopts a DID in v2; restoring v1 is refused)
  • Three pre-existing assertions flipped by design — owner-scoped creates now carry entityId. Renamed to pin the new invariant rather than patching expected values
  • Every docs/spec/*.md § Section reference in packages/ resolves to a heading that exists, checked mechanically
  • record-adapter-sqlite's FTS4-migration fixture hand-rolls a table mirroring the shared schema, so it needed the new column; that's fixture drift, not a compatibility surface

Unrelated flake seen once: stack.test.ts > deleteAttachment > leaves no orphaned metadata when the matching record is beyond the first page (>50 records) failed on a single full-suite run, then passed 5/5 in isolation, 3/3 across core, and 4/4 on full pnpm test — and 3/3 on the commit before these changes. It exercises unscoped Stack only, so nothing here touches it. A second, different test later failed the same way on one run — ScopedStack.create — attachment association gating > attachment association referencing a file readable via another record is allowed, with an explicit Too many IDs have been generated in the same millisecond. — then passed 3/3 on re-run. That message confirms the cause: generateId() exhausts its per-millisecond counter under a fast suite, which then destabilises IncapableMemoryAdapter's cursor pagination. Neither test touches anything here, and both exercise ID generation rather than access control. Worth its own issue rather than a fix in this PR.

Notes for reviewers

Breaking changes:

  • _app@1 content requires appId — the software a card is about
  • ScopedStack.create() always stamps entityId, so an absent entityId now means an unscoped Stack wrote the record
  • ScopedStack.create() throws StackPermissionError when a delegated non-owner principal supplies permissions
  • ScopedStack.setPermissions() and _group mutation require the subject, as well as the principal, to satisfy their rule. Only reachable under delegation — undelegated, the two identities are the same entity
  • ScopedStack refuses update(), associate(), dissociate(), delete(), undelete(), restoreVersion() and setPermissions() on a _grant Record unless the caller is the owner acting alone, regardless of the Record's own permissions. Reads are unaffected — get(), query(), getVersions() and getVersion() stay on their ordinary gates. Reachable on any stack where a _grant Record carries record-level write
  • ScopedStack.create() and ScopedStack.update() refuse an _entity card claiming the owner's own did unless the caller is the owner acting alone. On update() that means a write which would introduce or change the field; re-sending the value the card already holds is not a claim
  • ScopedStack.create() and ScopedStack.putAttachment() throw StackPermissionError when a delegated write supplies an appId that disagrees with the _app card naming its principal. Only reachable where the owner has registered a card for that DID
  • Hard delete, deleteAttachment(), collectAttachmentGarbage(), unstripped snapshot permissions, the restoreVersion() reference-gate exemption, setting an _app card's did or appId, and the owner's exemption from the _attachment@1 create refusal all require an undelegated owner. Also only reachable under delegation
  • StackAdapter.putAttachmentWithMetadata() takes a fourth appId argument
  • The SQLite records table gains a principal_id column. No install base, so no migration path — per AGENTS.md

StackRecord.appId moving from RecordId to AppId is structurally identical (both alias string) and changes nothing at runtime. The binding rules are not listed here because the fields they govern are introduced by this PR, except _entity.did — which had no uniqueness or immutability to depend on.

Deliberate, and worth disagreeing with if you do:

  • Create-time permissions is refused rather than silently stripped. Narrowing a write invisibly leaves an app believing it shared something it didn't, and this was the only refusal in ScopedStack its caller couldn't see
  • A mismatched appId is likewise refused rather than stamped-and-flagged. The alternative is storing a value the registry contradicts and hoping every reader runs the comparison; since _app is ungrantable, a delegated app cannot run it, so "the reader will check" means "the owner's own code will check, if it remembers"
  • Uniqueness covers the two did fields and not _app.appId. Nothing resolves a card by appId, so uniqueness there would disambiguate nothing while making an app's second card unwritable — and appId being required means a replacement card has no choice but to repeat it. The impersonation uniqueness would be reaching for is moving an existing card onto another app's name, which immutability refuses on its own
  • The owner-only binding fence and the owner-DID reservation are evaluated against values, not against which keys a patch carries. The stricter presence reading refuses a write that changes neither field, which costs every read-modify-write client the name/version reach the rule is scoped to leave open, and disagrees with the immutability check and restoreVersion(), both of which already compare values
  • The _grant fence is on writes, not reads. A write-holder keeps get() and history on that Record. The alternative — history follows the mutation gate wherever that gate goes — is more uniform, but it would take the recovery surface away from someone who can still read the Record's present state, and snapshot permissions are stripped for them anyway, so it protects nothing the current state doesn't already disclose
  • Reaching a file through a readable record is not masked against _attachment@1. Masking twice would break the ordinary case where an app granted read-any on note@1 reads that note's attachment
  • setPermissions()'s owner-or-creator rule is asked of each identity separately and intersected, rather than resolved on one. On the principal side it is an authorship question, so a delegated app never matches the author of a record it wrote for someone else — no app inherits reach over its subject. It does match records the app authored alone, where resharing its own is a reach it holds undelegated anyway. On the subject side it is the reachability question, which is what an owner principal would otherwise answer for its subject
  • Create-time permissions stays available to an owner principal while setPermissions() narrows, because the record being authored is the subject's own — the subject is its creator, so it is reach the subject holds undelegated
  • The _attachment@1 direct-create refusal keeps its readable-reference carve-out for a delegated app acting for the owner, rather than exempting it the way an undelegated owner is. The app is naming a fileId it did not derive from bytes, which is the thing the refusal exists to fence, and putAttachment() bypasses the gate entirely for genuine uploads
  • Bindings are immutable for the owner too, not just for write-holders. An app whose key changes gets a new card — which is what key rotation being deferred already implies, a new key being a new identity rather than the same one relabelled. That card carries the same appId as its predecessor, which is the state uniqueness on appId would have made unreachable
  • A soft-deleted card still reserves its unique bindings (includeDeleted: true), which is what stops an undelete resurrecting a collision. Reusing that DID means hard-deleting the card
  • _entity keeps its owner-only exemption off for every DID but the owner's own: a contacts app granted _entity can still mint the first card for any other DID no card names yet, with whatever display name it likes. Fencing that too would mean making _entity ungrantable, which costs the use case the type exists for — and the card is attributed to whoever wrote it, so a petname's provenance stays checkable

Known limits, documented rather than fixed:

  • The uniqueness check is read-then-write, so two creates racing on one value can both pass. Closing it means a unique index over a JSON field that each adapter would enforce separately — a decision about where uniqueness lives, not a local fix
  • On an adapter without contentFieldQuery, that check cursor-walks the family per create or update, stopping at the first clash. A remote stack whose server doesn't advertise the capability pays it on every _entity write. The appId cross-check has the same shape and the same cost, on every delegated write that carries an appId
  • Binding uniqueness and immutability both treat the empty string as unset, so a card may hold did: '' and adopt a real value later. Owner-only on _app, and refused for the owner's own DID on _entity; otherwise it is the same reach a grantee already has by minting a fresh card
  • A key-holding app acting alone stamps no principalId, so its appId is neither cross-checked nor checkable, even though its DID was verified. The check covers delegated writes; an app that never delegates is attributable only by entityId
  • Group mutation asks for standing, not a grant, so an app the owner rosters as a Group admin may mutate that Group when acting for another admin while holding no _group grant — and still needs a read grant to read the Record, _group being grantable. Rostering an app's DID is therefore the same kind of trust decision as granting it a type. Narrowing it would mean a grant fence on a verb that has never had one
  • Because _app.appId is not unique, two cards may name the same software under different keys, and nothing marks either as current. Attribution stays unambiguous — a Record's principalId resolves through did, which is unique — but a UI listing "apps" by appId sees both and should group rather than assume one. Retiring a key means soft-deleting its card
  • Per-edit app attribution is absent, not merely hard to reach: appId and principalId are stamped at create and snapshots carry neither, so a Record created by a verified delegated app says nothing about the software behind any later edit. Adding it means a field on RecordVersion and a decision about what an edit's principal means when the editor holds no delegation
  • migrateAll() writes content through commitMigration(), without the binding checks. It isn't on StackClient, so ScopedStack never exposes it and it stays owner-only; reaching it would also need a user-defined _app@2 or _entity@2 migration that fabricates a binding
  • Scoped consent (OAuth-style, where the subject narrows the app's mask at token issuance) is noted as an extension point, not built. The principal's mask comes from the owner's grants; a server MAY narrow it further

Follow-ups: #138 is the natural next step — the handshake is what establishes the (principal, subject) binding this PR consumes. Intersection is what makes an owner-asserted binding safe in the meantime, so nothing here blocks on it.

…ject

An app that isn't the owner's fully-trusted agent can now hold its own
did:key and be granted the types it needs. Modelling that as "the app is
the entity" would have made -own scoping per-app, which quietly breaks the
Schema Commons premise: two apps using the same commons type could not see
each other's records without an -any grant, so interop would only be
reachable through the bluntest instrument available.

Instead entityId keeps meaning the author — a person or group — and the
principal that authenticated is tracked separately. asEntity() takes an
optional onBehalfOf naming the subject; the principal governs authority
(owner bypass, grant lookup, and the privilege-bearing gates no grant
reaches — setPermissions, group management, hard delete) while the subject
governs attribution (entityId, -own matching, the uploader clause).

Effective authority is the intersection of both parties' grants, so
neither can lend the other reach it lacks. Without it a read-any app
delegated to a read-own contributor would hand that contributor the full
feed. Intersection also means a mis-issued delegation cannot escalate
anyone, which matters because the binding is asserted at token issuance
rather than proven per request. On the principal's side -own and -any
collapse to the bare verb: which records are reachable is settled by the
subject.

Delegation is opt-in, for apps the owner installs. Software the owner
never chose — a visitor's own client — authenticates as its user and is
bounded by what people were granted, so no one has to enumerate every
client anyone might run.

Records gain principalId, absent unless delegation actually happened, so
its presence distinguishes verified app attribution from a self-report:
appId can then be checked against the _app record whose did matches, and
nothing else can. appId itself is typed as the reverse-DNS string it always
was rather than a RecordId, and putAttachment gained the appId parameter it
was missing, so attachments stop being the one record kind that cannot
carry attribution.

BREAKING CHANGE: ScopedStack.create() now always stamps entityId, so an
absent entityId means an unscoped Stack wrote the record. StackAdapter's
optional putAttachmentWithMetadata() takes a fourth appId argument.

@cuibonobo cuibonobo left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security-focused review, concentrated on the delegation model.

The design is sound and the core mechanism is right. canRead(), checkCreateGrant(), requireUpdatable() and requireDeletable() all apply the intersection correctly; principalId can't be spoofed through ScopedStack.create() because the explicit fields are spread after ...opts; native fields stay immutable through update() (content-only merge patch); and asEntity(null, { onBehalfOf }) throws. Choosing intersection over "delegation grants the union" is the right call given the binding is asserted at issuance rather than proven per request, and the reasoning in access-control.md § Delegation is unusually clear about why each half lands where it does.

The findings below are all about the gap between that stated invariant — "an app can do only what both it and its subject may do" — and the places the code doesn't hold it. Two of them are reachable today:

  • getAttachment() bypasses the principal check (line 2069). An app with zero grants downloads any file its subject uploaded. This one directly contradicts the | none | read-any | denied | row in the spec table.
  • Create-time permissions isn't gated (line 2169). A contained app can publish its subject's records as public, even though setPermissions() is deliberately withheld from it.

The rest are hardening and doc/code alignment: default grants silently apply to app principals, _app@1 is now integrity-bearing but isn't protected like _grant/_config, and the "owner bypass keys on the principal" rule has an exception for record-level reads.

Both reachable findings were verified against d1fa8f4 with throwaway tests. The existing suite passes in full, so these are coverage gaps rather than regressions — worth a delegated-app case in scoped-stack.test.ts for each once fixed.

Not raising the missing principal_id migration for existing SQLite databases, per the maintainer's note that there's no install base to worry about yet.


Generated by Claude Code

Comment thread packages/core/src/stack.ts Outdated
Comment thread packages/core/src/stack.ts
Comment thread packages/core/src/stack.ts
Comment thread packages/core/src/stack.ts
Comment thread packages/core/src/stack.ts Outdated
claude added 20 commits August 9, 2026 02:21
Both cases are reachable on this branch and neither had coverage, which
is why the suite stayed green through them. They assert the behaviour
docs/spec/access-control.md § Delegation already promises, so they fail
until the implementation catches up.

- Create-time `permissions` is not gated, so a contained app can publish
  its subject's records as `public` — the reach setPermissions()
  deliberately withholds from it, reached one step earlier while the app
  is still authoring the record.

- canAccessFile()'s uploader clause resolves against the subject alone,
  with no principal-side verb check, so an app holding no grant at all
  downloads any file its subject uploaded. The record describing those
  bytes is correctly refused, which is the contrast the test pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYd2Xkp79bEfdrJhCkL4zD
Five findings from review, four of them holes in the containment the spec
already claimed and one an inaccuracy in how that claim was written down.

canAccessFile() returned true on the subject alone, so an app holding no
grant at all downloaded any file its subject had uploaded — while being
correctly refused the record describing those same bytes. Reaching a file
through a readable record stays unmasked, since canRead() already
intersected against the type the reference lives on; it is the uploader
clause that needed the principal's verb check, because deciding which
files the subject authored is not itself a grant. Owner-ness there now
resolves against the subject under delegation, matching how record reads
already behaved.

Create-time permissions passed through untouched, so an app denied
setPermissions() reached the same outcome one step earlier by publishing
records as it authored them. The existing argument for that passthrough —
create-time is the same capability exercised earlier — holds for a human
contributor who genuinely has setPermissions(), and not for a principal
denied it by design. It is now dropped under delegation unless the
principal is the owner.

Default grants satisfied the principal side of the intersection, so an app
granted nothing inherited every type carrying one. The subject still
bounded the result, so this was never an escalation, but it made the
README's "reaches only the types you grant it" false on any stack using
default grants. "Any authenticated entity" means the people who turn up,
not software the owner installed.

_app became integrity-bearing the moment principalId → _app.did → appId
became the chain distinguishing verified attribution from a self-report,
so it joins _grant and _config as ungrantable, and did is now unique
within a stack on create and update alike. Without uniqueness "the _app
record whose did matches" has no single answer, and ambiguity is all an
impersonating card needs.

Finally, owner bypass was documented as keying on the principal when it
had always keyed on the subject for record-level resolution. Both halves
are intended: the subject answers what data is reachable, the principal
who may exercise a privileged verb. The table now says so, and
setPermissions()'s creator clause says why it asks an authorship question
of the authority identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQ17SYRNUcRNuJKHJDfESh
Stripping them silently left the app believing it had shared something it
hadn't. A containment boundary an app cannot see is not one it can respect,
and every other refusal in ScopedStack is visible to its caller — this one
had no reason to be the exception.

The check runs before the write, so a refused create leaves nothing behind.
An empty permissions array carries no request and still passes.

BREAKING CHANGE: ScopedStack.create() throws StackPermissionError when a
delegated principal that is not the owner supplies permissions, where it
previously created the record without them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQ17SYRNUcRNuJKHJDfESh
create() and update() both refused a card claiming a DID already in use;
restoreVersion() did not, so a snapshot taken while a card held a DID put
it back after another card had legitimately claimed it. Two cards then
answered "the _app record whose did matches", which is the ambiguity the
rule exists to prevent.

Reachable without being the owner. Registering a card is owner-only since
_app is ungrantable, but record-level write on one is shareable, and write
is all restoreVersion() asks for.

restoreVersion() already re-ran validateContent and the _config entityId
guard, so this closes the gap rather than opening a new path: every write
that carries a did now pays the same check. Costs one query, and only when
the restored record is an _app card.

The check remains read-then-write, so concurrent creates of the same DID
can still both pass. That race needs the owner registering one DID twice
at once; closing it means a unique index over a JSON field that every
adapter would enforce separately, which is a decision about where
uniqueness lives and not one this rule should make. Documented in
identity.md § App rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYd2Xkp79bEfdrJhCkL4zD
`principalId` is the one field that answers "which principal did this"
with something a client cannot assert, but the wire contract said only
that POST /records "accepts a full record body" — and WireRecord now
carries the field, so a server taking the body at face value would hand
clients forgeable app attribution and defeat the _app cross-check that
reads it. State that entityId and principalId are assigned from the
authenticated session and ignored on input, and pin it with fixtures:
one where a body carrying both is discarded, one showing the delegated
pair a reader is meant to trust.

The upload appId reached the wire spec but not attachments.md, whose
three putAttachment() signatures still omitted the parameter and whose
ScopedStack line still described entityId as the uploading entity rather
than the subject. Fixtures gain the ?appId= case, so the query param is
exercised rather than only described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAp5uwgtw4wvG1QssGVLtn
Uniqueness stops a second card claiming a DID, but nothing stopped an
existing card being moved onto one, which reaches the same impersonation
by another route: a holder of record-level write on a card could repoint
it at a key they control and keep the name the owner gave it, so every
record naming that principal resolved to the owner's chosen app name.
The registry is what stands between "this DID authenticated" and "this
is My Notes App", and the check reading it cannot be worth more than the
binding behind it.

A card's DID binding is therefore permanent — update() and
restoreVersion() both refuse to change or clear it — and adopting one,
the single remaining transition, is reserved to the stack owner. That
suits a model where key rotation is deferred and a new key is a new
identity rather than the same one relabelled: an app whose key changes
gets a new card. Record-level write still reaches a card's other fields,
so a write-holder may correct a name or version and is refused only the
field a trust decision reads.

Restore no longer needs its own uniqueness check. Immutability subsumes
it: a restore can only put back a DID this same card already holds, and
any rollback that would move the binding is refused before uniqueness
would have been consulted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAp5uwgtw4wvG1QssGVLtn
hasGrant() takes two flags whose correct setting differs per side of the
delegated intersection — -own matching applies to the subject and not the
principal, default grants count for the subject and not the principal —
and neither means anything on its own. Four call sites set them by hand,
which is where a later change gets one wrong without a test noticing.
Give the subject side the wrapper the principal side already had, so
subjectAllows()/principalAllows() are the only callers and every gate
names the half it is asking about.

setPermissions()'s comment claimed the principal never equals an
authorship field under delegation. It does for records the app authored
alone, where the creator clause fires and the app may reshare its own —
a reach it holds undelegated anyway. State the narrower thing that is
true: no app inherits reach over the subject it writes for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAp5uwgtw4wvG1QssGVLtn
Three registries carry a field something resolves *through* to reach a
name the owner chose: a record's entityId through _entity.did, its
principalId through _app.did, and a claimed appId through _app.appId.
Only the second was bound, so the other two reached the same
impersonation by another route.

_app gains appId in content — the software the card is *about*, distinct
from the card record's own appId, which names whatever wrote the card.
An admin console registering a third-party app is the ordinary case where
those differ, and the cross-check read the wrong one.

_entity gains uniqueness and immutability. It stays grantable, because
naming people is what a contacts app does; only _app adds the owner-only
rule, because naming software decides who may speak as what. Uniqueness
and immutability generalise into one pair of helpers over a table of
binding fields, so a fourth binding is a table entry rather than a
fourth special case.

The residual is documented rather than closed: a grantee holding create
on _entity can still mint the first card for a DID no card names yet.

Also here, because each is a line or a paragraph and none stands alone:

- Ungrantable families are refused again at evaluation. grant() is not
  the only way a _grant record gets written — an unscoped Stack, an
  import, or a server mapping a request body onto Stack can mint one —
  so a rule enforced only at the writing helper held only for records
  that went through it.
- grant()'s own docs, the spec, and the README say what a delegated -own
  grant does not contain, since an owner reaching for the narrowest
  suffix to fence an app buys almost nothing in a personal stack.
- The reference-creation gating section no longer states flatly that
  create-time permissions is ungated, which delegation overrides.
- The refusal message names the families actually refused.

BREAKING CHANGE: _app@1 content requires appId.
ScopedStack holds two identities and a mistake between them is an
escalation, not a preference, so the neutral name was the risky one:
"requester" reads as whoever is asking, and a gate added later reaches
for it by default. It is specifically the authority identity, so it is
principalEntityId, paired with subjectEntityId.

checkAccess() takes the other one — record-level permissions are written
about who data is for — so its parameter is subjectEntityId, which is
what ScopedStack was already passing it.

Mechanical throughout; no behavior change.
The idempotency probe filtered out soft-deleted records while the binding
rules reserve a `did` regardless of deletion. The two disagreed the moment
an owner soft-deleted their own `_entity` card: the probe missed it, the
bootstrap created a second card, and uniqueness refused the write — so
`Stack.create({ ownerProfile })` threw StackConflictError on every
subsequent open, with no way back that didn't involve undeleting the card
out of band.

A deleted card now counts as present, matching what the reservation
already says about it. Getting the card back is `undelete()`'s job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DC1gkXAi1KkZ5Jv4bcxQ77
Deciding who else reaches a Record is a privileged verb, so the principal
must hold it — but it also acts on a specific Record, so the subject must
be able to reach that Record. Asking only the principal left an owner
principal, which passes the rule unconditionally, able to carry its
subject to every Record in the stack: the subject could reshare a Record
it could not read, then read it. That also reopened the _app route the
binding rules close, since record-level write on a card was reachable the
same way, and a card carrying no did yet could then be pointed at a key
the subject controls.

Both identities must now independently be the owner or the Record's
author. Undelegated the second check asks the same question of the same
identity, so nothing changes there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DC1gkXAi1KkZ5Jv4bcxQ77
Every gate that asked only "is the principal the owner?" answered yes for
an owner principal acting for someone else, which is what the owner's own
server does when it serves a visitor. That handed the visitor the owner's
powers: hard delete, attachment deletion and garbage collection, the
unstripped snapshot permissions that disclose the sharing graph, and the
restoreVersion() exemption from the reference-reconveyance gate — none of
which the subject could reach on its own.

Verbs resting on nothing but ownership now require the owner acting as
itself, behind one named `ownerActingAlone`. Group management gets the
two-sided treatment setPermissions() already has, since it does have a
rule to apply to each side. Three checks stay principal-only on purpose,
each a different question: grant reach for the owner's own software,
create-time permissions on the subject's own record, and who may set an
_app card's bindings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DC1gkXAi1KkZ5Jv4bcxQ77
…owner alone

Two gates still asked only whether the principal was the owner, so an owner
principal acting for someone else carried its subject past both.

Setting an _app card's did or appId is the trust decision the registry
exists to record. Record-level write on a card is shareable, so a subject
holding it could have the owner's own server point that card at a key the
subject controls, or relabel it onto another app's appId — the impersonation
the binding rules close, reached from the other side.

The owner's exemption from the _attachment@1 create refusal has the same
shape. The refusal fences a caller naming a guessed fileId, and the uploader
clause that turns such a name into a read matches record.entityId — the
subject a scoped create stamps, not the principal claiming the exemption. A
subject holding create on _attachment@1 could therefore name any fileId and
download the bytes.

Both now require the owner acting alone, joining the verbs delegation never
carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BukQpRVNPQjssVuwoRSeCZ
appId and principalId are stamped at create and never restamped, so the
cross-check answers which app wrote a Record, not which touched it last.
Worth stating now that principalId is trust-bearing: a reader who takes it
for current-state attribution gets a stale answer on any edited Record.

Version history is not a way around it — a snapshot carries content,
associations, permissions and the author's entityId, never appId or
principalId — so per-edit app attribution is absent rather than merely
inconvenient to reach.

Also corrects the data model's entityId note, which still described the
rule a scoped create always stamping authorship replaced, and which
Access control already describes the new way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BukQpRVNPQjssVuwoRSeCZ
…pe version

The bootstrap probe filtered typeId `_entity@1` while binding uniqueness
matches across the whole `_entity` family. A stack whose owner card had been
migrated to a later version therefore reopened by minting a second card, and
the binding rules refused it — leaving the stack unopenable with
ownerProfile, the same failure the soft-deleted case avoids.

The probe now filters by baseId and reads through Stack.query, so it is blind
to nothing uniqueness sees. Both blind spots have the same cause: the probe
and the rule have to agree about what "already exists" means, on every axis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNFf2nZdZ9tgRhMKtsuPZu
…ppId

Three facts the model already knew were left for a reader to check rather
than refused at the write. Each is now checked where it is known.

A _grant Record is authority itself. Refusing grants *on* _grant closes one
route to it; record-level write on a grant Record is another, reaching the
same escalation by editing what an existing grant confers — its actions,
typeId or granteeEntityId — instead of minting a fresh one. A grantee holding
write on their own narrow grant could retarget it at any type with every
action, or reassign it to someone else. ScopedStack now refuses every
mutating verb on a _grant Record, whatever its permissions say. Nothing
legitimate is lost: grant() and revoke() live on Stack, never StackClient.

The owner's own DID is the one _entity binding that feeds back into the
stack's identity. ownerProfile adopts whichever card holds it rather than
minting a second, so a card written by a grantee holding create on _entity@1
becomes the owner's profile — and uniqueness then makes that permanent. It
is now reserved to the owner acting alone, on create and on adoption. Every
other DID stays open, which is the reach _entity is grantable for.

A claimed appId is checkable but was unchecked. principalId is verified, so
a delegated write naming an appId the principal's own _app card does not
carry is a claim the registry disagrees with — refused now, rather than
stored for each reader to catch. Principals the owner never registered keep
appId as the bare self-report it is for every undelegated writer.

All three ask the owner acting alone, joining the verbs delegation never
carries: none is a reach an owner principal lends to a subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNFf2nZdZ9tgRhMKtsuPZu
The principal/subject rename reached the fields but not the prose. Comments
on gates that are now identity-specific still said "requester", which is the
word this PR's own rationale warns about: it reads as whoever is asking, so a
gate added later reaches for it by default. Each now names what it asks
about — the subject for authorship and reachability, the request as a whole
where both halves apply. The anonymous-caller messages keep "requester",
where there is genuinely no identity to name.

Two references pointed at "§ Delegation", which is not a heading; the section
is "Delegation: principal and subject".

Access control claimed a contained app is refused Group management outright.
An app the owner rosters as a Group admin is not: standing, not a grant, is
what the two-sided rule asks for, so such an app may mutate that Group
holding no _group grant — while still needing a read grant to read the Record,
since _group is grantable. Mutating a Group widens access to everything shared
with it, so the asymmetry is worth stating where the rule is described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNFf2nZdZ9tgRhMKtsuPZu
Four references named "§ Optimistic concurrency"; the heading carries the
parameter it documents, "Optimistic concurrency (`ifVersion`)". Section names
are the link, so a near-miss is a dead one. Every spec reference in the
packages now resolves to a heading that exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNFf2nZdZ9tgRhMKtsuPZu
The owner-write-only rule reached further than intended. getVersions() and
getVersion() borrow update()'s gate, because history is the recovery surface
rather than a read one — so fencing that gate took history away from a
write-holder who can still read the Record's current state.

That costs more than it protects. Reading how a Record you already reach came
to be is not the escalation the fence exists to stop, and a write-holder who
cannot audit the grant they hold has lost exactly the recoverability the
write bit rests on. Snapshot permissions are stripped there as everywhere, so
a grant's history discloses no more of the sharing graph than its present.

requireUpdatable() now takes `mutating`, defaulting to true so a gate added
later is fenced unless it says otherwise, and the two history readers pass
false. restoreVersion() is a write and stays refused, even though reading the
snapshot it would restore does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNFf2nZdZ9tgRhMKtsuPZu
Three rules introduced with the bindings reached past the thing they exist
to stop.

`_app.appId` was unique as well as immutable, which made an app's second
card unwritable. `appId` is required, so a replacement card for the same
software necessarily repeats it — and uniqueness then refused exactly the
card key rotation calls for, and the same app registered from a second
device, with no way out but hard-deleting the first card and orphaning the
`principalId` on everything it wrote. Uniqueness bought nothing there:
nothing resolves a card *by* `appId`, the cross-check reaching it by `did`
and only comparing, so `_app.did` alone already gives that lookup its single
answer. Immutability is what stops a card being moved onto a name it did not
have, and immutability stays. Uniqueness now covers the two `did` fields —
the ones something resolves by — and `UNIQUE_BINDING_FIELDS` names that
subset rather than leaving it implied by the immutability list.

The `_app` and owner-DID fences keyed on a field being *present* in the
patch, not on its value changing. Read-modify-write is the ordinary client
shape, so a write-holder sending a card back with `name` edited was refused
for round-tripping bindings it never touched — which made "`name` and
`version` stay writable" false for any client that patches with a whole
content object, and disagreed with `checkBindingImmutable()` and
`restoreVersion()`, both of which already compare values. Both fences now
ask whether the write would introduce or alter the field.

The uniqueness scan materialised the whole family through `queryAllPages()`
before testing it, so a family past that helper's cap turned every create
and update into a `StackQueryError` rather than a conflict. It now uses
`findFirstMatch()`, the same bounded walk the `appId` cross-check added
beside it, and stops at the first clash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017JmFgdZKqgqPFGoxMBgjwn
@cuibonobo
cuibonobo merged commit c84bac2 into main Aug 11, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/issue-139-plan-ggjmzg branch August 11, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: App identity in the multi-app topology — an app instance is an entity

2 participants