Policy is compiled data. Control owns authoring, compilation, and publication; the gateway only loads and evaluates. Postgres is the source of truth, Redis is the distribution mechanism, and neither sits on a per-decision path.

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::Workspace with env and frozen
  • one Obol::Agent per distinct agent id across the workspace’s virtual keys, carrying env, key_prefix, tools_mode, and max_refund_usd as a Cedar decimal extension value when the key declares it
  • one Obol::Connection per connection, with its connector slug and status
  • one Obol::Tool per tool, parented to its connection
  • one Obol::Model per active model route
For a live request the gateway replaces the authenticated principal’s Agent from the current KeyContext rather than trusting the published copy — see Cedar authorization.

Compilation

Compilation runs obol-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.
Staging the intent increments 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:
It refuses to write when a higher workspace version already exists, so publication is monotonic. On success it publishes two invalidation messages on the obol:invalidate channel:
Control then calls the gateway’s /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 MGET reads 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:invalidate and reconnects with backoff. A workspace or policy message re-reads both snapshots for that workspace and drops it from memory if the key is gone; all reconciles 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.
Cedar authorizers are compiled lazily from the pinned 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.