ADR-0013: assert authored syntax and backend: CEL-leaf condition trees on cel-go¶
| Status | Accepted (hybrid CEL-leaf trees on cel-go — Spike A; P2-E5) |
| Date | 2026-07-21 |
| Deciders | Konrad Heimel |
| Context links | ADR-0002 policy surface · ADR-0010 config files · ADR-0011 ports · ADR-0012 presentation · OQ-11 · OQ-12 · D-006 · appendix: syntax gallery |
Context¶
ADR-0002 settled the envelope (one YAML frontend; assert tier 1, rego tier 2) and left two
coupled questions: the authored syntax of assert (OQ-12) and its backend (OQ-11).
Method: a paper spike — six rule archetypes written concretely in every candidate syntax
(full gallery in the appendix), plus dependency
fact-checking (2026-07-21, GitHub API):
- kyverno-json: latest release v0.0.3; last push 2025-01-07 (~18 months dormant); 93 stars; 7 non-bot contributors, one of whom holds ~90% of commits; go.mod pulls k8s.io/apimachinery, k8s.io/client-go, gin — heavy for a single static binary (ADR-0001).
- cel-go (moved to the
cel-exprorg): v0.29.2 released 2026-07-08; pushed 2026-07-20; 3 036 stars; adopted by Kubernetes (ValidatingAdmissionPolicy, CRD validation rules) and by Kyverno itself for its next-generation ValidatingPolicy types.
The syntax finding that decides everything: assertion trees assert the shape of one
document; assent's tier-1 predicate compares two states (old/new) plus facts. In every
archetype the tree collapses into JMESPath expressions inside parenthesized YAML keys — the
structure adds nothing, the JMESPath dialect (backtick JSON literals, weak errors) costs a lot.
Options¶
| Option | Pros | Cons |
|---|---|---|
| Kyverno-json assertion trees, embedded kyverno-json | genuine Kyverno syntax reuse; engine for free | tree vestigial for diff payloads; JMESPath literals/errors; dormant pre-1.0 dep, bus factor ≈1; heavy transitive deps; authored syntax not portable off the engine |
| Bare CEL strings, cel-go (current draft samples) | reads like a spreadsheet formula; healthy dep; terminating & cost-budgeted | a && b fails as bare false — no per-conjunct attribution (ADR-0012); long predicates become string blobs |
Condition tree (all/any/not) with CEL leaves + per-leaf message; plain string = single-leaf shorthand; cel-go |
CEL readability and per-leaf failure attribution/messages; mirrors K8s ValidatingAdmissionPolicy validations (the modern "Kyverno-style"); draft samples stay valid as shorthand |
small bespoke combinator walker to maintain; leaves are still code-in-strings |
| Do nothing (defer to implementation) | none | syntax is a frozen public contract (Phase 3); deferral means accidental design |
Decision¶
We choose the hybrid — assert as an all/any/not condition tree with CEL-expression
leaves (plain-string shorthand for one expression), implemented on cel-go behind the
PredicateBackend wrapper — because it combines CEL's infix readability with per-leaf failure
attribution and rides the healthiest dependency, accepting a small combinator walker of our
own and that leaves remain expressions, over kyverno-json trees (structurally wrong for
old/new payloads; dormant upstream) and bare CEL strings (cannot explain which conjunct
failed).
Shorthand and full form are the same document:
# shorthand — exactly the existing draft samples (ADR-0010, examples/)
assert: "new >= old && new <= facts.quota.max_partitions"
# full form — one message per leaf; the failing leaf names itself in the finding
assert:
all:
- cel: new >= old
message: "partitions may not decrease ({{ old }} -> {{ new }})"
- cel: new <= facts.quota.max_partitions
message: "partitions {{ new }} exceeds quota {{ facts.quota.max_partitions }}"
The ceiling case (cross-field old/new consistency) stays inside tier 1 — see the appendix for all six archetypes and where each candidate syntax breaks.
Trade-off matrix (weights sum to 100; scores 1–5)¶
Readability and error quality carry 45% because the target persona and ADR-0012 are the product thesis; dependency health 15% because the facts above are stark; reversibility only 5% because the wrapper (ADR-0011) already bounds the blast radius. Readability scores are persona judgment (subjective); dependency scores are measured.
| Criterion | Wt | Trees/kyverno-json | CEL string/cel-go | Hybrid/cel-go |
|---|---|---|---|---|
| Readability (non-programmer platform engineer) | 25 | 2.5 | 4.0 | 4.5 |
| Error messages + testability (ADR-0012) | 20 | 2.0 | 3.0 | 5.0 |
| Expressiveness ceiling before Rego | 15 | 2.0 | 4.0 | 4.0 |
| Dependency health / bus factor | 15 | 1.0 | 5.0 | 5.0 |
| Implementability + maintenance in Go | 10 | 3.0 | 4.0 | 4.0 |
| Determinism guarantees | 10 | 4.0 | 5.0 | 5.0 |
| Reversibility behind wrapper | 5 | 2.0 | 4.0 | 4.0 |
| Weighted total | 2.3 | 4.05 | 4.6 |
Consequences¶
- Predicate scope becomes contract:
old,new,path,kind,file(matched change),entry/oldEntry(containing entry head/base state — K8sobject/oldObjectprecedent),changes,facts,mr,env. The existingentry.ownerownership sample stays valid. Freezes with fixtures in Phase 3. - All existing draft samples remain valid as shorthand — no rework of ADR-0010 or examples.
- Determinism for free: CEL is non-Turing-complete, side-effect-free, and cost-budgeted; the
purity invariant of
Predicate.Eval(ADR-0011) needs no sandboxing effort. - kyverno-json is dropped from Spike A; Chainsaw-style fixture UX for the test harness (ADR-0002) is unaffected — that borrowing never depended on the engine.
- Spike A narrows to residual code risk: (1) numeric type coercion YAML/HCL→CEL
(
CrossTypeNumericComparisonsvs adapter-side normalization) — highest risk; (2) error UX for missing facts/unknown fields; (3) cost limit + purity of the standard env; (4) per-leaf trace wiring intoFinding/Traceforexplain; (5) one activation model serving CEL and message templates. - Foreclosed: syntax-level compatibility with kyverno-json policies. Accepted — that ecosystem is K8s-admission-shaped, not diff-shaped.
- Reversibility: swapping cel-go for another CEL implementation is wrapper-internal; CEL is a spec with multiple implementations. Swapping away from CEL would break authored policies — that part of the decision is effectively one-way once packs exist.
Counterpoints considered¶
- "This isn't real Kyverno syntax, so D-006 is betrayed." — Strongest objection. Answer: Kyverno's own current direction (ValidatingPolicy) and K8s ValidatingAdmissionPolicy both use exactly this shape — CEL expressions with per-expression messages in a YAML envelope. CEL-in-YAML is contemporary Kyverno style; kyverno-json's JMESPath trees are the legacy branch, and a dormant one (facts above).
- "CEL is still code; the persona wanted no code." — True at the leaves. But the gallery shows the tree alternative doesn't remove code, it hides the same expressions in parenthesized YAML keys with backtick literals — strictly harder to read. Genuinely code-free authoring would need a form builder, which is a tooling layer, not a syntax.