Authorization
Every access in datadata runs as a principal — one identity model that covers a user’s frontend actions, server-side system code, and AI-generated edits alike. The host attaches a principal to each connection (and to in-process clients), and the engine gates it on both sides: it authorizes every stored-document write — create, update, delete, rename, restore, purge — and filters every read. A denied write is rejected like any other rejected write, so the writer’s optimistic update simply rolls back; a document a principal may not read simply does not exist for it (see Read filtering).
The principal
Section titled “The principal”A principal carries four things:
- Subject — who the access is on behalf of: a user or service identity, or nobody for anonymous.
- Actor — what kind of code is exercising it: a user’s frontend, a named
AI agent (
agent:chat), or server-side system code (sys:schema-seed). Actors are provenance and policy-targetable labels, carried so edits stay attributable — never gates of their own. - Grants — facts the app resolved at connect time, like a role or a membership.
- Scopes — an optional hard cap the engine enforces no matter what any rule would otherwise allow.
Subject and actor are independent: the subject is whose behalf the access is on, the actor only what kind of code is exercising it. A named AI agent therefore acts as its subject, not as a separate identity — which is what keeps its edits attributable to the person behind them.
The composition contract
Section titled “The composition contract”Reads and writes run the same gate, and the whole policy is declarative: every verdict is computed from the governance documents and the principal, so both sides of the wire can evaluate the same rules. At each choke point — one for writes, a parallel one for reads — two checks compose, the second able only to further restrict the first:
- Scope attenuation. If the principal carries scopes, the access must fall inside them — for every actor, before any rule is consulted. This is the hard cap for AI agents: an agent scoped to creating and updating notes can never delete anything or touch another document type, no matter what the rules say. Scopes attenuate visibility too — read is a scope kind alongside the write kinds, so that same agent sees nothing outside notes unless it is also scoped to read more broadly. (On the read path the system-document exemption is applied even before this cap, so a scoped agent keeps the index, schemas, and its own identity — see Read filtering.)
- Declarative rules, most specific grain first: the folder’s per-document entry for the target id if it has a rule for the operation, else the document type’s access rules (below) — evaluated against the folder’s roles. The write-kind rules run on the write path, the read rule on the read path.
Facts from external sources (a database, an API) enter as connect-time grants — the app resolves them at its trust boundary and the engine consumes them as rule conditions; nothing in the decision path does I/O.
Presence is governed like any document: its presence schema’s access block
decides who may read and write it (a presence write is an update on the
presence:<presenceType> docType, resolved from the channel’s id), and
per-document entries reach presence channels by their ids too. The channel is
governed independently of the document it is about — that document’s read
rules never gate presence — and the library adds one structural rule on top,
because the schema can’t express it: per-cell ownership.
System authority
Section titled “System authority”Some host operations must run above the folder’s own policy: seeding schemas at boot, creating the governance document itself, applying an operator’s schema migration. That privilege is a property of the call, never of any principal: a direct server call can carry an explicit system authority marker that skips the policy layers — the governance meta-gate, the access rules, and the per-document entries — while scope attenuation still binds, so a host can self-attenuate a system call.
Identity never implies authority. No actor value grants anything — a system-actor client connection is an ordinary, policy-bound principal. Only host code holding the server handle can make a system-authority call, and connections have no way to express one, so a client cannot mint privilege by construction. A client that thinks it needs system authority almost certainly wants an admin role grant (which passes the governance meta-gate through policy) or scopes instead.
The maintenance surfaces — export, import, verify, rebuild — sit outside the per-operation gates and require system authority.
Declarative access rules
Section titled “Declarative access rules”A schema (itself a document) declares access rules in both directions: a write rule per operation — a separate rule for create, update, delete, rename, restore, and purge, plus a fallback for any write kind without its own (purge excepted — see Defaults) — and a read rule governing who may see the type at all. This section covers the write rules; the read rule, whose default is deliberately different, lives under Read filtering.
A rule is one of: anyone, any authenticated subject, nobody, a role, a
named grant, or a named subject (exactly that principal — the condition
per-document ACLs are built from) — singly or as a list of alternatives, and
the same vocabulary serves read and write alike, at both the type grain and
the per-document grain. Roles form an ascending hierarchy —
viewer, editor, admin — and a principal’s effective role is the highest
of what the folder’s sys:access roles document assigns it (per subject, or
via folder-wide defaults for authenticated and anonymous subjects) and any
role carried in its connect-time grants. A document type with no write rules
defaults to any authenticated subject; reads default the other way (see
Defaults).
A domain command is a write kind of its own,
command:<name>, granted under the block’s commands map by name. Unlike the
other write kinds it never falls back to the write rule: a command no rule
names is nobody’s, so an app can keep raw updates to a type behind the admin
role while editors change it only through the mutators it registered.
The three roles are fixed — apps cannot define their own or reorder the hierarchy. App-specific privileges beyond the trio ride named grants (matched exactly, with no hierarchy).
Two meta-rules protect the governance surface itself: writing sys:access
or any schema document requires the admin role regardless of the access rules
— an editor cannot rewrite a schema to grant themselves more.
Allow schema editing while blocking changes to sys:access
Section titled “Allow schema editing while blocking changes to sys:access”Schema editing requires the admin role, which also permits writing
sys:access. To let an agent edit schemas while blocking changes to folder
roles and per-document permissions, attach scopes like these to its
server-resolved principal:
const agentScopes = [ { kinds: ["read", "create", "update", "rename"], excludeDocIds: [ACCESS_DOCUMENT_ID], // "sys:access" },];Scopes restrict permissions; the principal must already hold the admin role.
Multiple scopes are alternatives, so every scope that would otherwise permit
writes to sys:access must exclude it. The document remains readable so the
client can predict its own authorization.
This does not prevent all permission changes. Schemas contain access
rules, and an agent that can edit a schema can change those rules too —
including opting a type into the purge that no rule permits by
default. Per-document restrictions and principal scopes still
apply. If permission changes require human approval, this configuration alone
is insufficient.
Two configurations go further:
- Freeze the schemas whose rules matter. Add their document ids to the same
exclusion (
sys:schema:<type>, whichschemaDocIdspells for you). The agent can still create new types — and a new type’s rules govern only the documents it is creating anyway — but it can no longer redefine an existing one, so today’s types stop being an editable permission surface. The cost is schema evolution: those types then change only through a human admin or the host. - Keep the agent below admin. Without the admin role it cannot write schema documents at all, and the host creates them with a system-authority direct call — the same privilege bridge per-document entries use for a non-admin claiming a private document. The app decides which types come into existence; the agent never holds the authority itself.
Per-document entries
Section titled “Per-document entries”The object grain of the model: the folder’s sys:access document carries
an optional documents map of per-document entries, keyed by document id. An
entry holds the same eight rule fields as a schema’s access block — the same
vocabulary, one grain finer — and resolution is most-specific-wins per
operation: a rule in the entry (the operation’s own, else the entry’s
write fallback) replaces the document type’s rules for that operation;
only where the entry is silent does the type grain apply. Replacing rather
than composing is the point — it lets an entry restrict below what a
principal’s folder role would allow, which is what per-document privacy needs.
A private document is simply an entry naming one subject:
{ "documents": { "todo_x7fk2": { "read": { "subject": "alice" }, "write": { "subject": "alice" } } }}There is no owner concept — ownership is an entry naming one subject, and
sharing is an anyOf naming two. Three properties follow from the design:
- Entries are facts about the id, not the document. An entry may be
written before the document exists —
createresolves through it, so an entry admitting one principal reserves the id. It survives delete, restore, and purge, and applies again on recreation: a purge-and-recreate cannot shed an id’s access rules. Entries die only by an explicitsys:accessedit. (Awrite: "nobody"entry is a persistent freeze — the stored dual of theexcludeDocIdsscope axis.) - Entries hide from admins too. Under most-specific-wins a
read: { subject: "alice" }entry hides the document from folder admins as well. An admin regains access by editing the entry — a visible governance write — never by silent omniscience. The governance meta-gate itself is untouchable: entries targetingsys:accessor schema documents are inert, and the shaped system documents stay exempt from read rules. - Redaction shapes each entry to its reader. A non-admin’s synced
sys:accesscarries whole the entries whose rules it satisfies — parties sharing a document see each other, like any real ACL. An entry that does not admit the reader but leaves reads open (a lock on a visible document) arrives as an identity-free deny-stub, so the lock predicts without naming who holds it. An entry that hides its document is omitted entirely: a reader never learns that an id it may not see is governed at all (for a reservation, that would leak intent to create).
Who writes entries? sys:access is admin-meta-gated, so an admin edits
documents over the ordinary wire like any governance content — sharing and
revocation are just synced edits, and revocation fires the
revocation sweep. For a non-admin claiming a private document,
the host writes the entry with a system-authority
direct call — a privilege bridge at the same trust boundary as connect-time
grant resolution: the app decides who may mint, the entry itself is ordinary
declarative data, and the client then creates the document over the normal
wire.
Read filtering
Section titled “Read filtering”Visibility is governed the same way, enforced everywhere the server answers a
read — on direct reads and per recipient on every broadcast. A subscribe
or a history request for a document the principal may not read comes back as
not found (a hidden deleted document included — never revealed as deleted),
the sys:index and sys:trash listings omit it, live updates about it are
withheld, and any listing built on the index never shows it. Hidden
existence is deliberate: nothing a client can observe distinguishes “hidden
from you” from “does not exist”.
Two grains, mirroring writes:
- A per-document entry’s read rule hides that one document — where per-document privacy and ownership live.
- The schema’s read rule hides a whole document type from anyone below the required role. Its default is the inverse of the write kinds: with no read rule at either grain, anyone admitted to the folder reads — not merely any authenticated subject — and the write fallback does not apply. Read filtering is opt-in per document (an entry) or per type.
Hiding is not locking — pair a read rule with the matching write rules (an entry naming a subject usually carries both).
The read rule’s bar is a role, resolved from sys:access and connect-time
grants exactly as the write rules resolve theirs — so sys:access is not a
third read knob but the shared roles dial both axes read. Demote a subject
there and read access falls together with write access in a single edit, which
fires the revocation sweep.
The system documents are shaped, never blocked — blocking one would
strand the client. sys:index and sys:trash arrive filtered to the
reader’s visible entries; schema documents stay readable to everyone admitted
(validation and prediction depend on them); sys:access arrives redacted
(below). Presence is not in this set — it is governed like any document (its
presence schema’s access rules), so a channel can be read-blocked; the denial
is an explicit unauthorized error. Writing without
reading is otherwise legal: a “drop-box” type is readable only by
admins but writable by any authenticated subject (every write kind except
update). It works because the authoring connection always receives its own
writes’ events, so an awaited write still resolves for its author.
An update requires read. A patch runs against the document: its test
operations pass or fail on values the writer can’t see, and a path that isn’t
there is refused. So the server answers an update from a principal who can’t
read the document as “not found”, the same answer as for a document that doesn’t
exist, whatever the patch asks. can and getCapabilities predict it: update
is false wherever read is. A create needs no read, since a new document starts
empty.
Defaults
Section titled “Defaults”Every rule is optional, and the defaults are deliberately asymmetric: an untouched document type is easy to write and easy to read. Writes lean on “any authenticated subject”, reads lean on “anyone already in the folder” — so both write restriction and read filtering are opt-in per type.
| Axis | Default when the type declares no rule of its own |
|---|---|
| Write | any authenticated subject |
| Read | anyone admitted to the folder |
| Purge | nobody — explicit opt-in per type |
| Commands | nobody — each domain command is granted by name under commands |
Writes resolve through the grains before that default applies: the
per-document entry’s own rule for the kind, else the entry’s write fallback,
else the block’s rule for the kind, else the block’s write rule; only when
all four are absent does any authenticated subject take over. Reads have no
per-kind rules and no write fallback at either grain — a document with no
read rule on its entry or its type is simply visible to everyone admitted to
the folder.
Purge inverts the write default entirely. Hard deletion is irreversible, so
a document with no purge rule (at either grain) cannot be purged over the
wire by anyone — admins included — and the write fallback does not apply. The asymmetry is
deliberate: in a folder with no sys:access document every authenticated
subject acts as admin (see Bootstrapping access), so
any role-based default would hand irreversible destruction to anyone
authenticated in an ungoverned folder. A schema opts in explicitly, e.g.
access: { purge: { role: "admin" } }. (The host’s own retention sweep runs
under system authority and is unaffected.)
Two things sit outside the table and override it: scopes on the principal
(a hard cap checked first, for every actor) and the governance meta-gate
(writing sys:access or a schema always needs admin, whatever the block
says). And when the whole sys:access document is absent — not just a rule — a
folder-wide default takes over instead, which is the next section.
Bootstrapping access
Section titled “Bootstrapping access”A folder without a sys:access document treats authenticated subjects as
admin: governance is opt-in, and somebody has to be able to create the
document that grants the right to create it. Apps that hand out roles should
seed the roles document when the folder is created; an app whose roles ride
connect-time grants alone should seed an empty one, or the bootstrap
default quietly makes every authenticated subject an admin. On hosts that
support it, this seeding runs once at first boot as a
system-authority direct call and is never clobbered by
later admin edits.
Revocation
Section titled “Revocation”When the facts move — a sys:access edit demotes a subject, a schema tightens
a read rule — the server sweeps every live connection: newly-hidden
documents are evicted (the client drops its copy; the document leaves the UI)
and each connection’s filtered index, trash, and redacted sys:access view
refresh. Per-document entry changes are sys:access edits, so they sweep the
same way. Facts the engine can’t observe itself — grants in connection
metadata — trigger the sweep on demand. Eviction only drops the subscription, so a re-grant heals on the next
resubscribe: reveal, don’t push.
Mid-connection principal changes are first-class: a host can rewrite a live connection’s principal — a demotion, a grant widening — and the engine re-sweeps reads and refreshes that connection’s served identity, which the client adopts on the spot (see Capability-aware clients). Enforcement, prediction, and capability UI all flip without a reconnect. Forcibly closing a connection remains for actual offboarding — prefer demotion for revocation, disconnection for removal.
Managing access in practice
Section titled “Managing access in practice”A folder is one sync scope; an organization spans many. The split that keeps
membership changes cheap: org-wide facts live in your app’s own store and
ride the connection; sys:access holds only folder-local exceptions.
- Membership and admission stay in the app’s database. Whether a user may connect to a folder at all is the read boundary, and their baseline role arrives as a grant minted at connect time. Onboarding a member is one row in your store — no folder is touched; each folder learns the identity lazily, at connect. Never fan a role change out across folders.
- Folder-local roles go in
sys:access: per-subject exceptions and folder-wide defaults. The server reads it fresh on every write and clients sync it live, so granting or revoking there takes effect on the very next write, no reconnect. - Put ceilings in the document, floors in grants. Effective role is the
max of the two, so a grant only ever adds role — a role you routinely
lower belongs in
sys:access, where an edit takes effect on the next write. Grants can move mid-connection too: a host can rewrite a live connection’s grants and reauthorize in one motion, so even grant-carried role is revocable without closing the socket. Revoking admission itself (offboarding) blocks future connects at the app layer and closes live sockets. sys:accessis redacted per reader. A non-admin member’s synced copy carries only its own role entry, the folder defaults, and the per-document entries that admit it — never other subjects’ assignments or entries; admins read the full document (as do system-authority direct reads), and its history is admin-only. Redaction preserves every role-derived self-verdict by construction, so a member’s own capability prediction keeps working. But it also means a client can only predict for its own principal. Gate any member-management UI on the admin role.
Capability-aware clients
Section titled “Capability-aware clients”The same rule evaluation runs on the client — one shared, pure evaluation, so the two sides cannot drift:
- Identity is server-fed. Every client subscribes a virtual, per-connection identity document carrying the principal the server actually enforces, and adopts it. Prediction and capability discovery therefore need no client-supplied principal (a pre-connect seed remains, but the first served answer corrects it), the “worker mints it, browser mirrors it” drift class is gone by construction, and a host-driven mid-connection change arrives as a fresh adopted identity on the live socket.
- Prediction. A write the server would deny throws locally, before anything is sent, in the same window as optimistic validation. Prediction never falsely blocks: with no principal or unsynced facts it passes through, and the server stays authoritative — a stale “allowed” is corrected by the normal rollback.
- Discovery. A client can ask whether the current principal may perform a given kind on a document type — answered as yes, no, or unknown (“don’t gate the UI yet”). The kinds span the five write kinds plus read; a read “no” predicts the document would be hidden. Delete, rename, and restore, held only by id, resolve their target’s type first, so they are discoverable the same way — not just create and update. There is deliberately no “is this document hidden” query — absence is the signal (hidden existence, again: a hidden document is indistinguishable from a nonexistent one).
Staging and authorization
Section titled “Staging and authorization”Staged sessions compose with authorization rather than bypass it — commit has no privilege of its own. A commit drains through ordinary client writes, authorized exactly as the committer. That makes “the agent proposes, the human disposes” a configuration: scope an agent so it can stage freely but never drain a target, and let the human commit the same persisted changeset.
- A preflight reports the predicted verdict per stage before committing, and the session’s own projection carries the same per-stage flag.
- A denied stage fails as unauthorized and stays staged for retry.
- Every stored event records the writing principal (subject and actor) — so “which edits did the AI make?” is answerable from the committed data, not from logs.
Session-level attenuation
Section titled “Session-level attenuation”A session can carry its own scopes, drawn from the same vocabulary as a
principal’s scopes — for example, a worker session that may only ever update
kanban cards, never create or delete and never touch another type, regardless
of what its principal could otherwise do. Reads through that session are
filtered too: an out-of-scope document reads as absent, and discovery documents
such as sys:index, sys:trash, sys:session, and sys:stage:<docId> are
shaped to the documents still readable through the session.
Session scopes intersect the client’s principal scopes — both gates run — so a session can only ever narrow what its client may read or write through the session facade, never widen it, and never adopt a different identity: a session always writes as its client’s principal. An out-of-scope write is rejected locally, before it leaves the client, in the same unauthorized shape the principal-scope prediction raises.
Session scopes are advisory — a cooperative guardrail for agent tooling and UI, not a security boundary. The underlying connection still holds whatever its principal may read, and real write enforcement stays server-side against that principal, so a caller that bypasses the session API is not stopped by session scopes; use principal scopes, resolved at connect time, for a hard cap.
What this is not
Section titled “What this is not”The declarative model spans three grains — folder roles, document-type rules, and per-document entries — and stops there. There are no per-field rules: read filtering is whole-document — a principal sees a document entirely or not at all. There are no content-based rules either (“readable once published”): verdicts are computed from the principal and the governance facts alone, never from document data — the pure-facts contract that lets the client predict every verdict. An app encodes a state transition by writing the entry when the state changes, keeping the fact in governance where it is auditable. And there is no group or org model in the engine: relationship-shaped access (“members of team X”) rides grants, resolved at the app’s trust boundary and matched by grant conditions at either grain.