Policies
Policies are CEL rules the engine evaluates at fixed points in a step's lifecycle. They live as YAML files under POLICIES_ROOT and are referenced by path from saga steps (policy: my-gate.yaml), not by (namespace, name, version) like saga definitions.
Only the engine evaluates policies — never the worker. Unlike an LLM, CEL rules are entirely deterministic—the model can't hallucinate its way around them, interpret them creatively, or override your guardrails. The engine evaluates the exact same expression against the same inputs and gives you an identical pass/fail result every single time. The only thing that changes is when it checks: reasoning steps are evaluated right after the worker finishes (after_reason), while commit steps are evaluated right before a tool call leaves the engine (before_commit). On commit steps, before_commit is the hard gate — bad arguments never reach an external system. On react reason steps, MCP tools on the allowlist may already have run before after_reason fires; for a single irreversible side effect with a hard gate, use a commit step.
The sections below cover policy files, attachment syntax, CEL bindings, outcomes, and how policies differ from when.cel.
End-to-end example: GitHub MCP demo.
Policy files
Each policy is a YAML file under POLICIES_ROOT (default ./config/policies in the repo). Paths like ./config/policies are relative to your repository root (where the engine runs)—not to this documentation file's location in the docs tree. The file must contain a non-empty cel: expression. Optional metadata:
| Field | Purpose |
|---|---|
name | Display name (defaults to the manifest ref minus extension, preserving subdirs) |
version | Optional label on the policy artifact (frozen onto the step at saga start) |
cel | CEL expression — must evaluate to bool (true = pass, false = deny) |
# config/policies/github-issue-comment.yaml
name: github-issue-comment
version: "1"
cel: "phase == 'before_commit' && step.kind == 'commit' && step.id == 'post-comment' && tool.name == 'add_issue_comment' && arguments.owner == input.owner && arguments.repo == input.repo && arguments.issue_number > 0 && size(arguments.body) > 0 && size(arguments.body) <= 8000 && arguments.body.contains('## Warden triage')"
warden deploy validates that each referenced policy file exists and that its CEL compiles. At saga start, the engine freezes {name, version, cel} into the instance frozen_steps (and onto the step row as policy_definition), same pattern as compensation embeds. Runtime gates evaluate that frozen CEL. Keep policy files on disk where the engine can read them at start/deploy — see Configuration for POLICIES_ROOT and Compose mounts.
CEL expressions evaluate to true or false — in a policy gate, true passes and false denies. For syntax, operators, and functions, see the CEL documentation.
Attaching to a step
Reference a policy by path on the step definition that should be gated (not on the saga use: ref):
# config/step.github-post-comment.yaml (excerpt)
kind: step
name: github-post-comment
step_kind: commit
worker: github-demo-worker
worker_version: "0.1.0"
policy: github-issue-comment.yaml
tools:
allow:
- name: add_issue_comment
Saga composition binds ports and optional when / HITL tighten overrides — see Step manifests and Saga manifests.
Evaluation phases
The engine chooses the evaluation phase from the step's kind — you do not set after_reason or before_commit in the saga manifest or policy file.
| Phase | When | Step type | What CEL can inspect |
|---|---|---|---|
after_reason | After the worker returns structured reason-step output, before it is merged into saga context | Reason | output (validated business object), arguments (resolved step inputs) |
before_commit | Before DO_COMMIT is queued to the worker | Commit | arguments (resolved with → MCP tool args), tool.name |
On react reason steps, MCP tools on the allowlist may already have run during the ReAct loop before after_reason fires. For a single irreversible side effect with a hard gate, use a commit step and before_commit.
CEL evaluation context
At gate time the engine exposes these top-level names in policy cel::
| Name | Contents |
|---|---|
phase | "after_reason" or "before_commit" |
input | Saga start payload (context.input) |
arguments | Resolved step inputs — on commit steps, the MCP argument map from with |
output | Reason-step structured output in output.data (empty on before_commit) |
saga | trace_id, namespace, status |
step | id, name, kind, order_index |
worker | name (worker manifest name) |
tool | name of the single allowed tool (commit steps only) |
Policy CEL does not receive steps.* from saga context. To gate on a prior step's output or facts, bind the values you need into with (commit) or read them from the current step's output / arguments (reason). The GitHub demo policy checks arguments.owner, arguments.body, etc., because with already resolved them from earlier steps.
Policy vs when.cel
Both use CEL, but they are different gates with different bindings. Full when.cel reference: Conditional branching.
when.cel on a step | policy: on a step | |
|---|---|---|
| What does it ask? | Should this step run at all? | May this step proceed past the gate? |
| Evaluated when? | Before the step is scheduled | after_reason or before_commit |
| If false? | Step SKIPPED; saga continues | Step FAILED; compensation if needed |
steps in binding? | Yes — full context.steps | No |
| Typical use? | Branching, optional steps | Validate agent output or block bad commit args |
Policy files are validated (load + CEL compile) at saga registration as well.
Outcomes
CEL evaluates to true or false. true → passed; false → denied. If the expression cannot run (missing file, parse error, wrong type), the outcome is errored — treated like a denial for the step.
| Outcome | When | Effect |
|---|---|---|
passed | CEL is true | Step continues |
denied | CEL is false | Step fails (POLICY_REASON_DENIED or POLICY_COMMIT_DENIED); compensation if needed |
errored | CEL did not evaluate to bool | Step fails (POLICY_EVALUATION_FAILED); same halt as denial |
Denial vs HITL
Denial and human-in-the-loop review are independent. A policy denial fails the step — it doesn't automatically pause for human review. HITL is configured separately with hitl: true on the step. See HITL review for how that flow works.
You can use both on the same step: the policy gate runs first, and HITL only triggers if the policy passes.
What's next
Next up: Compensation — declare automatic rollback strategies and safety nets for when things go wrong mid-workflow.
Related
- Conditional branching (
when.cel) — schedule-time CEL gates - Saga manifests —
withbindings, reason vs commit - MCP and tools — allowlists and commit-step boundaries
- GitHub MCP demo —
before_commitpolicy on commit arguments