Skip to content

Latest commit

 

History

History
401 lines (271 loc) · 66.2 KB

access-control.md

File metadata and controls

401 lines (271 loc) · 66.2 KB

Access Control

Access control in a Stack has two complementary layers: record-level permissions (per-record sharing) and type-level grants (per-type delegation). ScopedStack enforces both; plain Stack is unscoped and performs no checks — correct for single-entity embedded use, where there's no requester distinct from the app itself.

Two questions next to this one have their own documents: whether a Record is enumerable, which is separate from who may read it, is Unlisted records; and what a requester learns when either layer refuses them is Refusals and disclosure.

Refused at the write, and again at evaluation

grantType(), grantAccess() and the change-set keys are not the only ways authority-bearing state reaches storage. An unscoped Stack, a JSON import, a server mapping a request body, an adapter write and a foreign server's response all produce permission elements and _grant Records that no helper vetted and no compiler saw.

So every rule below that decides reach is enforced twice: refused at the write, and read as conferring nothing at evaluation. A rule held only at the writing helper would hold only for what went through it, and what it was meant to refuse would instead store, answer 200, and deny forever — a share that looks like it worked and never did. Where a rule below says it is enforced "again at evaluation", this is what it means; the argument is not restated at each one.

Three consequences are general rather than per-rule:

  • Reach is never spelled by absence. No dropped field widens anything — an absent grantee, an unknown kind, an empty entityId or groupId confers nothing rather than falling back to something wider.
  • An element or Record the evaluator does not recognize conveys nothing, in either layer, whatever the rule at the write would have said about it.
  • A grant's target is checked on every _grant write, not only in grantType(). create(), mutate(), commitMigration() and POST /records all hold baseId to a bare family that is not a protected system type, next to the grantee check; a stored grant whose baseId carries an @version suffix confers nothing.

Record-level permissions

All Records are private by default — readable only by the stack owner. The permissions field is absent or empty on private records; there is no explicit private permission value. Permissions represent grants of access, not restrictions. Enforcement lives in ScopedStack (below), so every adapter inherits it; storage adapters read and write the field without consulting it, and it decides nothing on an unscoped Stack.

A permission is an association. It has element identity, so granting and revoking are a plain add and remove, and the same delta the association tier already computes describes it exactly. That is what lets a permission change be journalled like any other edge rather than snapshotted as a whole list — see Journal § The entry.

type GroupRole = 'member' | 'admin';

// The arms both layers share — a type-level grant's grantee widens this.
type Grantee =
  | { kind: 'entity'; entityId: string } // a DID
  | { kind: 'group'; groupId: string; role: GroupRole };

// Absence of permissions (empty or undefined) = private, owner only.
type PermissionAssociation = {
  kind: 'permission';
  label: 'read' | 'write';
  grantee: Grantee;
};

type AnyoneAssociation = {
  kind: 'anyone';
  label: 'read';
};
  • Bits are labels. A grantee reaching a Record for reading and writing carries two elements, not one element with two booleans, so the element's identity carries its whole meaning and there is no "revoke by writing false" spelling.
  • anyone is its own kind. It names no grantee and carries no bit beyond read, so it cannot be spelled as a permission whose grantee went missing. Reach to the world is affirmatively written; no dropped field produces it. read is correspondingly the whole of what the element can say: one labelled anything else is refused at the write, and read as naming no reach at evaluation — it conveys no read of its own, and does not stand in for the read a write element needs beside it. Same posture the permission arm takes toward a grantee it does not recognize, and for the same reason.
  • role is required. There is no "any member" default: role: 'member' is the wider set and role: 'admin' the narrower, matching the roster labels where admin implies member (see Group).
  • A permission's grantee is its own shape, not a RelationshipTarget, which has no role and no group arm. It is the same Grantee a type-level grant names, so one value reaches either layer.

Group permissions reference a _group Record by ID. The group may be a simple permission group or a collaborative group with its own stack — the permission model is the same either way.

Permission resolution:

  • no elements — owner only
  • anyone — any requester can read
  • a permission whose grantee is an entity — compare its entityId to the requester's DID directly
  • a permission whose grantee is a group — fetch the referenced _group Record; where it is live and in the family, walk its relationship associations to determine the requester's role, and the element is satisfied if role: 'admin' is set and the requester is an admin, or if role: 'member' is set and the requester is a member or admin

Only a live _group Record carries a roster. A groupId naming a Record outside the _group family, or one naming a soft-deleted Group, resolves to no role and the element confers nothing — the same rule type-level grants apply to a group grantee. Rosters are read from ordinary relationship associations, which any Record may carry: without the family check, an app modelling its own member or admin links (a project and the people on it, say) would turn every Record a permission was pointed at into an ACL, and a Record migrated out of _group would keep resolving after it stopped being a group. The tombstone check is the same rule at the other end of a Group's life: deleting a Group is how a Group is withdrawn, so one that stands deleted reaches nobody until it is undeleted — see Identity § Deleting a Group withdraws it. The groupId is still not format-checked, unlike a caller-named parentId: it is read on the permission path rather than asserted on a write, and one that resolves to nothing simply denies.

Resolving a _group Record that lives in a different stack from the Record being accessed needs read access to that stack, which ScopedStack does not have — see Known limitation.

Storage unifies; the API does not

