feat(core)!: split the authenticated principal from the authoring subject - #156
Conversation
…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
left a comment
There was a problem hiding this comment.
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
permissionsisn't gated (line 2169). A contained app can publish its subject's records aspublic, even thoughsetPermissions()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
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
Closes #139.
Summary
An app that isn't the owner's fully-trusted agent can now hold its own
did:keyand be granted the types it needs. Modelling that as "the app is the entity" — the direction #139 originally recorded — would have made-ownscoping 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-anygrant, so interop would only be reachable through the bluntest instrument available.Instead
entityIdkeeps meaning the author — a person or group — and the principal that authenticated is tracked separately.asEntity()takes an optionalonBehalfOfnaming the subject:The principal decides grant lookup and setting
permissionsat create time. The subject decidesrecord.entityId,-ownmatching, record-level permission resolution, and thegetAttachment()uploader clause. Two rules sit across both:setPermissions()and_groupmanagement 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, therestoreVersion()reference-gate exemption, setting an_appcard's binding fields, claiming the owner's own DID on an_entitycard, writing a_grantRecord, and the owner's exemption from the_attachment@1create 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-anyapp delegated to aread-owncontributor 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.appIditself is typed as the reverse-DNS string it always was rather than aRecordId, andputAttachment()gained theappIdparameter it was missing, so attachments stop being the one record kind that cannot carry attribution.principalIdis 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_appcard whosecontent.didequals the principal, and compare that card'scontent.appIdto theappIdbeing stamped. A mismatch is refused withStackPermissionErrorat the write, rather than stored for each reader to catch — so a storedappIdon a Record carryingprincipalIdis one the registry agreed to. A principal the owner never registered has no card to check against, so itsappIdstays 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 makesprincipalIdresolvable at all.AppContent.appIdis new and required — it names the software a card is about, which is not the same question as the card Record's ownappId, 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,permissionsand the author'sentityId, neverappIdorprincipalId— 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:
_entity.didentityId→ who authored it_app.didprincipalId→ which app wrote it_app.appIdappIdit may claimEach 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
didalready in use would leave "the Record whosedidmatches" without a single answer, and ambiguity is all an impersonating card needs._app.appIdis immutable but not unique, and the asymmetry is load-bearing. Nothing resolves a card by it — the cross-check reaches the card throughdidand only compares — so uniqueness would add no disambiguation, while forbidding a state the stack has to be able to reach.appIdis required, so a card for an app's new key necessarily repeats it: a uniqueappIdwould 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 theprincipalIdon everything it wrote. Both cards are the owner's own to write,_appbeing ungrantable; what stays refused is moving an existing card onto anappIdit did not hold._appadds two rules of its own: it is ungrantable, alongside_grantand_config, and both its binding fields are settable only by the owner acting alone, so record-levelwriteshared on a card reachesnameandversionbut not the fields a trust decision reads. That fence reads values, not keys. A write carryingdidorappIdunchanged is not setting either, so a client that reads a card, edits itsnameand 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._entitystays grantable and_appdoes not, deliberately. Naming people is ordinary app work — a contacts app creates and relabels cards — so_entityis 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
_entitybinding that feeds back into the stack's own identity:ownerProfileadopts 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 claimingownerEntityIdis refused to everyone but the owner acting alone, on create and on adoption. Every other DID stays open, which is the reach_entityis grantable for.Because a binding survives soft deletion — that is what stops an undelete resurrecting a collision — the
ownerProfilebootstrap counts a soft-deleted owner card as present, and for the same reason counts one migrated to a later_entityversion, 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 withownerProfile.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:
permissionsis refused withStackPermissionErrorunder delegation, before the write, unless the principal is the owner — otherwise an app deniedsetPermissions()reached the same outcome a step earlier while authoring. An empty array carries no request and still passes.setPermissions()and_groupmanagement 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_approute the binding rules close, record-levelwriteon a card being reachable the same way. Undelegated, the second check asks the same question of the same identity._grantRecord is only writable by the owner acting alone. Refusing grants on_grantcloses one route to authority; record-levelwriteon a grant Record is another, reaching the same escalation by editing what an existing grant confers — itsactions,typeIdorgranteeEntityId— instead of minting a fresh one. A grantee holdingwriteon their own narrow grant could otherwise retarget it at any type with every action, or reassign it to someone else.ScopedStacknow refuses every write path to a_grantRecord, whatever the Record's ownpermissionssay. The fence is on writes only:getVersions()andgetVersion()borrowupdate()'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()andrevoke()live onStack, never onStackClient.appIdis 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 tocreate()andputAttachment()alike.deleteAttachment(),collectAttachmentGarbage(), unstripped snapshotpermissions, therestoreVersion()reference-gate exemption, setting an_appcard'sdidorappId, claiming the owner's own DID on an_entitycard, writing a_grantRecord, and the owner's exemption from the_attachment@1create 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@1in 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.grant()is not the only way a_grantRecord gets written — an unscopedStack, an import, or a server mapping a request body ontoStackcan all mint one — so a rule enforced only at the writing helper held only for records that went through it.entityIdandprincipalIdas assigned from the authenticated session and ignored on input.ScopedStackalready 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, sosubjectAllows()andprincipalAllows()are its only callers and each names the half it asks about. The identities are named for what they govern:principalEntityIdandsubjectEntityId, since "requester" reads as whoever is asking and a gate added later would reach for it by default.checkAccess()takessubjectEntityId, 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.
ownerActingAloneis 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-timepermissionson the subject's own record.The binding rules are two lists rather than one,
BINDING_FIELDSandUNIQUE_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:
-own/-anycollapse on the principal side, the two rules asked of both identities, the table of verbs delegation never carries, create-timepermissions, attachment bytes, and three costs stated plainly: no per-app isolation on a shared type,-own ≈ -anyin a personal stack, and an app rostered as a Groupadminreaching Group mutation without a_groupgrant_grantRecord is writable only by the owner acting alone, scoped to writes so its history stays readableasEntity()signature; a scoped create always stamps authorshipsetPermissions(), which delegation overridesAppContent.appIdand.did, the twoappIds 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 whereappIdgrants 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_app.appIdis exempt from uniqueness and which states that keeps writable, the owner's own DID as the one reserved value and why, why_entitystays grantable while_appdoes not, and the residual that leaves opendidcounts as present for theownerProfilebootstrap: a soft-deleted card, and one migrated to a later version_entityRecord rather than an edit to the old one,didbeing immutable; the same rule is why an app's replacement card may carry theappIdits predecessor already claimsprincipalIdonStackRecordandFilter;appIddocumented as reverse-DNS; and theentityIdnote corrected, since a scoped create always stamping authorship makes an absententityIdmean an unscopedStackrather than an owner write?principalId=query param;?appId=onPOST /attachments, since a binary body has nowhere to carry it inline; andentityId/principalIdas server-assigned, which a server built onScopedStackinherits but one mapping a body ontoStackdirectly must do itselfappIdparameter on bothputAttachment()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 alonepermissionsare stripped for everyone but the owner acting as itself, delegation included; and "the same access asupdate()" means the same permission and grant resolution, not the owner-only fences a few families put on mutationVerification
All five green. 639 tests in
core, 1060 across 8 packages.Checked by hand:
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_appcard through one, set a card's binding fields for a subject holding record-levelwriteon it, mint an_attachment@1card for a file its subject cannot reach, hard delete, delete attachments or run GC, manage a Group its subject doesn't administer, see snapshotpermissions, or restore a reference its subject can't reach — while what the subject holds in its own right still works_appcase relies on the subject being unable to reach the card at all; the new one gives the subject record-levelwrite, which is the case that leaves the binding rule as the only fence_grantfence is pinned on each route it closes — widening a grant'sactions, retargeting itstypeId, reassigning itsgranteeEntityId, 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 snapshotpermissionsstill stripped in grant history andrestoreVersion()still refused.grant()'s own validation is not re-run onupdate(), so the fence is what stands between record-levelwriteand an arbitrary grant_appcard claiming a registeredappIdunder a newdid, a write-holder round-tripping unchangeddid/appIdin a full-content patch, and a grantee relabelling the owner's own_entitycard while sending itsdidback unchanged. Each was re-run against the source reverted and fails without its change, alongside the refusals they sit beside —didstill unique, both fields still immutable, both still owner-only to setcontentFieldQuerywith the colliding card past page oneownerEntityId, and adopting it onto a card carrying nodidyet (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 notappIdcross-check is pinned on mismatch, on match, on an unregistered principal keeping the self-report, and on an undelegated writer being unaffected — viacreate()andputAttachment()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 wayappId, and repointing an_entitycard 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'sname/versionAppContent.appId: a card registered through an admin console, where the card Record'sappIdnames the console andcontent.appIdnames the app being registered_grantRecord naming_appor_grantis pinned as conferring nothing, sincegrant()refuses to write one but is not the only writerownerProfilebootstrap 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" meansdidassertions 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)entityId. Renamed to pin the new invariant rather than patching expected valuesdocs/spec/*.md § Sectionreference inpackages/resolves to a heading that exists, checked mechanicallyrecord-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 surfaceUnrelated 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 acrosscore, and 4/4 on fullpnpm test— and 3/3 on the commit before these changes. It exercises unscopedStackonly, 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 explicitToo 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 destabilisesIncapableMemoryAdapter'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@1content requiresappId— the software a card is aboutScopedStack.create()always stampsentityId, so an absententityIdnow means an unscopedStackwrote the recordScopedStack.create()throwsStackPermissionErrorwhen a delegated non-owner principal suppliespermissionsScopedStack.setPermissions()and_groupmutation require the subject, as well as the principal, to satisfy their rule. Only reachable under delegation — undelegated, the two identities are the same entityScopedStackrefusesupdate(),associate(),dissociate(),delete(),undelete(),restoreVersion()andsetPermissions()on a_grantRecord unless the caller is the owner acting alone, regardless of the Record's ownpermissions. Reads are unaffected —get(),query(),getVersions()andgetVersion()stay on their ordinary gates. Reachable on any stack where a_grantRecord carries record-levelwriteScopedStack.create()andScopedStack.update()refuse an_entitycard claiming the owner's owndidunless the caller is the owner acting alone. Onupdate()that means a write which would introduce or change the field; re-sending the value the card already holds is not a claimScopedStack.create()andScopedStack.putAttachment()throwStackPermissionErrorwhen a delegated write supplies anappIdthat disagrees with the_appcard naming its principal. Only reachable where the owner has registered a card for that DIDdeleteAttachment(),collectAttachmentGarbage(), unstripped snapshotpermissions, therestoreVersion()reference-gate exemption, setting an_appcard'sdidorappId, and the owner's exemption from the_attachment@1create refusal all require an undelegated owner. Also only reachable under delegationStackAdapter.putAttachmentWithMetadata()takes a fourthappIdargumentprincipal_idcolumn. No install base, so no migration path — per AGENTS.mdStackRecord.appIdmoving fromRecordIdtoAppIdis structurally identical (both aliasstring) 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:
permissionsis 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 inScopedStackits caller couldn't seeappIdis likewise refused rather than stamped-and-flagged. The alternative is storing a value the registry contradicts and hoping every reader runs the comparison; since_appis ungrantable, a delegated app cannot run it, so "the reader will check" means "the owner's own code will check, if it remembers"didfields and not_app.appId. Nothing resolves a card byappId, so uniqueness there would disambiguate nothing while making an app's second card unwritable — andappIdbeing 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 ownname/versionreach the rule is scoped to leave open, and disagrees with the immutability check andrestoreVersion(), both of which already compare values_grantfence is on writes, not reads. A write-holder keepsget()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 snapshotpermissionsare stripped for them anyway, so it protects nothing the current state doesn't already disclose_attachment@1. Masking twice would break the ordinary case where an app grantedread-anyonnote@1reads that note's attachmentsetPermissions()'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 subjectpermissionsstays available to an owner principal whilesetPermissions()narrows, because the record being authored is the subject's own — the subject is its creator, so it is reach the subject holds undelegated_attachment@1direct-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 afileIdit did not derive from bytes, which is the thing the refusal exists to fence, andputAttachment()bypasses the gate entirely for genuine uploadsappIdas its predecessor, which is the state uniqueness onappIdwould have made unreachableincludeDeleted: true), which is what stops an undelete resurrecting a collision. Reusing that DID means hard-deleting the card_entitykeeps its owner-only exemption off for every DID but the owner's own: a contacts app granted_entitycan 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_entityungrantable, which costs the use case the type exists for — and the card is attributed to whoever wrote it, so a petname's provenance stays checkableKnown limits, documented rather than fixed:
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_entitywrite. TheappIdcross-check has the same shape and the same cost, on every delegated write that carries anappIddid: ''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 cardprincipalId, so itsappIdis neither cross-checked nor checkable, even though its DID was verified. The check covers delegated writes; an app that never delegates is attributable only byentityIdadminmay mutate that Group when acting for another admin while holding no_groupgrant — and still needs a read grant to read the Record,_groupbeing 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_app.appIdis not unique, two cards may name the same software under different keys, and nothing marks either as current. Attribution stays unambiguous — a Record'sprincipalIdresolves throughdid, which is unique — but a UI listing "apps" byappIdsees both and should group rather than assume one. Retiring a key means soft-deleting its cardappIdandprincipalIdare 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 onRecordVersionand a decision about what an edit's principal means when the editor holds no delegationmigrateAll()writes content throughcommitMigration(), without the binding checks. It isn't onStackClient, soScopedStacknever exposes it and it stays owner-only; reaching it would also need a user-defined_app@2or_entity@2migration that fabricates a bindingFollow-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.