Authoring policies in YAML

This tutorial walks through the multi-modal policy authoring path landed by epic #747. You’ll author a policy in YAML, compile it to the Rego the runtime evaluator loads, build a deployable bundle, and inspect which rule fired through the telemetry WAL.

What you’ll learn

  • The Phase 1 YAML DSL schema — six predicate operators (eq, ne, in, and, or, not) over a closed Rule shape.

  • How a YAML rule list compiles to Rego that the existing policy/evaluator.go loads unchanged.

  • How Decision.MatchedRule surfaces the matched rule’s ID + source file:line through the WAL, and how wal inspect lets operators see it.

Prereqs

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

  • A working directory you can write files into.

Step 1 — Author the YAML

Save the following as policy.yaml:

version: "1.0.0"
default: deny
rules:
  - id: REQ-PASS-001
    effect: allow
    tool: tool.echo
    when:
      op: eq
      field: preflight.outcome
      value: pass

This is the canonical preflight-gate shape from INV-MM-8: allow tool.echo only when the action carries a ContractRefine preflight binding whose outcome is pass. The rule’s id is operator-supplied; it becomes the explainability anchor downstream.

Schema at a glance

Field

Type

Required

Notes

version

string

yes

Must be "1.0.0" (Phase 1).

default

string

yes

allow or deny. Fall-through outcome.

rules[].id

string

yes

Must be unique across the file.

rules[].effect

string

yes

allow or deny.

rules[].tool

string

yes

The tool kind this rule targets.

rules[].when

predicate

no

Optional. Absent ⇒ unconditional match for tool.

The when predicate is a closed tree over eq / ne / in / and / or / not. See policy-ir-taxonomy for the full taxonomy.

Step 2 — Compile to Rego

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

Print the result:

cat compiled/policy.rego

You’ll see the canonical autonomy package the runtime loads:

package autonomy

import rego.v1

default allow := false

allow := true if {
    # Rule REQ-PASS-001 (allow)
    input.kind == "tool.echo"
    input.preflight.outcome == "pass"
}

matched_rule_id := "REQ-PASS-001" if {
    input.kind == "tool.echo"
    input.preflight.outcome == "pass"
}

rules_meta := {
    "REQ-PASS-001": {"file": "policy.yaml", "line": 4, "column": 5}
}

Two things worth noting:

  • The first match wins. The compiler emits a Rego else chain so rules are evaluated in document order — a leading deny rule shadows a later allow rule that the same input also matches.

  • The matched_rule_id chain and rules_meta map are the C05 explainability metadata. The runtime evaluator queries them alongside allow and populates Decision.MatchedRule with the rule ID and source position.

Step 3 — Build a bundle with frontend provenance

autonomy policy build \
  --in compiled \
  --out bundle.tar.gz \
  --version 1.0.0 \
  --name yaml-authored-demo \
  --policy-frontend yaml-dsl

--policy-frontend yaml-dsl stamps the inner manifest.json with "policy_frontend": "yaml-dsl" so downstream provenance audits can distinguish hand-authored Rego from YAML-compiled Rego. Omit the flag (or pass --policy-frontend rego) for hand-authored bundles. The build output names the value it wrote:

policy build: OK
  version:         1.0.0
  hash:            sha256:...
  runtime-version:
  policy_frontend: yaml-dsl
  output:          bundle.tar.gz

The matching outer-manifest field lives at schema_version: "1.6" — see bundle/manifest.go SchemaVersion16. The cross-check helper bundle.CrossCheckPolicyFrontend(outer, inner) catches tamper or build-pipeline drift between the two layers.

Step 4 — Evaluate an action against the bundle

autonomy policy eval runs an action through the bundle’s evaluator in-process — no daemon, no network. It’s the operator-friendly way to see the policy decision (and its explainability metadata) without spinning up the runtime server.

Evaluate an action that should be denied — tool.echo with no preflight binding fails the input.preflight.outcome == "pass" gate:

autonomy policy eval --bundle bundle.tar.gz --kind tool.echo
{"outcome":"deny","reason":"policy: deny","kind":"tool.echo","params":{}}

Exit code 1 (deny). No explainability fields — the default outcome fired because no rule matched. A deny with a populated matched_rule_id came from an explicit deny rule; this deny came from the fall-through.