Permission elements share a table, a delta shape and a durability tier with ordinary associations. They stay a separate call surface, because they carry different authority.

  • A Record presents two fields. associations and permissions are projections over one stored set, partitioned by kind. An app editing tags is never shown the authority half, so a round trip through associations cannot drop it.
  • Two change-set keys, each replacing only within its own domain. A permission or anyone kind named in the associations key — or a tag, attachment or relationship named in permissions — is a StackBadRequestError, not a validation error: the caller named the wrong surface rather than a malformed value. The refusal lives in Stack, so every writer is held to it.
  • associate()/dissociate()/amendAssociations() refuse authority kinds. They are gated on the write bit alone, which is exactly why: an authority element reaching them would let a write-holder grant themselves access.
  • grantAccess()/revokeAccess()/amendAccess() are the record-level ACL verbs, mirroring the type-level grantType()/revokeType(), and carry the reshare gate below. They amend the set where the permissions key replaces it — which is the spelling that survives two admins sharing one Record at once, since the later of two concurrent key writes drops what the earlier granted. Same concurrency shape as an associations key write, for the same reason.

The reshare gate reads the computed delta, not the elements a caller supplied: any add, remove or repoint of a permission or anyone element, in either direction, requires reshare authority. A gate that inspected only what was named would miss the wholesale replacement that drops everything, which names nothing at all. A permissions key that restates the set moves nothing, and alongside a key the write bit already carries it is not a reshare.

A key the gate read as inert is dropped, not forwarded. The delta is computed against the Record the gate read; the write recomputes it against the Record as it then stands. Those are two reads, so a key left standing on the strength of the first would replace the ACL on the strength of the second — and the set a caller who cannot reshare supplied is, by then, one a concurrent grant has already moved past. Dropping it is not the silent narrowing create-time permissions is refused to avoid: that key carries a request, and this one has just been shown to carry none. A caller who can reshare keeps the ordinary replacing semantics, concurrency and all — which is why grantAccess()/revokeAccess() remain the spelling that survives two admins sharing one Record at once.

A change set naming no such key is the one exception, and it reshares on the key's presence: the gate is chosen before a record is in hand to compute a delta against, and reading one ungated to decide would answer whether a Record exists to a requester who may not read it. So a permissions key sent on its own asks reshare authority whether or not it moves anything — the refusal a caller who cannot reshare would have earned for any other spelling of the same key.

Three things are therefore impossible for a write-holder, who cannot reshare: wiping the ACL through a wholesale associations write, granting themselves a permission element, and revoking someone else's.

The write bit: a recoverability trust model

Record-level write stays a single coarse bit — no per-verb fencing (update vs associate vs delete) at the record level. That's a deliberate scale decision: per-record sharing among a small, cohesive group doesn't call for maintained per-verb ACLs. What makes one bit safe is this:

Anything a write-holder does, the owner can undo. Recoverability is the backstop, not per-verb precision.

Verb, via a write element Reversible? How
patchContent, a change set's contentPatch Yes Versioned — restoreVersion() undoes it
associate / dissociate, a change set's associations Yes Directly invertible — the opposite call (dissociate undoes associate and back) reconstructs the prior state exactly, with no version history involved — see Versioning § Version history
A change set's parentId Yes Directly invertible — the journal entry names where the Record came from, and the opposite call puts it back, with no version history involved — see Versioning § Version history
restoreVersion Yes Update-shaped; itself creates a new, restorable version
delete (soft) Yes undelete() reverses it — the owner can always undelete, regardless of who deleted
Purge Never reachable via write Owner-only, unconditionally — see below

Operations that sit outside the write bit entirely, regardless of grants:

  • Purge is owner-only (see Deletion) — it destroys the Record and its version history, so there's nothing left to undo. An irreversible verb has no place in a bit whose entire safety argument is recoverability.
  • Resharing — grantAccess()/revokeAccess(), the permissions key and the unlisted key — is owner-or-creator-only. A write-holder who is neither cannot change who else can access the Record — otherwise a write element would let its holder escalate to granting others access, defeating the point of scoping access in the first place. Same pattern as purge: a privilege-bearing operation stays outside the coarse bit. (restoreVersion() reinforces this from the other direction — it restores content and typeId and nothing else, so a content rollback can't silently change who has access, or a Record's roster, tags or listing state, either.)
  • _group Records opt out of the write bit (and type-level grants) entirely, for every mutating verb including grantAccess()/revokeAccess() and a change set's permissions key. A Group's own permissions/grants govern who can read it, not who can manage it. See Group for the admin-or-owner rule that replaces it.

This is about the served topology: for adapter-local, direct adapter access is full trust and the permission model doesn't apply — Stack is unscoped by design. Record-level permissions exist for the requester on the far side of a server, who has no direct database access. For them, the write bit is the only fence, so what it permits has to hold up as a real policy surface — which is exactly why everything it reaches has to be undoable by the owner.

Write implies read

Mutation is never blind. No grantee holds write on a Record it cannot read — a read for the same grantee beside it, or an anyone that already reaches everyone — and a Grant conveys a mutate action only alongside a read action of matching scope.

Recoverability is what makes one coarse bit safe, and undo means seeing prior content — so the mutate surface hands back the Record it wrote and opens the Record's whole history, which is retroactive and deliberately not time-sliced. A requester holding write with no read would therefore be refused by get() and query() while reading current and every historical content through mutate(), getVersions() and restoreVersion(). The ACL would say strictly less than it does. Both layers refuse the combination instead:

Written Refused because
A write element with no read reaching the same grantee The write bit is inert without one, entity and group alike
Revoking a read, or an anyone, while its write stands The same rule, reached from the side where a removal blinds a writer
Grant ['update-any'], ['delete-any'] A -any verb needs read-any — read over the reach it can mutate
Grant ['read-own', 'update-any'] Scope mismatch: reading one's own is not reading what it may mutate
Grant ['update-own'], ['delete-own'] A -own verb needs read-own or read-any

Because the bits are separate elements, this is a cross-element invariant, not a check on one element: no element alone can tell whether a write means anything. It is asked of the permission set the write would produce, the same way the _group at-least-one-admin check reads its post-state — which is what makes revokeAccess() of a read refusable while the matching write still stands.

An anyone element satisfies it for every grantee. A world-readable Record leaves no writer blind, so a second read naming the writer would withhold nothing and refusing the set would be friction, not safety.

