Multi-Modal Policy — IR Taxonomy

Note

Status — Phase 1 design proposal (epic #747) This page documents the planned Phase 1 IR shape. The policy/ir/ package, ir.schema.json, and the property-test suite referenced below do not exist in the repository yet — child PR C02 lands them; C03 lands the YAML DSL parser + IR → Rego compiler. Treat every package path, file name, and test name on this page as a target to land, not a present-day fact.

This page exists in C01 so subsequent PRs have a single design contract to implement against and a single doc to update if the contract changes during implementation.

This page catalogs the planned Phase 1 shape of the policy intermediate representation (IR). The IR is the contract between authoring modality and runtime evaluation — every non-Rego frontend will produce it; the Rego backend will read it. See the parent Architecture for the design discipline that bounds catalog growth.

What the IR represents

A compiled IR program is a closed list of Rules plus a top-level default outcome. Each Rule has an effect (allow or deny in Phase 1; Phase 2 adds audit_only / require_approval / rate_limited / sandboxed), a target tool kind, and a when predicate. The runtime evaluator iterates the rules in document order; the first match wins; an empty match-set falls through to the default outcome.

Match precedence is deliberately first-match, not most-specific. The catalog page on the IR test suite pins it.

The Phase 1 IR shape

C02 will define the IR in a new policy/ir/ package as a closed set of Go types. The JSON schema at policy/ir/ir.schema.json will be the canonical wire form; the Go types will be the consumer surface. Neither exists today — the shape below is the contract C02’s PR will implement against.

Program
├── default: Outcome        — "allow" | "deny" (Phase 1)
├── version: string         — IR schema version, e.g. "1.0.0"
└── rules: []Rule

Rule
├── id: string              — operator-supplied; surfaces in matched_rule.id
├── effect: Outcome
├── tool: string            — required: the tool.kind this rule targets
├── when: Predicate         — optional; absent ⇒ unconditional match
└── source: SourceRef       — {file, line, column} for the frontend's view

Predicate
├── op: PredicateOp         — "eq" | "ne" | "in" | "and" | "or" | "not"
├── field: string           — for leaf ops; references a known input field
├── value: any              — leaf comparand
└── children: []Predicate   — for compound ops (and/or/not)

Phase 1 predicate operators

op

Arity

Meaning

eq

leaf

input.<field> == value

ne

leaf

input.<field> != value

in

leaf

input.<field> is in the array literal value

and

compound

every child matches

or

compound

some child matches

not

compound

single child does not match

field is a dotted path into the Rego input document — the same shape the existing Rego evaluator already exposes today. The IR does not introduce a separate input schema; it READS the input the runtime adapter populates. Concretely:

  • field: "kind" reads input.kind — the tool kind.

  • field: "params.altitude_m" reads input.params.altitude_m — a caller-supplied param.

  • field: "preflight.outcome" reads input.preflight.outcome — the ContractRefine (C07 #1027) resolved preflight verdict, present when a binding has been resolved. When the binding is absent, input.preflight is undefined and the leaf predicate evaluates to false (the rule body fails — same shape as input.preflight.outcome == "pass" failing in Rego). This is the C07 “absent ≠ true” contract pinned by TestEval_PreflightAbsent_RuleRequiringItDenies. An operator who instead wants to allow when the binding is absent (the inverse case) writes not input.preflight in Rego directly; Phase 1 of the IR does not provide a sugar for the not-present case (a Phase 2 predicate op would land it).

The path traversal MUST match Rego’s input.<dot>.<dot> access semantics so a YAML/JSON-authored rule expressing input.preflight.outcome == "pass" compiles to a Rego predicate identical (modulo formatting) to the hand-written equivalent. The C03 property test that pins IR → Rego equivalence covers preflight-bound paths in its fixture set.

The set is deliberately small. Phase 1 ships predicates that map 1:1 to Rego primitives the existing evaluator already understands — no string prefix matching, no regex, no numeric comparisons. Phase 2 widens the set when the context-aware inputs require it (e.g. classification ordering needs lt / lte).

What the IR is NOT

  • No imperative control flow. No loops, no recursion, no conditionals beyond the predicate tree.

  • No mutable state. The IR is a pure expression tree.

  • No external calls. Predicates read only from the input. Producers (HTTP fetches, file reads, OPA built-ins beyond comparison) are explicitly forbidden — the runtime already wraps Rego with the same contract.

  • No frontend leakage. A Rule’s source carries provenance for explainability but the IR does not encode the originating syntax. A YAML rule and a JSON rule that produce identical IR compile to identical Rego.

How frontends compile into the IR

Each Phase 1 frontend produces IR via a closed compile path. The compilers are pure functions: same input ⇒ same IR, no clock, no randomness.

YAML DSL → IR (Phase 1)

A YAML rule list:

default: deny
version: "1.0.0"
rules:
  - id: NET-001
    effect: deny
    tool: shell.exec
    when:
      op: eq
      field: network
      value: public
  - id: ROB-007
    effect: allow
    tool: robot.move

Compiles into the IR Program directly. The compiler:

  • Validates the version field against policy/ir.SupportedVersions.

  • Resolves every tool to a non-empty string.

  • Validates the predicate tree shape (op + arity).

  • Stamps source.file + source.line on every Rule from the YAML document position.

Canonical preflight gate (ContractRefine integration)

The ContractRefine epic #1020 ships policy.BundleManifest.RequiresPreflight (manifest-layer binding gate) plus input.preflight (Rego rules read the verified PreflightDecision). YAML-authored bundles MUST be able to express the same input.preflight.outcome == "pass" gating Rego authors write today. The canonical YAML form:

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

The IR compiler lowers this to a Rego predicate equivalent to:

allow if {
    input.kind == "tool.echo"
    input.preflight.outcome == "pass"
}

The same bundle’s policy.BundleManifest can carry requires_preflight: true and the runtime gates /v1/tool identically to a Rego-authored bundle — see INV-MM-8.

JSON Rules → IR (Phase 1)

The JSON shape is the IR schema verbatim (machine-generated bundles emit the IR JSON directly). The compiler validates against policy/ir/ir.schema.json and stamps source.file from the input filename; source.line is left zero for machine-generated bundles.

Natural language → IR (Phase 3)

Out of Phase 1 scope. The Phase 3 path produces a draft IR program that a Rego-fluent owner reviews and approves before it reaches policy build.

Graph / workflow → IR (Phase 3)

Out of Phase 1 scope. The graph shape compiles approval-chain semantics into require_approval rules (a Phase 2 outcome), so the Graph frontend naturally sequences after the Phase 2 outcome work.

How the IR compiles to Rego (Phase 1 backend)

The IR → Rego compiler is the Phase 1 backend. It emits a single autonomy.rego file whose data.autonomy.allow predicate evaluates to the same boolean a hand-written equivalent would. The generated Rego shape is:

package autonomy

import rego.v1

default allow := false   # mirrors Program.default

allow if {
    # one block per allow-effect Rule, in IR order; first match wins
    ...
}

deny-effect rules become explicit not allow if { ... } guards so the runtime’s existing fail-closed contract (any non-true → Deny) catches them via the unchanged policy/evaluator.go path.

Determinism

The compiler MUST walk the IR program in document order. Generated Rego identifier names MUST derive from Rule.id (or a deterministic hash of the rule’s position when id is empty). The output MUST be byte-identical across runs for the same input — C03 will land a property test (TestIRToRego_Deterministic in policy/ir/compiler_test.go) pinning this.

Explainability annotations

C03 will emit a structured comment block above each rule body encoding {rule_id, source_file, source_line}. C05 will widen runtime.Decision with MatchedRule; the evaluator (via the OPA Rego v1 metadata annotation surface) will populate it from these comments. See Architecture § Explainability widening.

Adding to the IR (v2 path)

New IR operators or rule fields land via the following discipline:

  • One operator (or one field) per PR. The op, its semantics, its precedence, and its Rego lowering are reviewed together.

  • A property test pins the lowering: random IR program containing the new op compiles to Rego that evaluates identically to a hand-written predicate.

  • A taxonomy table row on this page.

  • An INV-MM entry on Invariants iff the addition asserts a new safety or correctness property (the predicate-set additions in Phase 2 do, since they widen the field shape input contract).

Catalog growth is bounded by deliberate review pressure — operators ask “what new authoring surface does this enable?” before “is the code clean?”. An operator that already has a Rego analog is an implementation detail of the IR, not a new entry.