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_tokensbucket 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; samewhen: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 |
|---|---|---|---|
|
string enum |
yes |
Currently |
|
array of |
yes |
One chain per (operator-conceptual) approval policy |
Chain:
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
string |
yes |
Operator label; surfaces in error messages + the compiled IR rule IDs. MUST NOT contain |
|
string |
yes |
The tool kind this chain gates (e.g. |
|
array of |
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 |
|---|---|---|---|
|
string |
yes |
Operator label; surfaces in |
|
|
no |
IR predicate (same shape as |
|
array of strings |
no |
Operator intent (metadata); not consumed by the IR/runtime today. Future workflow tooling will read these |
|
enum |
no, default |
Operator intent (metadata); same deferral as |
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, standaloneir.Program. Composition with YAML DSL / JSON Rules output is operator workflow (runpolicy buildonce per frontend and load both bundles), NOT an IR-level merge — the IR has noir.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):
versionMUST be in the supported set.chainsMUST be non-empty.Within a graph file,
chain.idMUST be unique.Within a chain,
branch.idMUST 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.idnorbranch.idMAY 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"andchain="a" + branch="b/c"both producea/b/c). Operators wanting hierarchical naming use.,-, or_(e.g.team.security.critical-shell). PR #1082 review fix1.chain.toolMUST be non-empty.branch.approversif present MUST be a non-empty array of non-empty strings (catches typos that would otherwise be silent metadata loss).branch.requireif present MUST be exactlyallorany.branch.whenpredicate 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 |
INV-MM-2 |
Evaluator fail-closed |
|
INV-MM-3 |
Runtime is sole authority |
Runtime has no awareness of |
INV-MM-4 |
Compile is deterministic |
Chain + branch document order preserved through |
INV-MM-5 |
Provenance on both manifest layers |
|
INV-MM-6 |
Explainability survives end-to-end |
|
INV-MM-7 |
IR is the only frontend↔backend contract |
|
INV-MM-8 |
Preflight surfaces are frontend-orthogonal |
Graph chains don’t reference |
Sequencing (G2–G6 follow this design)¶
G2 —
policy/graphdsl/package:Parse(data, filename) (Graph, error)+Graph.Validate(). No IR mapping; just the typed graph + structural validation. Mirrorspolicy/jsonrules/parser.goshape.G3 —
Graph.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).G4 —
autonomy policy compile-graphsubcommand +--policy-frontend graphenum value onpolicy build. Mirrorscompile-yaml/compile-jsonshape.G5 — INV-MM-7 CI sub-check extended in
ci/test_arch_invariant.shto assertpolicy/graphdsl/imports onlypolicy/ir.G6 — End-to-end tutorial in
docs/tutorials/policy-graph-authoring.mdwith the worked approval-chain example above, plus integration test pinning the tutorial commands against actual CLI output.