The post-state reading is what keeps that honest from the other direction, and the rule it enforces is the same one: no write lands in a set whose grantee cannot read it, whichever verb or key produced the set. So withdrawing the anyone while a write it was covering stands is refused — element-wise through revokeAccess(), or named in a permissions key that keeps the write — while the spellings that drop the write in the same breath are ordinary writes: permissions: [] makes the Record private in one call, and a key naming the writer's own read in place of the anyone swaps one for the other in one call. Nothing here asks the owner to unwind in a particular order; only the set a call would produce is asked about.

write never implies read. A caller grants both by naming both: grantAccess(id, [read, write]) is one atomic write, and write alone is refused. The stored set holds exactly what was named — filling in read for the caller would leave a read behind when the write is revoked, would make revokeAccess(read) refuse for an element the owner never wrote, and would have the permissions key store more than it was given. amendAccess() makes a downgrade (remove write, keep read) one atomic write.

grantAccess()/revokeAccess()/amendAccess(), the permissions key at create time and after, and grantType() reject these with StackValidationError, and evaluation refuses them again: a write element with no read beside it, or a Grant carrying mutation without read, confers nothing however it came to exist.

The invariant rests on history being gated on the mutate surface: it is getVersions()/getVersion() sitting there, rather than on plain read, that makes "a write-holder can already see this Record" the fact the rule trades on.

The read action must sit in the same _grant Record, not merely somewhere in the grantee's set. A grant is revoked whole, at the granularity grantType() writes: a rule satisfied across two Records would let revoking the read one leave a mutate-without-read grant standing — arriving at exactly the configuration this rule refuses, without anyone having written it. Each grant is coherent on its own, so no revocation can produce an incoherent pair.

What this buys is an invariant the mutate gate already assumed: anything that passes it can be read by the same requester. getVersions()/getVersion() gate on the mutate surface precisely so that history isn't as public as the present, and this closes the reverse — the past never reaches a requester denied the present. The one deliberate exception is _group, which opts out of the write bit entirely: Group management is owner-or-admin (see Group), and an admin may mutate a Group — and read its history — without a read grant on _group. Roster standing, not a withheld read bit, is what carries it.

Blind write is offered, and it is create. Contribute-without-reading — a tip box, a contact form, comments held for moderation, an app filing telemetry — is a create grant with no read action, and that combination is deliberately left alone: writing a Record you cannot read back discloses nothing. The contributor can neither re-read their own submission nor enumerate the type; query() returns nothing to them and the owner sees everything in the box. putAttachment() follows the same gate, so a drop box can take files. Three edges are worth knowing before building one:

  • Anonymous requesters are always refused, default grant or not, so a contributor needs a DID — cheap to mint client-side, and it becomes the Record's author. An open box (a default create grant) is an unauthenticated-adjacent surface in every way that matters for abuse; rate limiting is the server's business, not the permission model's.
  • Parenting into a container needs read on the container — parentId is a reference, gated like any other, at creation and on a later move alike (see Reference-creation gating). Share the container Record with a read element and no write (it holds no submissions; each is its own private Record), or use no parent and correlate through a content field or tag.
  • The contributor keeps no sent copy. That's the federated shape — a sender who wants one writes it to their own stack — not something the box can hand back.

What is not offered is blind mutation of an existing Record. A content patch is defined over content the requester would not be able to see, ifVersion needs a version number that comes from a read, and the write bit's recoverability argument requires prior content by construction. A write-only surface is a total write; nothing in this model is one.

Type-level grants

A Grant authorises one or more Entities to perform specific actions on Records of a given Type, without touching individual records — a read-any grant on comment@1 makes all comments of that type readable by the grantee without setting permissions on each one. Grants are modeled as Records of the built-in system type _grant, making them queryable, versioned, and subject to the same lifecycle as any other Record.

type GrantContent = {
  baseId: BaseId; // The type family this grant covers — never a versioned TypeId
  actions: GrantAction[]; // Which actions are permitted
  grantee: GrantGrantee; // Who it reaches — required
};

type GrantGrantee =
  | Grantee // One DID, or a `_group` Record's roster at a role; a group never satisfies a delegated principal
  | { kind: 'authenticated' }; // Any authenticated entity

type GrantQuery = // What listTypeGrants() accepts
  | Exclude<GrantGrantee, { kind: 'group' }>
  | { kind: 'group'; groupId: string; role: GroupRole | 'any' };

type GrantAction =
  | 'create' // Create new records of this type
  | 'read-own' // Read records where record.createdBy.subjectId === requester
  | 'read-any' // Read all records of this type
  | 'update-own' // Update records where record.createdBy.subjectId === requester
  | 'update-any' // Update all records of this type
  | 'delete-own' // Delete records where record.createdBy.subjectId === requester
  | 'delete-any'; // Delete all records of this type

The grantee lives in content.grantee, not in createdBy. createdBy means "author" on every Record in the system, and a _grant Record is written by the stack owner through grantType() on an unscoped Stack — never by the entity or group it names. So "everything this entity authored" queries (filter: { createdBy: { subjectId } }) don't pick up grants that merely name that entity.

Stack.grantType(baseId, { actions, grantee }) is the owner-facing helper for creating a grant record; Stack.listTypeGrants(query?) and Stack.revokeType(baseId, { actions, grantee }) are the read/undo counterparts. Every type-level verb carries Type in its name and the record-level verbs carry Access, so a call site says which layer it touches. baseId is a bare family (com.example/comment). A versioned TypeId (com.example/comment@1) is refused with StackValidationError naming the family to pass instead, as is anything malformed: a grant reaches the whole family (see What a grant covers), and accepting a version would leave a grant that reads as pinned to one version while covering them all. The shape mirrors the record-level verbs: grantAccess(id, permission) names the subject first and carries the grantee inside the element, and so does grantType() — the type is the subject. A grantee is a GrantGrantee — the same union a stored grant carries, so the argument is the grantee it writes. A query is a GrantQuery: the same union, widened by one listing-only value described below.

