---
title: "Policy publishing"
description: "How a Cedar draft becomes a compiled snapshot, reaches Redis as a monotonic pair, and is picked up by the gateway — with the versioning, retry, and readback model."
---
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
`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.
`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.
`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
| Hash | Covers | Used for |
|---|---|---|
| `draft_hash` | The Cedar source bytes only | Optimistic concurrency on publish; "am I publishing the draft I read?" |
| `published_hash` / `PolicySnapshot.hash` | The compiled bundle: source **plus** the generated entity snapshot | Identifying a revision and keying the gateway's authorizer cache |
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](/policy/cedar).
## 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:
| Outcome | Response |
|---|---|
| Success | The revision, its hash, and the publication state |
| Invalid bundle | 422 carrying the operator projection with `validation_state: "invalid"` and bounded diagnostics with line and column |
| Over the process concurrency budget | 503 `policy_compiler_busy` |
| Compiler missing, timed out, or unusable | 503 `policy_compiler_unavailable` |
| Draft or revision moved underneath | 409 `policy draft changed` or `policy revision changed`, with no writes |
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.
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.
Compile with no transaction open and no lock held.
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:
```text
snap:workspace:{workspace_id} the WorkspaceSnapshot JSON
policy:{workspace_id} the PolicySnapshot JSON
snap:pair-fingerprint:{workspace_id} sha256 over the exact published bytes
```
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:
```json
{"type": "workspace", "workspace_id": "ws_acme_prod"}
{"type": "policy", "workspace_id": "ws_acme_prod"}
```
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:
| Error code | Meaning |
|---|---|
| `compile_failed` | The committed pair could not be compiled |
| `redis_unavailable` | The write or the readback could not reach Redis |
| `readback_stale` | Redis did not return a coherent pair at or above the requested version |
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](/policy/overview), [Cedar authorization](/policy/cedar), [Gateway overview](/gateway/overview), [Policies API](/api-reference/policies), [Invariants](/concepts/invariants).