Three states, three rules
1
A read never writes
GET /workspaces/{workspace_id}/policy returns the stored draft, its hash, the current revision, and the publication state. For a workspace that has never published, it returns a projection of what a first publish would produce — the default source and a stable pol_default_… id — without creating any row.2
A draft is not live
PUT /workspaces/{workspace_id}/policy stores Cedar source and stamps draft_updated_at. It does not compile, does not create a revision, and does not touch the workspace snapshot version. It requires the policy.draft action, held by developers and above.3
Publish compiles the stored draft
POST /workspaces/{workspace_id}/policy/publish requires the policy.publish action, held by admins and owners. The body carries expected_draft_hash and optionally expected_revision_id; publishing confirms which stored draft goes live and can never submit a new one.POST /workspaces/{workspace_id}/policy/test compiles the stored draft and reports diagnostics without writing anything, so a developer can find out why a draft will be rejected before an admin tries to publish it.
Two hashes that are never comparable
The bundle hash moves whenever a key or a connection changes, even if no policy was edited, so the two are never equal after a publish and must never be compared. “Is my draft the one that is live?” is answered by
current_revision_id plus the publication state.
published_hash also is not the hash of what is currently running: it is frozen at publish time over source and entities-as-then, while each publication rebuilds the Redis pair from the revision’s source plus fresh entities. It identifies the revision, not the running bundle.
Entities are generated
Operators author Cedar; they never author entities. Control derives the entity set from the workspace graph on every compile:- one
Obol::Workspacewithenvandfrozen - one
Obol::Agentper distinct agent id across the workspace’s virtual keys, carryingenv,key_prefix,tools_mode, andmax_refund_usdas a Cedar decimal extension value when the key declares it - one
Obol::Connectionper connection, with its connector slug and status - one
Obol::Toolper tool, parented to its connection - one
Obol::Modelper active model route
Agent from the current KeyContext rather than trusting the published copy — see Cedar authorization.
Compilation
Compilation runsobol-policy-check compile --format json, a subprocess that is the only Rust inside the control image. It parses, strictly validates against the built-in Cedar schema, loads the entities, and emits a PolicyCompileReport on stdout for both success and invalid outcomes.
Compiler stderr, exit codes, and exception text never reach an operator response. The outcomes an operator can see are:
Diagnostics from the entity stage are replaced with a fixed message and their span dropped, because entity errors can quote agent ids, key prefixes, connection ids, tool names, and refund caps.
The publish transaction
Publishing is deliberately three phases so that no lock is ever held across the compiler subprocess.1
Phase A — read only
Snapshot
(policy_id, cedar_text, draft_hash, current_revision_id) and the entity set, check the caller’s expectations against them, then close the read transaction.2
Phase B — compile
Compile with no transaction open and no lock held.
3
Phase C — one write transaction
Lock the workspace and the policy row, re-check the same draft hash and revision id, then commit the new revision, the actor-attributed audit row, and one coalescing durable publication intent together or not at all. Anything that moved underneath is a 409 with no writes.
Workspace.snapshot_version by one and sets the workspace’s SnapshotPublication row to pending with the new requested_version.
Publication to the hot path
After the commit, control publishes the committed pair to Redis. This is post-commit on purpose: a Redis outage must not erase a committed operator mutation. The write is one atomic operation over three keys:obol:invalidate channel:
/internal/reload as an accelerator. That call is best-effort: Redis pub/sub is the contract, and a failed reload is ignored.
Readback
Control reads the three keys back and only records success when the pair is coherent — the stored fingerprint matches the stored bytes — and either the bytes are exactly what it submitted, or, when its own write was refused as stale, the live pair is a coherent higher version for the same workspace with a plausible policy and revision identity. Failures are recorded durably with a reason and retried with exponential backoff capped at 300 seconds:
The operator response reports
state: "pending" with the requested_version, and published_version once known. A pending publication is enqueued for retry immediately, and a cron sweep re-drives every due pending publication every ten seconds until readback confirms the requested version.
How the gateway picks up changes
The gateway holds one immutable workspace/policy pair per workspace in a snapshot cache, with a 30-second freshness bound.- A fresh in-memory pair is served directly. On a miss or after 30 seconds, one Redis
MGETreads workspace, policy, and fingerprint together; only a validated coherent pair is installed. Failed revalidation does not extend the previous pair’s freshness — expired reads fail closed. - A dedicated pub/sub connection listens on
obol:invalidateand reconnects with backoff. Aworkspaceorpolicymessage re-reads both snapshots for that workspace and drops it from memory if the key is gone;allreconciles everything and preloads. Messages are rebroadcast even when the reload fails, so downstream listeners still drop stale state. - Immediately after subscription or reconnection, and every 30 seconds thereafter, known workspace ids are reconciled to repair missed notifications.
- A request pins one publication pair for routing and authorization, so a mid-request republish cannot split a decision across two versions.
- On a complete Redis miss, the gateway may ask control for the pair over the internal snapshot API; an atomic create-if-absent publishes it and the gateway reads back the authoritative winner. A delayed response never overwrites an existing publication.
PolicySnapshot and cached by (workspace_id, policy_id, revision_id, hash). Because the cache key includes the publication identity and not just the bundle content, every verdict names the revision that is actually live. If a compile fails, the cache is untouched and the previous authorizer keeps serving.
Related: Policy overview, Cedar authorization, Gateway overview, Policies API, Invariants.