// Grant a specific entity permission to create comments and manage their own
await stack.grantType('com.example/comment', {
  actions: ['create', 'read-own', 'update-own', 'delete-own'],
  grantee: { kind: 'entity', entityId: 'bob-entity-id' },
});

// Grant a _group Record's roster — `member` reaches members and admins alike
await stack.grantType('com.example/comment', {
  actions: ['create', 'read-any'],
  grantee: { kind: 'group', groupId: 'editors-group-id', role: 'member' },
});

// Default grant — applies to any authenticated entity, and to no anonymous requester
await stack.grantType('com.example/comment', {
  actions: ['create', 'read-own'],
  grantee: { kind: 'authenticated' },
});

await stack.listTypeGrants(); // every grant record, any grantee
await stack.listTypeGrants({ kind: 'authenticated' }); // only default grants
await stack.listTypeGrants({ kind: 'entity', entityId: 'bob-entity-id' }); // grants naming Bob, grants naming a group he belongs to at a role he holds, plus every default grant — what currently applies to him
await stack.listTypeGrants({ kind: 'group', groupId: 'editors-group-id', role: 'member' }); // grants naming that exact group and role
await stack.listTypeGrants({ kind: 'group', groupId: 'editors-group-id', role: 'any' }); // every grant naming that group, whichever role

// The inverse of grantType(): soft-deletes the _grant record(s) matched by
// baseId, action set and grantee — the same granularity grantType()
// writes at. The grantee is matched whole, role included.
// Returns the grants it withdrew, as they stood. An empty array means
// nothing matched — a grant already withdrawn, or never written.
const withdrawn = await stack.revokeType('com.example/comment', {
  actions: ['create'],
  grantee: { kind: 'entity', entityId: 'bob-entity-id' },
});
await stack.revokeType('com.example/comment', {
  actions: ['create'],
  grantee: { kind: 'group', groupId: 'editors-group-id', role: 'member' },
});

What a grant covers

  • No wildcard baseId: there is no * or catch-all. Every grant is opt-in per type. Adding a new type never implicitly inherits existing grants — it starts default-deny.

  • Some system types can't be granted at all: grantType() refuses _grant, _config, _app and _install. Each would hand the grantee the machinery the model rests on — minting their own grants, rewriting stack ownership, registering an app card claiming a DID that isn't theirs (see App), or approving an app's install (see App installs). Other reserved types (_attachment, _entity, _group) stay grantable. The refusal is enforced again at evaluation: a _grant Record naming one of these families confers nothing, however it came to exist.

  • 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, baseId, or grantee — rather than by minting a fresh one. So ScopedStack refuses mutate(), patchContent(), associate(), dissociate(), amendAssociations(), grantAccess(), revokeAccess(), amendAccess(), delete(), undelete() and restoreVersion() on any _grant Record with StackPermissionError, whatever the Record's own permissions say, and delegation does not carry it (see Delegation). Nothing legitimate is lost: grantType() and revokeType() live on Stack, never on StackClient, so a scoped caller has no business writing one. An _install Record is fenced on the same terms, since it decides which grants exist (App installs § The _install record).

  • commitMigration() is owner-acting-alone, and no grant substitutes for it. Moving a Record between type families is not something record-level write or an update grant confers, in any combination: ScopedStack.commitMigration() refuses every requester but the owner acting alone, delegation included. The one exception is an installed app migrating within the families its install claims, to a version the owner approved — see App installs § Migrating an installed app's types. This mirrors the bulk path — migrateAll() lives on Stack and is absent from StackClient for the same reason grantType()/revokeType() are — so the per-record verb carries the restriction the family-wide one already had, instead of introducing a grant model beside it.

    The restriction is what makes the verb safe to expose. commitMigration() replaces content and typeId wholesale, so it is create-shaped at the destination and update-shaped over the Record as it stands: a grant-based version would have to re-derive every gate create() applies and every gate mutate() applies, and would reopen each one it missed. The sharpest is the non-owner _attachment@1 refusal (see Attachments) — a requester holding a create grant on _attachment@1 and write access to any Record they authored could otherwise migrate that Record into the family naming any fileId, then read the bytes through the uploader clause. Ordinary write access to a Record is not consent to move it between families.

The fence is on writes only. get(), query(), getVersions() and getVersion() on a _grant Record stay on their ordinary gates. Reading how a Record you can already reach came to be is not the escalation the fence exists to stop, and taking history away would leave a write-holder unable to audit the grant they hold — history is the recovery surface, so losing it costs more than it protects. A snapshot carries no permissions at all (see Versioning § Version history), so a grant's history discloses no more of the sharing graph than its current state does. restoreVersion() is a write and stays refused, even though reading the snapshot it would restore does not. _config is already unreachable through Stack.get(); _app keeps record-level write for its display fields, fenced only on the bindings a trust decision reads (see DID bindings).

  • Defining a Type is owner-acting-alone too. defineType() lives on Stack and is absent from StackClient, so a scoped caller has no route to it, and a server serves POST /types to the owner acting alone and to no one else. A Type is read by every app that touches its family, so defining one is at least as stack-wide as committing a migration, and no grant names it.

  • Grants target the type family, not the exact version: a grant naming com.example/comment covers com.example/comment@1 and @2 alike — matching is by the stored baseId, and a grant whose baseId carries an @n suffix names no family and confers nothing. This keeps a version bump from silently orphaning existing grants (grants are checked in memory before any migration applies). revokeType() matches at the same granularity.

  • Actions are independent, with one dependency: 'create' does not imply 'read-own', and so on — each action must be listed explicitly. ['create', 'read-own', 'update-own', 'delete-own'] is the common bundle for contributor access. The dependency is that a mutate action needs a read action of matching scope in the same grant, or it conveys nothing; 'create' alone stays valid and is the blind-write shape.

  • -own scope: -own actions apply only to Records whose author — record.createdBy.subjectId — is the requester. Records with no createdBy (written by an unscoped Stack) do not satisfy any -own check.

  • baseId and actions are read as data at evaluation, exactly as the grantee is. A _grant Record reaching storage any other way carries whatever shape it arrived with, so evaluation asks for both rather than taking them from the type: a baseId that is not a non-empty string, or that carries an @version suffix, names no family, an actions that is not a list names no verbs, and an entry the action vocabulary does not hold is dropped from the list rather than read. The Record then confers nothing, instead of conferring what a looser reading of the field would produce. actions is the sharpest of the three, because the loose reading is not a hypothetical: membership in a string is substring matching, so one naming several verbs would answer for each of them, and a mutate verb would find its required read companion spelled in the same string. revokeType() reads the stored list rather than the recognized one, so a grant carrying an action the vocabulary drops is not withdrawn by a target that omits it — a malformed grant confers nothing and is withdrawn by deleting the Record, which listTypeGrants() still returns.

