Policy graph frontend (Phase 3, epic #1079)

Status: Phase 3 design — accepted format + scope for v1.0; parser (G2) + IR mapping (G3) + CLI (G4) + INV-MM-7 (G5) + tutorial (G6) land on the same epic branch.

The graph frontend is the third policy authoring frontend, after the YAML DSL (docs/architecture/multi-modal-policy.md) and JSON Rules. It targets one specific authoring problem the other two frontends model awkwardly: branched approval chains — “tool X requires approval from these N people under condition A, but only this 1 person under condition B.”

Why a separate frontend

YAML DSL + JSON Rules already let you express approval requirements by authoring a require_approval rule with a when: predicate. That works for a single chain. The pain shows up when:

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

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

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

By hand: N branches × M conditions = N×M require_approval rules to maintain, with no syntactic kinship between them. The graph frontend compiles to exactly that rule set from a declarative chain definition, so the operator authors intent (the chain) and the IR captures mechanism (the rules).

Scope decision: v1.0 ships compilation, NOT runtime workflow

This frontend lowers entirely to existing IR require_approval rules (shipped by epic #1070 F3b). Adapter inaction is what require_approval already does — block via !IsAllowed() Defer gate. So the asymmetry test passes: this is an IR-frontend-only epic, no Phase 2-style adapter substrate bundled.

What the v1.0 frontend does NOT ship (deferred to follow-up epics if operator demand emerges):

  • Approval token mechanism — multi-stage chains where stage N gates on approval tokens from stage N-1 require an input.approval_tokens bucket and a workflow tool that issues tokens. Out of scope.

  • Approver metadata in IR Rule — the approvers: + require: fields in the graph format are operator-visible intent today; future workflow tooling consuming them needs an IR shape extension (e.g. Rule.Approval *ApprovalBlock) that this epic does NOT add.

  • Visual editor / web canvas — text format only; UI is a separate concern.

  • Conditional approver routing (X approves IF condition beyond when:) — out of scope for v1.0; same when: predicate the IR already supports is the only conditional surface.

Graph YAML format (v1.0)

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
  - id: deploy-approval
    tool: tool.deploy
    branches:
      - id: any-deploy
        # no `when:` → unconditional match for this tool kind
        approvers:
          - ops-lead
          - eng-lead
        require: any

Field reference

Top-level:

Field

Type

Required

Notes

version

string enum

yes

Currently "1.0.0" only; mirrors IR version discipline (policy/ir.SupportedVersions)

chains

array of Chain

yes

One chain per (operator-conceptual) approval policy

Chain:

Field

Type

Required

Notes

id

string

yes

Operator label; surfaces in error messages + the compiled IR rule IDs. MUST NOT contain / (reserved for the compiled <chain>/<branch> separator — see Validation contract).

tool

string

yes

The tool kind this chain gates (e.g. tool.shell)

branches

array of Branch

yes (≥1)

Each branch becomes one IR rule. Order is operator-controlled and preserved in the compiled IR (first-match-wins semantics)

Branch:

Field

Type

Required

Notes

id

string

yes

Operator label; surfaces in Decision.MatchedRule.ID after compilation (combined as <chain.id>/<branch.id>). MUST NOT contain / (same separator-reservation rule as chain.id).

when

Predicate

no

IR predicate (same shape as policy/ir/ir.schema.json#Predicate); absent means unconditional match for the chain’s tool

approvers

array of strings

no

Operator intent (metadata); not consumed by the IR/runtime today. Future workflow tooling will read these

require

enum all|any

no, default all

Operator intent (metadata); same deferral as approvers

when’s shape is byte-identical to the IR Predicate (closed op set eq/ne/in/and/or/not; leaf vs compound arity rules; scalar / homogeneous-array value rules). The graph parser delegates the predicate sub-tree validation to policy/ir.Predicate.Validate() rather than re-implementing it — INV-MM-7 stays satisfied.

IR mapping (G3 implements; documented here so the contract is fixed)

Each Chain.Branch lowers to exactly one IR Rule:

Branch                                    IR Rule
─────────────────────────────────────     ─────────────────────────────────────
chain.id          = "prod-shell-..."      Rule.ID     = "prod-shell-.../production-strict"
chain.tool        = "tool.shell"          Rule.Tool   = "tool.shell"
                                          Rule.Effect = EffectRequireApproval
branch.when                               Rule.When   = (same Predicate)
branch.approvers  = [on-call, security]   (NOT in IR yet — see deferred scope above)
branch.require    = all                   (NOT in IR yet)
                                          Rule.Source = file + line of branch in graph YAML

The compiled ir.Program has:

  • Default = EffectDeny — load-bearing. Chains express positive approval requirements; anything that doesn’t match any chain’s (tool, when) falls through to fail-closed deny. This is a fixed contract in v1.0: every graph file compiles to a complete, standalone ir.Program. Composition with YAML DSL / JSON Rules output is operator workflow (run policy build once per frontend and load both bundles), NOT an IR-level merge — the IR has no ir.Merge(a, b) API today, and inventing one would touch every frontend’s contract. If multi-frontend composition lands later, it’ll do so as a separate epic with its own merge semantics (precedence rules, default conflict resolution).

  • Rules = ordered list, one per branch across all chains, in document order (chain A branches in order, then chain B branches in order, etc). First-match-wins per IR contract.

Worked compilation example

Given the YAML above, policy/graphdsl/Parse + Graph.ToIRProgram produces:

version: "1.0.0"
default: deny
rules:
  - id: prod-shell-approval/production-strict
    effect: require_approval
    tool: tool.shell
    when:
      op: eq
      field: params.env
      value: production
    source:
      file: chains.graph.yaml
      line: 6
  - id: prod-shell-approval/staging-relaxed
    effect: require_approval
    tool: tool.shell
    when:
      op: eq
      field: params.env
      value: staging
    source:
      file: chains.graph.yaml
      line: 15
  - id: deploy-approval/any-deploy
    effect: require_approval
    tool: tool.deploy
    source:
      file: chains.graph.yaml
      line: 26

The runtime evaluator sees three require_approval rules and behaves exactly as if the operator had hand-written them — Decision.Outcome = Defer, Decision.IsAllowed() = false, action blocked.

Decision.MatchedRule.ID carries the <chain>/<branch> form so autonomy wal inspect --kind autonomy.decision shows which branch fired, and Decision.MatchedRule.Source.File/Line points back into the graph YAML for explainability (the C05+C06 path from #1066).

Validation contract (G2 implements; documented here)

Beyond IR-level Validate (which fires after ToIRProgram runs):

  • version MUST be in the supported set.

  • chains MUST be non-empty.

  • Within a graph file, chain.id MUST be unique.

  • Within a chain, branch.id MUST be unique (so the compiled Rule.ID <chain>/<branch> is also unique across the program — required by IR Rule.ID uniqueness from PR #1065).

  • Neither chain.id nor branch.id MAY contain /. The character is reserved for the compiled-Rule.ID separator; without this rule two valid (chain, branch) pairs can collapse to the same IR Rule.ID (e.g. chain="a/b" + branch="c" and chain="a" + branch="b/c" both produce a/b/c). Operators wanting hierarchical naming use ., -, or _ (e.g. team.security.critical-shell). PR #1082 review fix1.

  • chain.tool MUST be non-empty.

  • branch.approvers if present MUST be a non-empty array of non-empty strings (catches typos that would otherwise be silent metadata loss).

  • branch.require if present MUST be exactly all or any.

  • branch.when predicate is validated via the IR Predicate validator (no graph-side re-implementation).

  • Duplicate top-level / chain / branch / predicate keys reject at parse time (mirrors the YAML DSL discipline from PR #1063).

Operator workflow (target shape, lands in G4)

autonomy policy compile-graph chains.graph.yaml --out compiled/policy.rego
autonomy policy build --in compiled --out bundle.tar.gz --version 1.0.0 \
  --policy-frontend graph
autonomy policy eval --bundle bundle.tar.gz --kind tool.shell \
  --params '{"env": "production"}'
# → outcome=defer, matched_rule_id=prod-shell-approval/production-strict,
#   matched_rule_file=chains.graph.yaml, matched_rule_line=6

The --policy-frontend graph value extends policy.BundleManifest’s existing enum (rego | yaml-dsl | json-rules | graph).

Invariant coverage

INV

Statement

How this frontend stays inside it

INV-MM-1

Ternary outcome preserved

Compiles only to existing EffectRequireApproval; no new effects

INV-MM-2

Evaluator fail-closed

ToIRProgram returns validated IR; policy/ir.Program.Validate re-fires defense-in-depth

INV-MM-3

Runtime is sole authority

Runtime has no awareness of policy_frontend = "graph"; reads the compiled IR identically to YAML/JSON outputs

INV-MM-4

Compile is deterministic

Chain + branch document order preserved through ToIRProgram (no maps, no time)

INV-MM-5

Provenance on both manifest layers

--policy-frontend graph populates both policy.BundleManifest.PolicyFrontend and bundle.Manifest.PolicyFrontend

INV-MM-6

Explainability survives end-to-end

branch.idRule.ID; branch line → Rule.Source.Line; policy.Eval carries through to Decision.MatchedRule

INV-MM-7

IR is the only frontend↔backend contract

policy/graphdsl/ imports only policy/ir, never any other frontend or backend (G5 extends the CI check)

INV-MM-8

Preflight surfaces are frontend-orthogonal

Graph chains don’t reference input.preflight; ContractRefine integration unchanged

Sequencing (G2–G6 follow this design)

  • G2policy/graphdsl/ package: Parse(data, filename) (Graph, error) + Graph.Validate(). No IR mapping; just the typed graph + structural validation. Mirrors policy/jsonrules/parser.go shape.

  • G3Graph.ToIRProgram() (ir.Program, error). Golden-file tests pin the expansion contract for representative chains (single branch, multiple branches per chain, multiple chains, predicate-bearing branches, edge cases).

  • G4autonomy policy compile-graph subcommand + --policy-frontend graph enum value on policy build. Mirrors compile-yaml / compile-json shape.

  • G5 — INV-MM-7 CI sub-check extended in ci/test_arch_invariant.sh to assert policy/graphdsl/ imports only policy/ir.

  • G6 — End-to-end tutorial in docs/tutorials/policy-graph-authoring.md with the worked approval-chain example above, plus integration test pinning the tutorial commands against actual CLI output.