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 deniedtool.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.