Who a grant reaches

  • Default grants (grantee: { kind: 'authenticated' }): apply to any authenticated entity. Useful for "any logged-in user can comment" scenarios. Anonymous requesters (no entityId) are always denied, even under a default grant. They also do not count on the principal's side of a delegated request (see Delegation): "any authenticated entity" means the people who turn up, not software the owner installed, so a contained app reaches only the types it is named in.
  • The grantee may be an app: an entity grantee's entityId is a DID, and an app that holds its own key has one — so granting an installed app the types it needs is the existing model applied, not new machinery (see App). When such an app acts for a person, the -own/-any distinction on its grant collapses to the bare verb; see Delegation.
  • A group grantee names a role, and member is the wider one. role: 'member' is satisfied by any entity on the roster, admins included; role: 'admin' only by an admin — the same reading a record-level permission's group grantee has, because an admin association implies member. The role is required, so the wider tier is chosen rather than arrived at, and the two layers say "the editors" the same way. The named Record must be a live one in the _group family, on the same terms and for the same reasons a record-level group grantee is.
  • Group-targeted grants do not count on the principal's side of a delegated request, for the same reason default grants don't (see Delegation) — one step removed. A _group roster is editable by any of its admins, not only by the stack owner, so a grant reaching a principal through a roster would let someone other than the owner name an app to a type the owner never named it to. The rule is about how the authority arrived, not who holds it, which is what makes it enforceable: a roster entry is an opaque DID and an _app Record's did is optional, so nothing can reliably tell an app's DID from a person's. An owner who means to grant an app names it directly, one grant at a time — the same shape as any other capability system that has to name software. Group grants still apply to the subject under delegation; only the principal half refuses them.
  • Every tier is spelled, and a target must name one. grantType(), revokeType() and listTypeGrants() reject a target naming no kind, an empty entityId, an empty groupId, or a group target without a role, with StackBadRequestError. The _grant schema requires grantee, so a Record arriving any other way is refused at validation rather than stored. A schema cannot say which fields each arm carries, since a closed object field holds one properties set, so the arm's own fields are required on every write instead: create(), patchContent(), restoreVersion() and commitMigration() refuse a _grant whose grantee names an unknown kind, or whose entity arm carries no entityId, or whose group arm carries no groupId or no member/admin role, with StackValidationError. And evaluation reads the tier off kind again: a grantee that is absent, names an unknown kind, or carries an empty entityId or groupId confers nothing.
  • The two "everyone" tiers reach different audiences, and share no word. A Grant's { kind: 'authenticated' } reaches any entity that turned up with a DID; a Record permission's { kind: 'anyone' } reaches every requester, anonymous included. An anonymous requester is denied under every grant, so the wider tier exists only on the record layer, where world-readability is the thing being asked for. Naming them apart is what keeps "a default grant makes this public" from being a reasonable reading.
  • Group roster resolution is memoized per operation. Resolving a group grantee re-fetches the _group Record the same way a record-level permission's group grantee does (walking relationship associations), so it costs the same per-group lookup. The resolved roles are cached for the lifetime of one operation and threaded alongside prefetchedGrants — exactly the lifetime that has — so a query() examining many Records resolves a given roster once instead of once per candidate. Deliberately not cached for the life of a ScopedStack: asEntity()/asActor() return an object a caller may hold for as long as it likes, and a cache outliving the operation would let removal from a group go unnoticed by that instance. Revocation is the direction an authorization cache must never fail in.

Granting a group grants everyone its admins ever add. A _group roster is managed by the stack owner and by any entity holding an admin association on it, and an admin may appoint further admins. So a group-targeted grant is a standing delegation, not a fixed list: whoever holds admin on that group decides, from then on, who the grant reaches. This is what delegating group management means, and it is bounded in two ways — the owner outranks the roster, so ownership can never be locked out of a group and pruning is always available; and roster-derived authority stops at the principal boundary (above), so it can never reach an app acting for someone. An owner who wants a roster only they can change appoints no other admins: a group begins with exactly one admin, its creator, and plain members hold no roster authority at all. Where one group would need two levels of trust, use two groups.

Groups are intended for people, and nothing enforces that. A roster entry is an opaque DID; the system has no notion of "person" to check against, and an _app Record's did field is optional, so an app operating without one is indistinguishable from a person. Treat "members are people" as a convention of your own data, never as a guarantee the library upholds — the guarantee that does hold is the principal-side rule above, which is written to be independent of telling the two apart.

Listing and revoking

A revocation is a soft delete like any other mutation — the owner can undelete() it the same as an accidental delete anywhere else.

