ADR-0002: Policy surface: one Kyverno-style YAML envelope, pluggable expression backends¶
| Status | Accepted (v2 — supersedes the "two parallel frontends" draft of this ADR; P2-E5). The pluggable-backend half is UNBUILT as of 2026-08-09: the YAML envelope and the CEL/assert backend are Core, but the Rego backend is deferred to E11 and nothing selects a backend today — no opa/rego module dependency, no backend field in any frozen v1alpha1 policy schema. README.md's maturity table (Rego backend — Locked, E11) and docs/architecture/c4-container.md (PLANNED — E11) are the accurate surfaces; this ADR is the design, not a statement of what ships. Do not cite Rego as an available escape hatch — see ADR-0013 Amendment 1 and D-012. |
| Date | 2026-07-21 (revised) |
| Deciders | Konrad Heimel |
| Context links | ADR-0003 change model · ADR-0007 effects · ADR-0008 routing · D-006 |
Context¶
The first draft proposed Rego and declarative YAML as two parallel, equivalent frontends. Review verdict: too much — two documentation surfaces, an equivalence test matrix, and permanent feature drift between them. At the same time, YAML-only hits an expressiveness ceiling and Rego-only scares off the primary audience (operator preference is Kyverno-style — D-006).
The unlock: routing, matching, effects, risk points, and scope (ADR-0007/0008) are structural concerns that belong in a declarative envelope no matter what — Rego should never own orchestration. Only the predicate inside a rule needs an expression language. That part can be pluggable without creating a second frontend.
Options¶
| Option | Pros | Cons |
|---|---|---|
| Two parallel frontends (v1 draft) | each audience fully served | double docs/tests, drift; rejected |
| Rego only | max power, OPA tooling | wall of Rego; loses the preferred UX |
| YAML only (assertion trees) | lowest barrier | ceiling: cross-entry logic, branch-state conventions get ugly — superseded 2026-09-03, see Amendment 1 |
One YAML envelope; rule bodies choose a backend: assert (assertion tree / CEL) or rego (module escape hatch) |
one document model, one doc set; 80% never see Rego; Rego available where it earns its keep; backends are tiers, not equivalents — no equivalence testing | envelope schema must be designed carefully; two expression languages to document (but scoped to rule bodies) |
| Call Kyverno proper as engine | reuse mature engine | Kyverno's engine is K8s-native (GVK match, admission semantics, CRD lifecycle) — we'd fake AdmissionReviews and lose old/new diff semantics; wrong fit |
Decision (proposed)¶
One policy document format — Kyverno-inspired YAML (MergePolicy + RulesetBinding
kinds). The envelope owns: match/classification hooks, environment routing, rule scope,
effects, risk points, messages. Each rule's predicate is one of:
assert— declarative assertion tree / CEL expression. Default tier; covers the archetypes. Implementation candidates (Spike A decides, OQ-11): kyverno-json embedded as a Go library (pkg/jsonengine) — genuine Kyverno assertion-tree semantics and syntax familiarity for free — vs. a native CEL (cel-go) evaluator (Kyverno itself moved to CEL for its new ValidatingPolicy types, so CEL is Kyverno-style now). Either way the engine is wrapped behind our own interface so the choice is reversible.rego— inline or file-referenced Rego module (embedded OPA), receiving the same PolicyInput scope and returning findings data only. Its reach over tier 1 was measured against the surface this repo actually binds (E11-S01 / D-156,docs/planning/rego-tier-ceiling.md) and is two shapes, both unconditional on today's shipped input contract: folds and aggregates over an in-input collection — tier 1 hassize()and nosum,reduce,math.*orlists.*, so counting is expressible and summing is not — and unbounded graph reachability over an adjacency deliverable as a{type: string, cardinality: set}fact, which tier 1 cannot spell at any depth because the number of nested iteration levels in a CEL expression is syntactic. (Deliverable, not shipped: no provider in the corpus returns an encoded adjacency today — the ceiling record's §5 caveat.) Not cross-entry, cross-manifest or whole-branch checks: those fail on input availability, and theregotier is pinned to the identicalEvaluationInput(REQ-E11-S05-01), so it fails them identically. See Amendment 1 for the claim this replaces.
Rego never controls routing, effects, or aggregation — it computes; the envelope decides. Downstream (engine, findings, harness, docs) a rule is a rule regardless of backend.
Consequences¶
- The "isn't this too much?" problem dissolves: there is exactly one frontend; backends are
tiers of one surface, documented as "start with
assert, graduate torego". - Syntax familiarity is deliberately stolen from Kyverno (match/exclude, validate, message
templating,
apiVersion/kindenvelope); semantics for git-diff payloads are ours. - Chainsaw is the wrong layer for the engine (it's a K8s e2e test orchestrator), but its declarative assert-file UX is the model for our policy test harness fixture format.
- Whether
assertis implemented on kyverno-json or cel-go is an implementation detail hidden behind the wrapper — but the authored syntax it implies is not; Spike A must fix the syntax before Phase 3 freezes contracts. - The wrapper (
PredicateBackend, ADR-0011) is what keeps adding theregotier a placement decision rather than a redesign: where the OPA evaluator lives and how its runtime capabilities (http.send, wall-clock, randomness) stay contained inside the guarded core tree is settled in ADR-0011's amendments (D-141/D-144). Rego still never touches routing, effects, or aggregation — only what it computes as a predicate.
Counterpoints considered¶
- "Just use conftest/Rego, it exists." — conftest proves Rego-over-config-files works, but offers no envelope: no effects, routing, risk, or resolvable-thread semantics. We'd rebuild the envelope anyway — the actual product — and inherit the steep default UX.
- "kyverno-json is pre-1.0 with a small maintainer pool." — True; that's why it sits behind our wrapper interface with cel-go as the recorded fallback (OQ-11).
Amendment 1 (2026-09-03, D-166 — the rego tier's justification is folds and unbounded graph reachability, not cross-entry checks)¶
Withdrawn. Until this amendment the rego bullet above read, verbatim:
Escape hatch for cross-entry checks, complex derivations, whole-branch conventions.
Why it was wrong, not merely imprecise. E11-S01 measured the tier-1 CEL ceiling against the
surface this repo actually binds — newEvalEnv's eleven frozen predicate-scope variables, zero
extension libraries — and D-156 (2026-08-23) struck three of the four shapes that sentence
gestures at:
- Cross-entry / cross-manifest checks are expressible at tier 1 where the data is present
(
x in facts.<p>.<n>.valuefor membership; a purpose-built provider orfacts.<p>.<n>.value[key]for keyed lookup — both shipped and lint-clean), and where the data is absent the blocker is input availability, not expressiveness. The evaluation unit is one file, and REQ-E11-S05-01 pins the Rego module to the identicalEvaluationInputwith the tier fenced to "declared data, no I/O" — so Rego fails those rules identically. A second backend does not fix an input-contract limitation. - "Complex derivations" — the named-intermediate shape — is struck:
cel.bindis absent, but re-deriving the sub-expression inside each leaf is semantically identical, and the surface's value binder[expr].all(v, …)computes it once. The residual is legibility, which is not a licence to add a backend. - "Whole-branch conventions" is the same input-availability limit under another name.
What survives, and it is narrower. Two shapes, both unconditional on today's shipped
input contract: folds/aggregates over an in-input collection, and unbounded graph reachability
over an in-input adjacency. The rego bullet above now states those and nothing else.
Blast radius of the wrong sentence. It was published on an Accepted ADR and reachable from
the ADR index; the correction was tracked as backlog residual E11-R01 precisely because
REQ-E11-S12-01's Test: list omitted docs/adr/0002-*, so no gate would ever have caught
it — it was invisible to CI by construction, and E11-S12 is story 12 of 14. That omission is
now closed: this file is named in REQ-E11-S12-01's Test: list, so it sits inside S12's
sweep rather than outside it. That buys a review pin and not a gate — nothing
machine-consumes a REQ's Test: list, and the only gate that reads ADRs at all compares Status
rows against docs/adr/README.md, never body content — so a regression re-inserting the withdrawn
sentence into this file would still fail no check today.
Left standing deliberately. The Options table's "YAML only (assertion trees) — ceiling: cross-entry logic, branch-state conventions get ugly" cell is a record of what was believed during the 2026-07-21 deliberation, not a claim this ADR makes today. It is superseded by this amendment and is not restated as current.