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.

policy.json, top-level shape
{
  "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, not my-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/i9 binds $tenantId = "t1" for every rule on that template.
  • Two templates may not collide modulo variable names, users/$a and users/$b is a deploy error.

Fields

fields maps field names to types. Names start with a letter; id, pathId and platform-prefixed names are reserved.

TypeMeaning
StringUTF-8 string
IntSigned safe integer
UIntUnsigned safe integer, required for rollingSum fields
Booltrue / false
FloatDecimal. Not allowed on onchain collections, use Int/UInt.
AddressWallet / 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, owner-scoped documents
"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. null when unauthenticated.
  • @data.field, the existing document (not available in create rules).
  • @newData.field, the incoming document (not available in delete rules).
  • @time.now, server time.
  • $pathVariable, any variable from the path template.
  • get(/path) / getAfter(/path), read another document: get sees pre-transaction state, getAfter sees 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.

one-click market settlement, proof + runtime rule
{
  "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.

TierSemanticsUse for
durableEvery write is committed before the caller sees success. Required for rollingSum and materialized/sharded conservation.Money, ledgers, anything an invariant protects
checkpointedWrites fold to the provable store on an interval; survives + is replayable. Required for a provable live room.High-write app state, live rooms, counters
ephemeralIn-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, side effects, not gates
"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.

roles, admin / editor / viewer
{
  "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"
    }
  }
}
KeyMeaning
membersNon-empty array of universal principal identities (@user.id values). Use @const.NAME to keep them in a constants block.
readGates the read action. "*" = every collection; an array lists collection names.
writeGates 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.NAME anywhere 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 number 5000).
  • 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 + defs
{
  "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 + production
{
  "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 + constants

Error 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:

LevelWins over
errorDisclosure on a collectionMost specific, overrides the policy-global value for that collection.
errorDisclosure at top levelPolicy-global default for every collection that does not set its own.
Environment defaultUsed 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:

ModeClient sees
fullThe 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.
minimalA 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:

codeMeaning
policy_denied403 for writes/invokes; denied reads are hidden as 200 with empty data.
invariant_violation409, a postcondition / invariant (rollingSum, conserve, ...) was violated.
minimal invariant rejection, identity retained; numbers withheld
{
  "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
  }
}
full flowBound boundary, numeric detail added
"boundary": {
  "type": "flowBound",
  "field": "amount",
  "cap": 100,
  "current": 40,
  "attempted": 70
}
errorDisclosure, global + per-collection
{
  "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 index String fields. See Files & search.
  • Live sessions, a session.live / session.tick block turns a rooms/$roomId template into a server-authoritative realtime room. See Realtime & live.
  • Functions, the top-level functions block declares imperative escape-hatch handlers. See Functions.