05Docs
Policy reference
One JSON file defines your backend: collections, types, auth rules, side-effect hooks, and invariants, plus optional top-level blocks for roles, macros, environments, and proof declarations. Everything in it is validated and compiled at deploy; the constraints can be proven. This page is the full surface (the six boundary types plus windowSum get their own page).
The shape
A policy is a JSON object. Most keys are path templates (your collections). A handful of reserved top-level keys (constants, defs, roles, environments, proofs, functions, links) are configuration, not collections.
{
"constants": { "ADMIN": "<admin-user-id>", "CAP": 5000 }, // optional: named values
"defs": { "isOwner": "@user.id == @data.owner" }, // optional: rule fragments
"roles": { "admin": { "members": ["@const.ADMIN"], "read": "*" } }, // optional
"environments":{ /* per-env appId + constants, CLI-side */ }, // optional
"proofs": { /* proof-only declarations: transferAuthority, attestations */ }, // optional
"tenants/$tenantId/invoices/$invoiceId": {
"fields": { "...": "..." },
"rules": { "read": "...", "create": "...", "update": "...", "delete": "..." },
"tier": "durable",
"hooks": { "offchain": { "create": "..." } },
"search": { "fields": ["..."] },
"invariants": [ /* see the Invariants page */ ]
},
"functions": { /* imperative escape hatch, see Functions */ }
}Collections & path templates
Top-level collection keys are path templates. Segments alternate between a collection name and a $variable (the document id), so paths always have an even number of segments:
- Collection names: letters and digits, starting with a letter (
invoices, notmy-invoices). - Id segments:
$camelCase, they become path variables usable in rules and invariants (e.g.$tenantId == @user.id). - Nesting encodes ownership: a write to
tenants/t1/invoices/i9binds$tenantId="t1"for every rule on that template. - Two templates may not collide modulo variable names,
users/$aandusers/$bis a deploy error.
Fields
fields maps field names to types. Names start with a letter; id, pathId and platform-prefixed names are reserved.
| Type | Meaning |
|---|---|
String | UTF-8 string |
Int | Signed safe integer |
UInt | Unsigned safe integer, required for rollingSum fields |
Bool | true / false |
Float | Decimal. Not allowed on onchain collections, use Int/UInt. |
Address | Wallet / account address |
Two suffixes compose with every base type: ? marks a field optional, ! marks it readonly after create (immutability becomes a proof obligation, not a convention). String!? is both.
Rules & the expression language
rules gates read, create, update, delete with boolean expressions. A rule that evaluates false rejects the write with a 403 and a trace. An action with no rule defaults to deny.
"rules": {
"read": "@user.id != null && @data.ownerId == @user.id",
"create": "@user.id != null && @newData.ownerId == @user.id",
"update": "@data.ownerId == @user.id && @newData.ownerId == @data.ownerId",
"delete": "@data.ownerId == @user.id"
}Variables
@user.id, the authenticated caller.nullwhen unauthenticated.@data.field, the existing document (not available increaterules).@newData.field, the incoming document (not available indeleterules).@time.now, server time.$pathVariable, any variable from the path template.get(/path)/getAfter(/path), read another document:getsees pre-transaction state,getAftersees post-batch (staged) state, this is what makes in-batch composition work. Paths are unquoted and start with/:get(/users/$userId).role.
Operators
- Logic:
&&,||, comparisons==!=<<=>>=. - Arithmetic:
+-*//(integer division)**. Plain/is reserved for paths. - Literals: numbers, quoted strings,
true,false,null. - No ternary, no string concatenation. Branching is
(cond && A) || (!cond && B); path building embeds variables directly:get(/teams/@newData.teamId/members/@user.id).
Conditional transfer authority
Ownership-like fields such as owner, ownerAddress, and holder get a deploy-time proof: they may stay unchanged, or be reassigned only by the current holder. Use proofs.transferAuthority when a different atomic condition is intentionally safe, for example a listed good moving to the buyer only when the paired wallet payment lands in the same set-many.
{
"defs": {
"settledSale": "@data.forSale == true && @newData.holder == @user.id && getAfter(/wallets/@data.holder).ink == get(/wallets/@data.holder).ink + @data.price && getAfter(/wallets/@user.id).ink == get(/wallets/@user.id).ink - @data.price"
},
"proofs": {
"transferAuthority": [{
"scope": "goods/$goodId",
"field": "holder",
"name": "settledSale",
"allow": "@def.settledSale"
}]
},
"goods/$goodId": {
"fields": { "holder": "String", "forSale": "Bool", "price": "UInt" },
"rules": {
"read": "true",
"create": "@user.id != null && @newData.holder == @user.id",
"update": "@user.id != null && (@data.holder == @user.id || @def.settledSale)",
"delete": "false"
}
}
}This is not a runtime bypass. The collection’s update rule still has to allow the write. The proof declaration adds two deploy obligations: every holder change must be current-holder authorized or satisfy the declared predicate, and the declared predicate may only assign the holder field to the caller. Put the shared predicate in defs so the rule and proof cannot drift.
Tiers
durable is the default and by far the most common tier: omit tier on a collection and it is durable. Reach for checkpointed or ephemeral only when you specifically want their tradeoff.
| Tier | Semantics | Use for |
|---|---|---|
durable | Every write is committed before the caller sees success. Required for rollingSum and materialized/sharded conservation. | Money, ledgers, anything an invariant protects |
checkpointed | Writes fold to the provable store on an interval; survives + is replayable. Required for a provable live room. | High-write app state, live rooms, counters |
ephemeral | In-memory only; gone on restart (live runtime snapshots bound the loss). Fastest. | Game ticks, cursors, per-client views, transient rooms |
The validator enforces the pairing: declaring rollingSum (or materialized/sharded conservation) on a non-durable collection is a deploy error, not a silent downgrade.
Hooks
hooks.offchain and hooks.onchain attach side effects to create/update/delete , token transfers, plugin calls, derived writes. Hooks are expressions too, chained with && so a falsy guard short-circuits later effects.
"hooks": {
"offchain": {
"create": "@TokenPlugin.transfer(@newData.from, @newData.to, @newData.amount)"
}
}Invariants (the boundaries)
Boundary invariants are transaction postconditions checked atomically on their documented runtime surfaces. bounded verify reports a per-obligation proofStatus; only PROVED carries deploy-time proof weight. There are six boundary types: conserve, rollingSum, bound, tenantTag, tenantEdge, and flowBound, plus windowSum, a derived-aggregate declaration rather than a write-gating boundary. A violated invariant’s name is present as a stable client key in both disclosure modes and in the owner decision log.
Each type, its keys, and what it proves get the full treatment on the Invariants page.
Roles, provably-scoped admin
The top-level roles block declares a role whose members get cross-collection read/write access, governed entirely by the policy, not an out-of-band god-mode. It is additive: a policy with no roles block behaves exactly as before.
{
"constants": { "ADMIN": "<your-admin-user-id>" },
"roles": {
"admin": { "members": ["@const.ADMIN"], "read": "*", "write": "*" },
"editor": { "members": ["<user-id>"], "read": "*", "write": ["posts", "comments"] },
"viewer": { "members": ["<user-id>"], "read": ["posts"] }
},
"orders/$id": {
"fields": { "buyer": "String", "total": "UInt" },
"rules": {
"read": "@user.id == @data.buyer",
"create": "@user.id == @newData.buyer",
"update": "false", "delete": "false"
}
}
}| Key | Meaning |
|---|---|
members | Non-empty array of universal principal identities (@user.id values). Use @const.NAME to keep them in a constants block. |
read | Gates the read action. "*" = every collection; an array lists collection names. |
write | Gates create, update, AND delete. Omit read or write to grant only the other; at least one is required. |
A read/write is authorized if either the caller holds a role whose grant covers this (action, collection) or the collection’s own rule passes. Roles only ever grant, they never restrict. Anonymous callers are never granted a role.
Constants & defs, keep policies DRY
Two additive blocks resolve at compile time (server-side, during deploy and verify), so the stored and proven policy contains only literals, no runtime cost.
constants, named string / number / boolean values, referenced as@const.NAMEanywhere a value appears (rule strings, role members, invariant limits). Type is preserved when the whole value is one@const("limit": "@const.DAILY_CAP"compiles to the number5000).defs, reusable rule-fragment strings, referenced as@def.name. Inlined wrapped in parens so they compose safely; may reference other defs and constants (a cycle is a compile error).
{
"constants": { "ADMIN": "7xKQ...adminId", "DAILY_CAP": 5000 },
"defs": {
"isOwner": "@user.id == @data.owner",
"isAdmin": "@user.id == @const.ADMIN",
"canEdit": "@def.isOwner || @def.isAdmin"
},
"posts/$id": {
"fields": { "owner": "String", "body": "String" },
"rules": {
"read": "true",
"create": "@user.id == @newData.owner",
"update": "@def.canEdit",
"delete": "@def.isAdmin"
}
}
}For one-off CI overrides there is also a CLI flag, --constants NAME=value (the legacy @constants.NAME token); prefer an in-policy constants block for values that live with the policy.
Environments, one file, many deploys
The environments block is a client-side (CLI-only) construct that lets one policy.json drive several apps, staging, production, each with its own appId and its own constant values. The dev-api never sees it; the CLI resolves it and ships a normal policy.
{
"environments": {
"staging": { "appId": "6a2e...stg", "constants": { "ADMIN": "StgWallet", "DAILY_CAP": 50 } },
"production": { "appId": "6a2e...prd", "constants": { "ADMIN": "PrdWallet", "DAILY_CAP": 5000 } }
},
"constants": { "ADMIN": "StgWallet", "DAILY_CAP": 50 },
"roles": { "admin": { "members": ["@const.ADMIN"], "read": "*" } },
"spend/$id": {
"fields": { "amount": "UInt" }, "tier": "durable",
"rules": { "read": "@user.id != null", "create": "@user.id != null", "update": "false", "delete": "false" },
"invariants": [
{ "type": "rollingSum", "name": "cap", "field": "amount", "windowSeconds": 86400, "limit": "@const.DAILY_CAP" }
]
}
}For --environment <name>, the CLI overlays that env’s constants onto the top-level constants block (env wins), targets its appId, then strips the block and ships a normal policy:
bounded deploy ./policy.json --environment staging # staging appId + constants
bounded deploy ./policy.json --environment production # production appId + constantsError disclosure, minimal vs full
errorDisclosure controls how much of a policy rejection reason reaches the client. It does not change enforcement and it hides nothing from the owner, it only decides what the rejected caller is told. Set it to "full" or "minimal", at the top level (policy-global) and/or inside a single collection. The most specific value wins:
| Level | Wins over |
|---|---|
errorDisclosure on a collection | Most specific, overrides the policy-global value for that collection. |
errorDisclosure at top level | Policy-global default for every collection that does not set its own. |
| Environment default | Used when neither is set: minimal in production, full everywhere else (dev + staging). So you debug freely locally and prod is locked down with zero config. |
Both modes keep a machine-readable decline. The disclosure switch gates detailed human text, rule/formula text, and numeric boundary values; it does not make a rejection opaque:
| Mode | Client sees |
|---|---|
full | The stable envelope and structured decline, plus the full reason or failed rule expression. When available, decline.boundary also includes numeric cap, windowSeconds, current, and attempted, e.g. postcondition failed: invariant "spend_cap" requires rolling sum(spend/$id.amount) <= 100. |
minimal | A generic message ("Access denied by policy." / "This change was rejected because it would violate a data constraint.") plus the stable code and structured decline. A named invariant remains available at the top level and inside decline; boundary type/field, collection, and operation can also remain. Formula/rule text and numeric cap, windowSeconds, current, and attempted are withheld. |
A policy rejection uses { error, code, status, requestId?, invariant?, decline }.requestId is present when one is available; invariant is present for named invariant violations. code remains the stable category in minimal mode:
| code | Meaning |
|---|---|
policy_denied | 403 for writes/invokes; denied reads are hidden as 200 with empty data. |
invariant_violation | 409, a postcondition / invariant (rollingSum, conserve, ...) was violated. |
{
"error": "This change was rejected because it would violate a data constraint.",
"code": "invariant_violation",
"status": 409,
"invariant": "released_le_deposited",
"decline": {
"verdict": "declined",
"reason": "invariant",
"invariant": "released_le_deposited",
"collection": "vault",
"op": "create",
"boundary": { "type": "flowBound", "field": "amount" },
"message": "This change was rejected because it would violate a data constraint.",
"provenAtDeploy": true
}
}"boundary": {
"type": "flowBound",
"field": "amount",
"cap": 100,
"current": 40,
"attempted": 70
}{
"errorDisclosure": "minimal", // policy-global default
"spend/$id": {
"fields": { "amount": "UInt" }, "tier": "durable",
"errorDisclosure": "full", // per-collection override (wins)
"rules": { "read": "@user.id != null", "create": "@user.id != null", "update": "false", "delete": "false" },
"invariants": [
{ "type": "rollingSum", "name": "spend_cap", "field": "amount", "windowSeconds": 3600, "limit": 100 }
]
}
}More collection features
- Storage collections, declare
"type": "storage"to back a collection with R2 files, scoped by the same path rules. See Files & search. - Full-text search, declare
"search": { "fields": [...] }to indexStringfields. See Files & search. - Live sessions, a
session.live/session.tickblock turns arooms/$roomIdtemplate into a server-authoritative realtime room. See Realtime & live. - Functions, the top-level
functionsblock declares imperative escape-hatch handlers. See Functions.