Deletion is the only lifecycle state that withdraws a grant. A _grant Record is read with unlisted Records included, so withholding one from enumeration changes nothing about what it confers: listing state decides which queries enumerate a Record, never what it means, and a grant silently disarmed by an unlisted key would be a revocation spelled by a flag that is defined not to revoke. The same read serves listTypeGrants() and revokeType(), so an unlisted grant stays visible to the owner who wants to withdraw it — a grant that still confers and cannot be found is the one state worth refusing outright.

  • listTypeGrants() answers identity, except for the entity arm. listTypeGrants(X) returns the grants grantType(X, …) would have written with the same argument — a group query matches the stored grantee whole, role included, so the listing and revokeType() never disagree about what an argument names. The entity arm is the exception and answers coverage: the grants that currently apply to that entity, which includes grants naming a group they belong to and every default grant. That result is therefore not a preview of what revokeType() would withdraw — feeding it back to revokeType() would aim at grants that were never written for that entity. Coverage is well-formed only for a real principal; a group and a role describe a grant, not someone who could hold one.
  • A group listing widens affirmatively, with role: 'any'. listTypeGrants({ kind: 'group', groupId, role: 'any' }) returns every grant naming that group, whichever role it carries — the one question exact matching cannot ask. 'any' is listing-only: it is not a role an entity can hold, grantType() and revokeType() reject it, and it can never reach storage, so a query can say it and a grantee cannot. Spelling the widening this way rather than by omitting role keeps the rule above intact — breadth is never spelled by absence, in a query any more than in a grantee.
  • revokeType() returns what it withdrew, and matching nothing is not an error. The result is the grants as they stood, so a caller can say what was taken away, and an empty array says plainly that nothing matched. It stays a no-op rather than a failure because revokeType() matches on an exact actions set and the whole target: a grant already withdrawn, or never written, is the ordinary case, and re-running a revocation must be safe. This mirrors revokeAccess(), where an element the Record does not carry is a no-op returning the Record as it stands. The shapes differ because the targets do: revokeAccess() edits one Record, while one revokeType() target can match several _grant Records — grantType() does not deduplicate, and grants written twice against the same family match alike.

The two layers use different granularities

Record-level write is one coarse bit (above); grants are precise per verb. Type-wide access for a third-party app warrants verb precision in a way per-record sharing among intimates doesn't. associate()/dissociate()/amendAssociations() don't get their own grant action — they ride update-own/update-any, the same as content changes, keeping the grant vocabulary from growing a verb for every mutation kind.

Enforcement: Stack.asEntity()

The core library ships a permission-enforcing wrapper so server implementations don't need to reimplement resolution logic. stack.asEntity(entityId) — entityId is null for an anonymous/unauthenticated requester — returns a ScopedStack: the same surface as Stack, but every operation is checked against both permission layers. If either the Record's own permissions or a matching _grant record permits the action, access is granted. The owner always has full access and bypasses both checks.

Use asEntity() when one Stack instance serves requests from multiple, possibly untrusted, entities — e.g. a server adapter.

Resolution may be cached for the lifetime of a single request. A server MAY resolve the requester's grants and Group memberships once and reuse that snapshot for every check within the same request without deviating from this spec — the per-operation memoization ScopedStack already does, described above. Caching across requests is out of scope here: a grant revoked between two requests must take effect on the second, so any longer-lived cache needs an invalidation story this spec does not define.

ScopedStack.create() additionally checks _grant records for a 'create' action on the target type before allowing the Record to be written. Anonymous requesters are always denied. The owner always passes. The created Record's createdBy is always set to the requester, so -own grants apply to it immediately — a scoped write always names its author, so an absent createdBy means an unscoped Stack wrote the Record.

Delegation: principal and subject

An app that holds its own key may act on behalf of a person (see App). asActor() takes both identities as one Actor:

// The app authenticated; the comment is Bob's.
const scoped = stack.asActor({ subjectId: bobDid, principalId: appDid });

Two identities are then in play, and one rule separates them:

The principal governs authority; the subject governs attribution.