Now construct an action that DOES match the rule. The rule’s when reads input.preflight.outcome — we’d need a real preflight binding to satisfy it through policy eval. For the purposes of this tutorial, swap the rule body to a params check that’s reachable via --params:

# policy.yaml — adjusted to use a params field rather than preflight
version: "1.0.0"
default: deny
rules:
  - id: REQ-PASS-001
    effect: allow
    tool: tool.echo
    when:
      op: eq
      field: params.tier
      value: pro

Recompile, rebuild, and re-evaluate:

autonomy policy compile-yaml policy.yaml --out compiled/policy.rego
autonomy policy build --in compiled --out bundle.tar.gz --version 1.0.0 \
  --policy-frontend yaml-dsl
autonomy policy eval --bundle bundle.tar.gz --kind tool.echo \
  --params '{"tier": "pro"}'
{
  "outcome": "allow",
  "reason": "policy: allow",
  "kind": "tool.echo",
  "params": {"tier": "pro"},
  "matched_rule_id": "REQ-PASS-001",
  "matched_rule_file": "policy.yaml",
  "matched_rule_line": 4,
  "matched_rule_column": 5,
  "evaluation_trace": [
    "rule \"REQ-PASS-001\" matched on input.kind=\"tool.echo\" (source: policy.yaml:4)"
  ]
}

The C05 MatchedRule (id + file + line) and C06 evaluation_trace flow through policy eval’s JSON output verbatim. Operators get “which rule fired and where it lives in source” without leaving the CLI.

Step 5 — Trigger an explicit deny and read the remediation hint

A deny that comes from an explicit rule (not the default fall-through) carries a remediation_hint. Add a deny rule and exercise it:

version: "1.0.0"
default: allow
rules:
  - id: BLOCK-SHELL
    effect: deny
    tool: tool.shell
    when:
      op: eq
      field: kind
      value: tool.shell
autonomy policy compile-yaml policy.yaml --out compiled/policy.rego
autonomy policy build --in compiled --out bundle.tar.gz --version 1.0.0 \
  --policy-frontend yaml-dsl
autonomy policy eval --bundle bundle.tar.gz --kind tool.shell
{
  "outcome": "deny",
  "reason": "policy: deny",
  "kind": "tool.shell",
  "params": {},
  "matched_rule_id": "BLOCK-SHELL",
  "matched_rule_file": "policy.yaml",
  "matched_rule_line": 4,
  "evaluation_trace": [
    "rule \"BLOCK-SHELL\" matched on input.kind=\"tool.shell\" (source: policy.yaml:4)"
  ],
  "remediation_hint": "denied by rule \"BLOCK-SHELL\" at policy.yaml:4"
}

The hint names the rule + file:line so the operator can jump straight to the offending rule. Compare this to the Step 4 default-fallthrough deny which had no hint at all — that distinction is the audit signal.

What’s next

  • Production runtime path: when the bundle is loaded into the runtime server (autonomy policy load → in-process activation), the same explainability fields surface on every WAL decision entry the running runtime emits. Inspect them with:

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

    Each JSON line carries the same matched_rule_id / matched_rule_file / matched_rule_line / evaluation_trace / remediation_hint attrs the policy eval output above shows.

  • The taxonomy reference: policy-ir-taxonomy.

  • The invariants the Phase 1 substrate holds: INV-MM-1 through INV-MM-8.

  • The matching --with-explainability flag on autonomy audit query is a follow-up; today operators get explainability via policy eval’s JSON output (this tutorial) or the WAL entries the runtime emits.

Troubleshooting

  • policy compile-yaml: duplicate rule id “X”: Rule IDs must be unique across the file. The error names both indices so you can locate the typo.

  • policy eval output has no matched_rule_* keys: either the bundle is hand-authored Rego (no rules_meta block) or the evaluation hit the default outcome (no rule matched). Confirm --policy-frontend yaml-dsl was passed to policy build and the --kind + --params actually satisfy a rule’s when.

  • Deny outcome with empty remediation_hint: the deny came from the default outcome — there’s no rule to point at. Add an explicit deny rule if you want the hint populated.