Skip to content

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).

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.

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:

  1. 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.)
  2. 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.

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.

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>, which schemaDocId spells 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.

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 — create resolves 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 explicit sys:access edit. (A write: "nobody" entry is a persistent freeze — the stored dual of the excludeDocIds scope 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 targeting sys:access or 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:access carries 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.

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.

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.

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.

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.

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:access is 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.

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).

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.

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.

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.