Governed by the principal (who authenticated) Governed by the subject (who it's for)
Grant lookup for the app's own reach createdBy.subjectId on writes
Setting permissions at create time -own matching
Record-level permissions resolution
The getAttachment() uploader clause

Two rules sit across both columns rather than in either. Resharing and _group management are asked of both identities: each is a privileged capability, so the principal must hold it, and each acts on a named Record, so the subject must be able to reach that Record. Both identities must independently satisfy the rule — owner-or-creator to reshare, owner-or-admin for a Group. Requiring only the principal would let an owner principal — software the owner trusts unconditionally — carry its subject to Records the subject could not otherwise touch, which is the reach create() already withholds.

Omitting principalId, or giving it equal to subjectId, makes the two the same entity — the undelegated case, where the second check asks the same question of the same identity. asEntity(did) is shorthand for asActor({ subjectId: did }). An Actor always names a subject, so an anonymous principal has no way to act on behalf of anyone.

A ScopedStack reports the pair it acts as through read-only principalId and subjectId, named after the Actor fields asActor() took, so code handed a view needn't carry its identity separately. Both are null for an anonymous view, and principalId equals subjectId when undelegated. asEntity() and asActor() are the only way to obtain one: the class is exported for instanceof checks, but its constructor refuses any caller but Stack.

A server at its request boundary passes the session a token names straight through, since a TokenSession is an Actor:

const session = await tokens.lookupToken(bearer); // { subjectId, principalId }
const scoped = stack.asActor(session);

Both identities are DIDs, so a positional pair would leave nothing to catch a swap — and a swapped pair is undetectable in the undelegated case, where the two are equal. It would surface only once delegation is in use, as authority no longer fenced by the app's grants and every write attributed to the app rather than the person it acted for. Naming both fields removes the order to get wrong.

Unconditional owner access splits across both identities, rather than belonging to one. It answers what data is reachable against the subject — an owner subject passes every record-level permission check — and who may exercise a privileged verb against the principal. So an app delegated for the owner reaches what the owner can, on the types it was granted, and still cannot purge, manage a group, or decide who else sees a Record.

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. Being the owner is therefore never on its own enough under delegation. Where a privileged verb has a rule to apply, it is applied to both identities; where it rests on nothing but ownership, it is refused outright:

Verb Why delegation doesn't carry it
includeUnlisted on query()/subscribe() Enumeration standing rests on nothing but ownership — see Unlisted records
Purge Irreversible; the subject holds soft delete already
deleteAttachment(), collectAttachmentGarbage() Irreversible, and neither takes a Record to gate on
The restoreVersion() reference-gate exemption The gate resolves against the subject, whose reach a restore would widen
Setting an _app card's did or appId Names who may speak as what — the owner's trust decision, not a reach lent to a subject holding record-level write
Writing a _grant Record The Record is authority; deciding it is the owner's call, and no scoped caller writes one legitimately
Claiming the owner's own DID on an _entity card ownerProfile adopts whichever card holds it, so the stack's own profile is not a reach lent to a subject
The owner's exemption from the _attachment@1 create refusal The refusal fences a guessed fileId, and the uploader clause that would then match resolves against the subject

Everything else an owner principal does still runs the subject's checks first, so a subject is never carried past one by the software acting for it: delete() and restoreVersion() through the update/delete gate, resharing and Group management through the two-sided rule above, reads through the ordinary permission and grant path.

The one place an owner principal is deliberately unbounded is grant lookup — it is the owner's own software, so its side of the intersection is not fenced by grants. The subject's side still is, which is what keeps that from being a way around anything.

The right-hand column is why delegation is worth having: -own keeps meaning "this person's Records, through whichever app they used", so two apps writing the same commons type still interoperate. The principal's column is why it is safe. Those operations have no grant fence at all — resharing has only owner-or-creator behind it, and _group mutation bypasses grants entirely — so resolving them against the subject alone would let any delegated app reshare its subject's data or seize a group it was never granted. An app with no standing of its own is refused them outright.

Standing, not a grant, is what the two-sided rule asks for, and that has a consequence worth naming: an app the owner rosters as a Group admin may mutate that Group when acting for another admin, holding no grant on _group at all — while still needing a read-any/read-own grant to read the Group Record, since _group is grantable. Mutating a Group widens access to every Record shared with it, so rostering an app's DID as admin is the same trust decision as granting it a type, and should be made as deliberately.

Refusing a delegated reshare only contains an app if the same reach isn't available a step earlier, so permissions passed to create() is refused under delegation with StackPermissionError, unless the principal is the owner. The general argument that create-time permissions is "the same capability exercised earlier, not a new one" holds for a human contributor, who genuinely does hold it after the fact. It does not transfer to a principal denied that capability by design.

The create is refused outright rather than written with the permissions quietly stripped: silently narrowing a write leaves the app believing it shared something it didn't, and a containment boundary an app can't see isn't one it can respect. An empty permissions array carries no request and is not refused.

Attachment bytes follow the records that describe them. Reaching a file through a Record the requester can read is already intersected — the check ran against that Record's own type. The uploader clause is not a second grant: it decides which files the subject authored, so the principal still needs a read verb on _attachment@1 of its own.

Effective authority is the intersection of both parties' grants. An app can do only what both it and its subject may do:

App's grant Subject's grant Effective
read-any none denied
none read-any denied
read-any read-own subject's own
read-own read-any everything

Neither party can lend the other reach it lacks. This matters because the principal/subject binding is asserted when a token is issued, not by the app itself: intersection means a mis-issued delegation cannot escalate anyone, so the binding is a question of correctness rather than a security boundary. A server MAY narrow the principal's side further from what the subject consented to at issuance — OAuth-style scopes — but nothing in core requires it.

On the principal's side of the intersection, -own and -any mean the same thing: the question asked of the app is only whether it may perform this verb on this type at all, since which Records are reachable is settled by the subject. The suffix keeps its ordinary meaning for every undelegated principal.

Only a grant naming the principal directly counts on its side. Neither a default grant nor a group-targeted one satisfies the principal half — both would let an app be named by someone other than the owner (by nobody at all, or by any admin of a granted group), and containment rests on the owner having named it. Both still count on the subject's side, where they mean what they ordinarily do. See Type-level grants.

Two consequences worth stating plainly rather than leaving to be discovered:

  • Per-app isolation on a shared type is not offered. Two apps both granted commons/note@1 for the same person see the same notes. Containment is per type — an app reaches only the types the owner granted it — which is what keeps shared commons types interoperable by default.
  • In a personal stack, a delegated -own grant is close to -any. Nearly every Record is owner-authored, so -own covers nearly all of them. The precision of -own pays off in multi-person stacks, not single-owner ones. An owner reaching for read-own to contain an app should know it buys almost nothing there — containment comes from which types the app is granted, not from the suffix.

Reference-creation gating

A create grant on a type authorizes writing Records of that type — it does not, by itself, authorize referencing arbitrary other Records or files through that Record. ScopedStack.create() and ScopedStack.associate()/amendAssociations() additionally check that the requester may create the specific reference being written, since a reference elsewhere confers access (an attachment association or file-ref content field makes the referenced file downloadable via getAttachment()):

  • attachment associations and file-ref content fields require file access: the requester is the owner, uploaded the file themselves (holds an _attachment@1 Record for it), or can already read some Record referencing it. This is exactly getAttachment()'s own access rule — reference creation requires what reference possession would grant.
  • parentId, and a relationship whose target is a Record in this stack ({ kind: 'record' } naming no stackUrl), require read access to that Record. The gate tests stackUrl for a value rather than for presence, because absent and empty are one target everywhere else — storage, association identity, and the filter all read both as this stack — so both spellings meet the same check. This gate runs before the target's shape is validated, so a reference is refused for the access it names rather than reporting what was wrong with it.
  • tag associations carry no reference and are never gated.
  • The other relationship target arms are never gated — a record target carrying a stackUrl, an entity target, and an external target alike. This is not a gap in the check but the absence of anything for it to protect: the gate exists so that creating a reference cannot convey access to, or confirm the existence of, a Record the requester may not read, and none of these three names a Record in this stack. Core never dereferences them, so no access flows through one, and an accepted write reports back only what the requester already supplied. Gating a target in another stack would additionally require dereferencing that stack at write time, which core does not do — and a stack cannot even recognize its own URL, since _config holds no stackUrl.
  • _group roster associations are exempt from the relationship check — a roster association names an Entity rather than a readable Record (see Group), and roster mutation is already gated by the stricter admin-or-owner rule there.

The owner acting alone is exempt from this gate, before the target is resolved rather than by reading it — there is no Record in their own stack they may not read, so the gate could only ever refuse them for the target's absence, and absence is not its question. Refusing there would put a permission error in front of the answers a caller-named parentId is owed — the 400 for a malformed one and the 409 for one naming no Record (Data model § Reparenting) — making both unreachable through a scope, which is every request a server serves. The exemption grants nothing: it hands the question to Stack, which refuses or accepts on its own terms. Delegation does not carry it, on either side; the predicate is the owner acting as itself, the same one unconditional owner authority rests on. The owner may therefore name a relationship target that does not exist, exactly as an unscoped Stack allows — a dangling relationship is an ordinary state at rest, and no check anywhere refuses one.

A missing target and an existing-but-inaccessible one always produce the same StackPermissionError for everyone else, with no distinguishing detail. The message names only what the caller passed (the parentId, the file ID and its field) and says the target "does not exist or you cannot read it", as getAttachment()'s refusal does — otherwise the check itself becomes a confirmation oracle (e.g. for a guessed file hash: content-addressed fileIds mean a successful attach-then-read round-trip would otherwise confirm the stack holds those exact bytes). In a content patch, only file-ref fields actually present in the patch are checked — untouched fields carry no new reference.

The parentId key applies the same gate as create(), so a move cannot reach a container an authoring call could not have named. It is the only route: a restore settles no containment, so it can neither reach a container nor need gating on one. It is otherwise an ordinary write on the record — not reshare-gated like permissions and unlisted, because containment decides which listings enumerate a record rather than who may read it, so a move discloses it to nobody who could not already read it and withdraws it from nobody who could. The origin container is ungated: naming it requires reading the record, which the caller has already had to do. See Data model § Reparenting.

appId and permissions are deliberately not gated by this: appId is self-reported, untrusted metadata everywhere (see App for what can and cannot be checked after the fact), never a permission input. permissions at create time is consistent with the same key's owner-or-creator policy after it — a contributor authoring a Record in your Stack can already widen its access up to and including world-readable; create-time is the same capability exercised earlier, not a new one. That holds for every requester who genuinely holds the reshare capability, which is every undelegated one, and not for a delegated principal, where the key is refused outright rather than ungated.

Composing a change set

mutate() names several aspects in one call, and the aspects do not share a gate. Content is reachable by a write-holder or an update-* grantee; permissions and unlisted are reachable only by the owner or the Record's own creator; a _group's roster is reachable only by its admins. One verb over all of them therefore resolves authority per key, and lands exactly where the same caller would have landed one key at a time:

Combining changes creates no authority. A requester who may perform every key in a change set may perform the set; a requester who may not perform one of them may not perform any of it.

Four rules make that hold.

  • Every key present is gated, and gated on presence. unlisted: false and parentId: null name aspects and are checked like any other value — only an omitted key goes unchecked. A gate is never skipped because a value looks inert.
  • Every gate settles before any of the write lands. A change set is one atomic write, so there is no state in which part of it has been applied while the rest is still being authorized, and no ordering among the keys for a caller to exploit.
  • Every gate reads the Record as it stands, never as the change set would leave it. This is what stops a change set from bootstrapping its own authority. { permissions: [...], parentId: x } does not let the widened permissions satisfy the read check on x; { associations: [...], permissions: [...] } against a _group does not let a roster naming the requester admin satisfy the admin check that same call must pass. Either would otherwise be a one-call escalation out of a gate the single-key spelling enforces.
  • A refused key refuses the call. Nothing is partially applied and no key is quietly dropped. An app that believed it had published a Record it had not is worse served by a success than by StackPermissionError.

Per key, over a Record in no system family:

Key What the requester must hold
contentPatch record-level write, or an update-own/update-any grant on the type — required of the principal and the subject both
associations the same, plus reference-creation gating on every Association the change adds
parentId the same, plus read access to the destination
permissions, unlisted owner or the Record's creator, on both sides of a delegation — and never a delegated principal, as at create time

The family fences apply to every key alike, whatever the table says: a _group Record is writable only by an admin or the owner, a _grant or _install Record only by the owner acting alone, and an _app card's did/appId only by the owner acting alone (see Identity § DID bindings).

A single coarse gate over the whole verb is deliberately not the rule. Requiring the strictest of them — the owner acting alone — for any multi-key call would be simpler to state, and would leave every collaborator back at one call per aspect, which is the cost the verb exists to remove. Requiring the loosest would hand a write-holder the reshare the write bit is defined not to carry. Per-key composition is the only one that changes nobody's reach.

Known limitation

ScopedStack's group-membership check only resolves _group Records living in the same stack as the Record being accessed — it does not implement the cross-stack case described under Record-level permissions. A server relying on cross-stack groups must still handle that case itself. The same limitation applies to a type-level grant's group grantee (above): a group-targeted grant only resolves a roster in the same stack as the _grant Record.