Authoring approval-chain policies as graphs

This tutorial walks through the graph frontend landed by epic #1079, the third authoring modality in the multi-modal policy stack after the YAML DSL and JSON Rules. You’ll author a branched approval chain in graph YAML, compile it to the require_approval IR rules the runtime evaluator promotes to runtime.Defer, build a deployable bundle, and inspect which branch fired through the telemetry WAL.

When to use the graph frontend

YAML DSL and JSON Rules already let you write a single require_approval rule. The graph frontend earns its keep when:

  • The same tool kind has multiple branches with different approver requirements per environment / region / tier.

  • The branches share structure but differ in approvers / require: all|any.

  • A reviewer wants to read the approval chain as a hierarchy, not as N independent rules scattered through a flat list.

The graph frontend lowers each branch to exactly one IR rule with effect: require_approval, so the runtime semantics are identical to what you’d get from hand-authoring the rules in YAML — but the authoring document captures the operator’s intent (the chain) explicitly.

Prereqs

  • autonomy CLI installed (autonomy --version should report a build).

  • A working directory you can write files into.

Step 1 — Author the approval chain

Save the following as chains.graph.yaml:

version: "1.0.0"
chains:
  - id: prod-shell-approval
    tool: tool.shell
    branches:
      - id: production-strict
        when:
          op: eq
          field: params.env
          value: production
        approvers:
          - on-call
          - security
        require: all
      - id: staging-relaxed
        when:
          op: eq
          field: params.env
          value: staging
        approvers:
          - on-call
        require: all

This is one chain (prod-shell-approval) gating tool.shell with two branches: production-strict requires on-call AND security to approve when env=production; staging-relaxed requires only on-call when env=staging. Anything else falls through to the program-level default (deny) — see Scope decision for why graph files compile to standalone programs.

Schema at a glance

Field

Required

Notes

version

yes

Must be "1.0.0"

chains

yes (≥1)

One chain per (operator-conceptual) approval policy

chains[].id

yes

Unique within the document; MUST NOT contain / (reserved for the compiled <chain>/<branch> separator)

chains[].tool

yes

Tool kind this chain gates (tool.shell, tool.deploy, etc.)

chains[].branches

yes (≥1)

Each branch becomes one IR rule

branches[].id

yes

Unique within the chain; MUST NOT contain /

branches[].when

no

IR-shape predicate (same op set as YAML DSL: eq/ne/in/and/or/not). Absent = unconditional match for the chain’s tool

branches[].approvers

no

List of approver labels (operator intent — metadata today)

branches[].require

no

all (default) or any

Approvers + Require are operator intent today; future workflow tooling will consume them once an IR Approval block lands. The graph parser validates their shape so typos surface at compile time instead of being silently lost.

Step 2 — Compile to Rego

mkdir -p compiled
autonomy policy compile-graph chains.graph.yaml --out compiled/policy.rego

You should see:

policy compile-graph: wrote compiled/policy.rego (2 rules, <N> bytes)

Two rules — one per branch. The compiled IR Rule IDs are <chain.id>/<branch.id>:

  • prod-shell-approval/production-strict

  • prod-shell-approval/staging-relaxed

The generated Rego is the standard data.autonomy package the runtime evaluator loads. Each rule carries effect: require_approval in the rules_meta block, so the evaluator maps a match to runtime.Defer (which runtime.Decision.IsAllowed() returns false for — the action blocks).

Step 3 — Build a bundle

autonomy policy build \
  --in compiled \
  --out bundle.tar.gz \
  --version 1.0.0 \
  --name prod-shell-approval \
  --policy-frontend graph

Expected output:

policy build: OK
  version:         1.0.0
  hash:            sha256:<digest>...
  runtime-version: 
  policy_frontend: graph
  output:          bundle.tar.gz

The --policy-frontend graph flag stamps the inner manifest’s policy_frontend field, so downstream provenance audits can distinguish graph-authored bundles from hand-written Rego / yaml-dsl / json-rules. See INV-MM-5 for the cross-check contract this enables.

Step 4 — Evaluate a request

autonomy policy eval \
  --bundle bundle.tar.gz \
  --kind tool.shell \
  --params '{"env": "production"}'

A single JSON line on stdout:

{"outcome":"defer","reason":"...","kind":"tool.shell","params":{"env":"production"},"matched_rule_id":"prod-shell-approval/production-strict","matched_rule_file":"chains.graph.yaml","matched_rule_line":6,...}

The load-bearing fields:

  • outcome: defer — the action is blocked pending approval. policy eval’s exit code is 1 (any non-allow outcome).

  • matched_rule_id — the compiled Rule.ID, i.e. <chain.id>/<branch.id>. The graph-frontend convention makes the operator-authored chain + branch immediately recoverable from the runtime decision.

  • matched_rule_file + matched_rule_line — the explainability anchor (C05+C06 path from epic #747). The line points to the branch in the original graph YAML.

Try the staging branch:

autonomy policy eval \
  --bundle bundle.tar.gz \
  --kind tool.shell \
  --params '{"env": "staging"}'
{"outcome":"defer",...,"matched_rule_id":"prod-shell-approval/staging-relaxed","matched_rule_line":15,...}

Different branch fired; line number points at the staging-relaxed branch in the source. Try a non-matching env:

autonomy policy eval \
  --bundle bundle.tar.gz \
  --kind tool.shell \
  --params '{"env": "dev"}'
{"outcome":"deny","reason":"default deny","kind":"tool.shell","params":{"env":"dev"}}

outcome: deny — no branch’s when matched, so the program-level default (deny) fired. No matched_rule_id because no rule fired.

Step 5 — Load + observe in the WAL

When the bundle is loaded into a running runtime server, each decision lands in the WAL with the same explainability fields:

autonomy wal inspect --kind autonomy.decision --json

Each line is a JSON object whose attrs carry matched_rule_id, matched_rule_file, and matched_rule_line for every graph-authored decision, identically to the explainability fields the YAML authoring tutorial documents — the IR makes both frontends emit the same downstream shape.

Validation rules to be aware of

The graph parser is fail-closed and rejects common authoring mistakes at compile time:

  • Unknown top-level / chain / branch key → reject.

  • Duplicate keys at any level → reject (a common copy-paste mistake that would otherwise silently “second-wins”).

  • Chain ID containing / → reject. The / is reserved for the compiled-Rule.ID separator; without this rule two valid chains could collapse to the same Rule.ID. Use ., -, or _ for hierarchical naming (e.g. team.security.critical-shell).

  • Duplicate chain.id across the document → reject.

  • Duplicate branch.id within a chain → reject.

  • Predicate semantic errors (unknown op, leaf without field, compound with field, not with multiple children) → reject. The graph parser delegates predicate validation to the IR validator (INV-MM-7).

  • Non-string scalars where the schema says string (tool: false, id: 42, require: true) → reject with a hint to quote the value.

What’s NOT in scope today

  • Approver tokens / multi-stage chains. The runtime today blocks on require_approval and waits for an upstream system to decide. A future epic could add an input.approval_tokens bucket so chains can advance through stages; that needs workflow-substrate work not in scope here.

  • Approver-metadata-in-IR. Branch approvers: + require: are captured + validated for shape but NOT propagated into the compiled IR Rule. Future workflow tooling needs an ir.Rule.Approval field that this epic doesn’t add.

  • Visual / graphical editor. Text format only.

See Scope decision for the full deferred-scope list and the asymmetry-test rationale.