@repo/datadata/authz
Declarative authorization: evaluating a schema's access rules for a principal, the role vocabulary, and the scope checks behind every read and write decision.
Functions
commandKind
export declare function commandKind(name: string): CommandKind;The CommandKind of the command named name.
commandNameOf
export declare function commandNameOf(kind: string): string | null;The command name a CommandKind carries, or null for any other kind.
deny
export declare function deny(message: string): AuthzDecision;Deny verdict carrying the message surfaced in the doc:error(unauthorized).
evaluateReadAuthorization
export declare function evaluateReadAuthorization(input: {
docId: string;
docType: string;
access: AccessBlock | undefined;
facts: AuthzFacts;
}): AuthzDecision;The declarative read verdict. NOTE the default INVERTS the write default: a read rule absent at BOTH grains (the per-document entry, then the docType block) means every principal ADMITTED to the folder reads (folder-granular visibility, the pre-read-filtering behavior) — not "authenticated" — and neither grain's write fallback applies to reads. There is no meta-rule branch either: the write meta-rules' dual is shaping (filtered index, redacted sys:access), not blocking, so exempt system docIds answer ALLOW here too — checked FIRST, so an entry cannot blind a client to the shaped system documents it needs to function.
Composed callers run the full read seam in this order: exemption (must precede scopes, or a docTypes-scoped agent loses its own sys:index and schemas) → scope attenuation → the system-authority short-circuit (direct calls only) → this function. A denial is answered as doc:notfound — hidden existence.
evaluateWriteAuthorization
export declare function evaluateWriteAuthorization(input: {
kind: WriteKind;
docId: string;
docType: string;
access: AccessBlock | undefined;
facts: AuthzFacts;
}): AuthzDecision;The declarative write verdict. Order: 1. META-RULES — writes to the governance surface itself (sys:access, sys:schema:*) require effective role admin, regardless of any access block or per-document entry. This closes the schema-as-root-of-trust hole: an editor cannot rewrite a schema's access rules to grant themselves more — and an entry targeting a governance docId is inert (it can never loosen the gate). Scopes can block sys:access writes while permitting schema edits (see AccessScope in ./types.ts), but those edits can still change the schema's access rules. Blocking sys:access does not freeze all authorization policy. 2. The finest declarative grain that speaks to the kind, MOST-SPECIFIC WINS: the sys:access per-document entry for this docId (the kind's own rule, else the entry's write fallback), else the docType's access block (the kind's own rule, else the block's write fallback), else the default (any authenticated subject). An entry rule REPLACES the docType grain rather than composing with it — that is what lets an entry restrict BELOW what the holder's folder role would allow. purge reads only its own rule at either grain (entry purge, else block purge — the write fallback never applies) and defaults to "nobody" (see PURGE_DEFAULT_RULE), so purge is never INFERRED in either direction: an entry widens it only with its own purge rule and NARROWS it only with one too. An entry whose write restricts update/delete/rename below the block leaves purge to the block's rule — making a document private takes purge: "nobody" in the entry as well. (The wire purge's read-visibility floor bounds that: an entry narrowing read answers a would-be purger notfound; see ops/lifecycle.ts.) 3. A COMMAND kind (command:<name>) reads only the commands.<name> rule at either grain — the entry's, else the block's — and defaults to "nobody" (see COMMAND_DEFAULT_RULE): a command is granted by name, never through write. Decided before the meta-rules: the governance documents declare no commands, so no principal, admin included, holds a command on them.
Callers short-circuit system-authority contexts BEFORE calling (a direct call under systemContext(…) skips declarative policy entirely) — this function deliberately knows nothing about authority.
isCommandKind
export declare function isCommandKind(kind: string): kind is CommandKind;Whether kind is a CommandKind.
isReadAuthorizationExempt
export declare function isReadAuthorizationExempt(docId: string): boolean;Read targets OUTSIDE read authorization entirely — checked FIRST, before scope attenuation, by both server enforcement and client prediction: the per-principal-SHAPED system documents. sys:index / sys:trash arrive filtered to what the reader may see, sys:access arrives redacted for non-admins (redactAccessDocumentFor), sys:principal IS the reader's own identity (per-connection synthesized — nothing to hide from yourself, and a docTypes-scoped agent must still see its own scopes), and sys:schema:* stay readable to every admitted principal (validation and prediction depend on them). Blocking any of these would strand the client: no schemas to validate with, no index to navigate, no authz facts to predict from.
Presence documents are NOT exempt: they are governed like any document by their own presence schema's access block (declarative read/write rules, scope attenuation on the presence:<type> docType), on top of the one library-enforced invariant the schema cannot express — per-cell ownership. There is no target-read floor: a channel is independent of its target, which is what admits presence on staged documents and cross-document channels. Presence admission is composed by the presence subscribe/write paths, not short-circuited here.
operationWithinScopes
export declare function operationWithinScopes(operation: AccessOperation, scopes: readonly AccessScope[] | null | undefined): boolean;Scope attenuation check — runs at the engine's read/write choke points BEFORE any policy is consulted, for every actor.
- scopes absent or null ⇒ unattenuated ⇒ allowed. - Empty array ⇒ deny everything. - Otherwise the operation must match at least one scope (scopes are OR-ed); a scope matches iff each present ALLOW axis (docTypes, kinds) contains the operation's value and neither DENY axis (excludeDocTypes, excludeDocIds) does.
redactAccessDocumentFor
export declare function redactAccessDocumentFor(principal: Principal, document: AccessDocument): AccessDocument;The non-admin view of a folder's sys:access document: the reader's OWN role entry plus the folder defaults — nothing about other subjects — plus each per-document entry shaped by what the reader may know of it:
- ADMITTED (the principal satisfies at least one rule present in the entry, resolved with the FULL document's facts): included whole — parties sharing a document see each other's subjects, like any real ACL. - NOT admitted, and the entry RESTRICTS read: omitted entirely. The document itself is hidden from this reader, so the entry's existence would leak what hidden existence conceals (or, for a pre-creation reservation, intent to create). - NOT admitted, and the entry does NOT restrict read — the LOCK pattern, e.g. { write: { subject: "alice" } } on a document everyone reads: included as a DENY-STUB, the entry with every subject condition stripped (a rule emptied that way becomes "nobody"). The stub is verdict-EXACT for this reader — non-admission means it satisfied no rule, so every stripped condition was already false for it — and withholds the subject identities exactly as omission would, while letting the reader's prediction see the lock instead of offering an editable document whose every write bounces.
The engine delivers this view to every non-admin reader (admins get the full document, as does a system-authority DIRECT read — trusted by its CALL context, never by any actor value), closing the governance-visibility hole where any folder member could enumerate every subject's role assignment.
INVARIANT (redaction preserves self-verdicts, property-tested): for any principal p and document d, resolveEffectiveRole({ principal: p, accessDocument: redactAccessDocumentFor(p, d) }) equals the full-document result — resolveEffectiveRole reads only the principal's own entry, the defaults, and the principal's grants, all of which redaction preserves. For the per-document grain the invariant is WEAKER in exactly one place: evaluateWriteAuthorization / evaluateReadAuthorization SELF-verdicts are preserved for every entry the redacted view RETAINS — whole entries evaluate identically on both sides, and a deny-stub's allow/deny answers match the server's for its reader (only the denial MESSAGES differ, category instead of identity) — while an OMITTED entry (read-restricting, non-admitted) makes the client resolve the docType grain instead and possibly mispredict "allowed" for a document the server hides. That misprediction lands exactly where hidden existence already lands (rollback on doc:error, doc:notfound on subscribe), never as a predicted DENIAL the server would contradict.
ACCEPTED CORNER LEAK: this function sees neither a docId's docType nor its access block, so a read-silent entry is stubbed even when the BLOCK's read rule hides its document from this reader — newly disclosing that the id has an entry at all. The stub carries no content and no identity (an opaque docId, the same shape as the accepted alreadyExists create leak), and the combination — a type-hidden document under a read-silent per-doc lock — is one an app reaches only by declaring privacy at one grain and ownership at the other.
resolveEffectiveRole
export declare function resolveEffectiveRole(facts: AuthzFacts): AccessRole | null;The principal's effective role in this folder: the MAX of the role the sys:access document assigns (per-subject entry, else the authenticated/ anonymous default) and any role:<r> connect-time grants. With no sys:access document, authenticated subjects are admin and anonymous principals hold no role (see AuthzFacts.accessDocument).
roleGrant
export declare function roleGrant(role: AccessRole): RoleGrant;Build the role:<r> connect-time grant that confers the declarative role role (see Principal.grants). The one exported way to construct a role grant: apps that resolve their own roles at a trust boundary and forward them as Principal.grants use this instead of hardcoding the role: prefix, so a role rename becomes a compile error at every call site rather than a grant that silently confers nothing.
scopeDecision
export declare function scopeDecision(operation: AccessOperation, scopes: readonly AccessScope[] | null | undefined): AuthzDecision;The scope-attenuation verdict as an AuthzDecision, so the server (which throws the message on the wire) and the client (which returns it as a predicted rejection) share ONE denial message — a reword can't drift them.
systemContext
export declare function systemContext<C extends PrincipalContext>(context: C): C & SystemContext;The greppable spelling of a system-authority direct call: stamps authority: "system" onto a host-built context. For host code holding the server handle (schema seeding, governance bootstrap, operator migrations, maintenance surfaces) — a client that thinks it needs this almost certainly wants a role:admin grant or scopes instead.
Interfaces
AccessOperation
export interface AccessOperationOne operation about to be authorized — a stored write or a read.
docId: string;The document the operation targets.
docType: string;The target document's docType.
kind: AccessKind;What the operation does to the document.
AccessScope
export interface AccessScopeOne attenuation scope. A scope matches an operation iff docTypes is absent or contains the operation's docType AND kinds is absent or contains the operation's kind AND neither DENY axis rejects it — excludeDocTypes absent or NOT containing the operation's docType, excludeDocIds absent or NOT containing its docId. Multiple scopes on a principal are OR-ed.
kinds absent = every kind, INCLUDING read (an unattenuated axis). A present list is exhaustive: a scope listing only write kinds does NOT match a read — a scoped principal that should still SEE documents must list "read" (or omit kinds). Only the per-principal-SHAPED system documents (sys:index, sys:trash, sys:access, sys:principal, sys:schema:*) are exempt from read attenuation — their CONTENT is per-principal shaped instead (filtered / redacted).
excludeDocTypes is the DENY axis — "everything this scope covers, except these docTypes" — for the attenuations the docTypes allow-list cannot express because the rest of the world is open-ended (docTypes may even be invented at runtime). The motivating case: an agent's staging session is opened with scopes excluding the docType that HOSTS it, so the host-write capability the changeset persistence requires at the CLIENT level never reaches the session's editing surface. Because scopes are OR-ed, the exclusion must appear in EVERY scope that would otherwise cover the docType — a second scope without it re-admits.
CONSUMER TRAP: out-of-scope documents are dropped from the sys:index / sys:trash listings ENTIRELY, so resolving a docId's TYPE through an attenuated view answers undefined — indistinguishable from "no such document (yet)". That is the correct non-disclosure behavior (a hidden document reads exactly like a nonexistent one, everywhere), but an APP-LEVEL gate built on that resolution degrades silently: a gate that admits "unknown type = not created yet" starts admitting the excluded documents the exclusion was meant to fence off, with no type error and no failing test. An app that gates on type resolution must resolve through a view that is NOT attenuated by the exclusion — for a session-level scope (StagingSessionParams.scopes) that is the underlying client's own index; for a connection-principal scope the client's index is filtered too, so the gate belongs server-side.
excludeDocIds is the same deny axis one grain finer: FREEZE THESE DOCUMENTS, whatever their type. It exists because the docType axis cannot reach a document whose type is shared with documents the principal must keep — the motivating case being the excluded type's own SCHEMA document: sys:schema:<type> has docType sys:schema, so excludeDocTypes: ["chatConversation"] leaves the agent able to redefine what a conversation IS, while excluding sys:schema wholesale would also take away the schema authoring it legitimately does for other types. excludeDocIds: ["sys:schema:chatConversation"] denies exactly the one document.
To let an admin edit schemas while blocking writes to the folder's roles and per-document permissions, use excludeDocIds: [ACCESS_DOCUMENT_ID]. Apply the exclusion to every scope that would otherwise admit that document. Scopes restrict existing permissions; they do not grant the admin role required for schema editing.
This does not prevent all authorization changes: editable schemas contain access rules, which the principal can still change. Per-document rules and principal scopes continue to apply. sys:access remains readable through the exclusion so the client can predict its own authorization. To close the schema surface too, exclude the schema documents whose rules matter (sys:schema:<type>; creating NEW types stays available), or keep the principal below admin and write schemas host-side under system authority.
An exclusion is sound as a freeze because docIds are STABLE: rename changes a document's display name, not its docId, and schema documents refuse rename outright — so nothing can be renamed into or out of an exclusion. create is gated on the same docId, so a docId may be frozen before any document claims it.
NOTE the deny axes do NOT hide the read-exempt system documents: the exemption (isReadAuthorizationExempt) is checked BEFORE scopes, so excluding sys:schema:<type> blocks WRITES while the schema stays readable — which is what a client needs to keep validating and predicting. Freezing is not blinding.
docTypes?: readonly string[];The docTypes this scope covers; absent = every docType.
excludeDocIds?: readonly string[];docIds this scope never covers, whatever their docType.
excludeDocTypes?: readonly string[];docTypes this scope never covers, whatever docTypes admits.
kinds?: readonly AccessKind[];The access kinds this scope covers; absent = every kind, including read. A command is named by its CommandKind (command:<name>).
AuthzFacts
export interface AuthzFactsThe synced facts a declarative verdict is computed from.
accessDocument: AccessDocument | null;The folder's sys:access document, or null when the folder DEFINITIVELY has none. "Absent" and "not yet synced" mean different things — with no document, authenticated subjects are treated as admin (governance is opt-in; also avoids the bootstrap deadlock where nobody could create the document that grants the right to create it). A caller whose facts are not yet synced must NOT call this with null — it should skip prediction.
principal: Principal;The principal whose authorization is being decided.
Principal
export interface PrincipalThe identity a write is performed as. Threaded through ALL write paths — wire connections and trusted in-process/direct calls alike — so there is a single identity model for users, system code, and AI agents.
actor: string;What kind of code is acting: 'user' (frontend on the user's behalf), agent:<name> (AI-generated actions), or sys:<name> (server-side system code, e.g. sys:schema-seed). Pure identity — no actor value carries privilege; trusted authority is a property of the CALL, not the principal (see SystemContext). Stamped as actor on stored events.
grants?: readonly string[];Connect-time grants resolved by the app (from D1 etc.). role:<r> grants confer declarative roles; other grants are matched by { grant: ... } access conditions.
scopes?: readonly AccessScope[] | null;Attenuation: the principal's effective permissions are the policy verdict ∩ these scopes, enforced by the engine before any policy runs. Absent or null = unattenuated. An empty array denies every write and every read of non-system documents.
subject: string | null;User or service id; null = anonymous. Stamped as subject on stored events.
PrincipalContext
export interface PrincipalContextA context shape carrying the principal a write is performed as.
principal: Principal;The identity the operation is performed as.
ReadOperation
export interface ReadOperation extends AccessOperationOne read (visibility question), as seen by the read decision point.
kind: "read";Always "read".
SystemContext
export interface SystemContext extends PrincipalContextA host-built context for a DIRECT server-handle call carrying trusted system authority. authority: "system" skips the policy layers — the governance meta-gate, docType access blocks, access.read rules, sys:access redaction. Scope attenuation on the principal still binds, so a host can self-attenuate a system call.
Authority is a property of the CALL, never of the identity: Principal has no authority field, so no actor string — sys:* included — is privileged. Only the server handle's direct-call methods read this marker; the wire / in-process entry (handleEvent) never does, so connections and sessions cannot carry authority by construction.
authority: "system";Marks the call as trusted system authority; build it with systemContext.
WriteOperation
export interface WriteOperation extends AccessOperationOne write about to be applied, as seen by the authorization decision point.
kind: WriteKind;The stored-write kind being authorized.
Types
AccessKind
export type AccessKind = BuiltinAccessKind | CommandKind;A kind an access decision can be about: one of ACCESS_KINDS, or the CommandKind of a domain command.
AccessRole
export type AccessRole = (typeof ACCESS_ROLES)[number];An access role. A principal's effective role is the higher of its role in the folder's sys:access document and any role:<r> grant it connected with.
AuthzDecision
export type AuthzDecision = {
allowed: true;
} | {
allowed: false;
message: string;
};The verdict of an authorization check: allowed, or denied with the message the client receives in the doc:error(unauthorized). See ALLOW and deny.
BuiltinAccessKind
export type BuiltinAccessKind = (typeof ACCESS_KINDS)[number];One of the enumerable kinds, ACCESS_KINDS: the stored-write kinds and read.
CommandKind
export type CommandKind = `${typeof COMMAND_KIND_PREFIX}${string}`;The access kind of one domain command: command:<name>, where <name> is the command's name in the registry (defineCommands). Each command is a kind of its own beside update, granted by the docType's access.commands.<name> rule (or a per-document entry's), named in a scope's kinds, and asked about with can. Build one with commandKind.
OperationKind
export type OperationKind = (typeof OPERATION_KINDS)[number];A stored-write kind: one of OPERATION_KINDS.
RoleGrant
export type RoleGrant = `role:${AccessRole}`;A role:<r> connect-time grant string. See roleGrant.
WriteKind
export type WriteKind = OperationKind | CommandKind;A kind a stored write is authorized as: one of OPERATION_KINDS, or the CommandKind of a domain command (a command is a stored write: it executes through the update tail).
Variables
ACCESS_KINDS
ACCESS_KINDS: readonly ["create", "update", "delete", "restore", "rename", "purge", "read"]Every kind an access decision can be about: the six stored-write kinds plus read (visibility — subscribe, get-events, index/trash listing). Scopes are expressed in this vocabulary; the declarative write verdict and the write choke points stay in OperationKind.
ACCESS_ROLES
ACCESS_ROLES: readonly ["viewer", "editor", "admin"]The built-in access roles, lowest first. A role satisfies any condition naming it or a lower role, so an admin passes an { role: "editor" } condition.
ALLOW
ALLOW: AuthzDecisionAllow verdict, for policies with nothing to add.
ANONYMOUS_PRINCIPAL
ANONYMOUS_PRINCIPAL: PrincipalThe identity of a connection that presented none: anonymous user action.
COMMAND_KIND_PREFIX
COMMAND_KIND_PREFIX = "command:"The prefix a CommandKind carries before the command's name.
OPERATION_KINDS
OPERATION_KINDS: readonly ["create", "update", "delete", "restore", "rename", "purge"]The six stored-write kinds. NOT uniform at the authorization layer: for every kind except purge, evaluateWriteAuthorization resolves the rule as the kind's own rule → the block's write fallback → the "authenticated" default. purge is the deliberate odd one out — it reads ONLY its own access.purge rule and defaults to "nobody" (irreversible destruction is opt-in per docType; see PURGE_DEFAULT_RULE in evaluate.ts). Consumers iterating this array (tests, capability tables, authz UI) must not assume every member follows the write fallback.