ADR-0013 appendix — assert syntax gallery (paper spike, OQ-11/OQ-12)¶
Six rule archetypes written concretely in the three candidate syntaxes, inside the ADR-0010 envelope. Verdicts feed the matrix in ADR-0013. All snippets are design fiction against the draft PolicyInput (ADR-0011).
Predicate scope (variables visible to assert)¶
| Variable | Meaning | Scope |
|---|---|---|
old, new, path, kind, file |
promoted fields of the matched Change |
change |
entry |
head-state value tree of the containing entry | change |
oldEntry |
base-state value tree of the containing entry (absent for adds) | change |
changes |
all changes in this class slice | change |
facts |
provider results (ADR-0004) | both |
mr |
author, branches, labels | both |
env |
environment label from the classifier (ADR-0008) | both |
Naming precedent: K8s ValidatingAdmissionPolicy's object / oldObject.
Candidate syntaxes:
- T — kyverno-json assertion trees (
assert.all[].check, JMESPath in parenthesized keys, backtick JSON literals). Syntax verified against kyverno-jsonmaindocs 2026-07-21. - C — bare CEL expression string (the current draft-sample style).
- H — hybrid:
all/any/notcombinators, CEL leaves, per-leafmessage; a plain string is shorthand for one leaf.
1. Bounded numeric change with a fact (quota)¶
# T — tree
- name: partition-increase-within-quota
match: { changes: [{ path: "**/partitions", kind: modify }] }
assert:
all:
- message: "partition change out of bounds"
check:
(new >= old): true
(new <= facts.quota.max_partitions): true
effect: vouch
# C — CEL string
assert: "new >= old && new <= facts.quota.max_partitions"
# H — hybrid full form
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 }}"
Verdict: T's tree adds no structure — the payload is flat, so both checks are already
expressions-in-keys. C is shortest but fails as an undifferentiated false. H names the
failing bound. H wins on ADR-0012 grounds.
2. Ownership (set membership)¶
# T
assert:
all:
- check:
(contains(facts.author.groups, entry.owner)): true
# C and H shorthand — identical
assert: "entry.owner in facts.author.groups"
Verdict: CEL's infix in reads like English; JMESPath needs the contains() function
call. C/H tie; T readable but noisier.
3. Allow-listed-fields-only-changed¶
Per-change form (idiomatic — vouch coverage is per change, ADR-0007):
# T
- name: only-safe-fields
match: { classes: [kafka-topic] }
assert:
all:
- check:
(contains(`["/partitions", "/retentionMs", "/config/cleanup.policy"]`, path)): true
# C / H shorthand
assert: 'path in ["/partitions", "/retentionMs", "/config/cleanup.policy"]'
Whole-slice variant (when one rule must judge the set):
# C / H shorthand — CEL macro
assert: 'changes.all(c, c.path in ["/partitions", "/retentionMs"])'
Verdict: first real T casualty — the JMESPath list literal needs a backtick-quoted JSON
array inside a parenthesized YAML key: three nested quoting regimes. CEL's native list
literal wins outright. Macros (all(c, …)) are the most programmer-y corner of CEL;
per-change evaluation keeps most users away from them.
4. Deletion detection¶
Owned by the envelope in all three candidates — no assert needed (ties):
- name: no-topic-deletion
match: { changes: [{ path: "topics/**", kind: delete }] }
effect: block
message: "Topic deletion is never auto-mergeable."
Conditional deletion (allow only unused dev topics) needs a predicate:
# C / H shorthand
assert: 'env == "dev" && facts.usage.consumer_groups == 0'
# H full form gives each condition its own message (same shape as §1)
Verdict: tie on the base case — evidence that match already owns the structural part,
which is precisely why an additional structural tree layer in assert is redundant.
5. Environment-conditional strictness¶
First choice is no predicate at all: route env strictness via bindings.yaml
(ADR-0008/0010) — separate packs/thresholds per environment. Inside a single rule:
# T — JMESPath has no conditional operator; emulate with boolean algebra
assert:
all:
- check:
(env == 'prod' && new <= `12` || env != 'prod' && new <= `48`): true
# C — CEL ternary
assert: 'env == "prod" ? new <= 12 : new <= 48'
# H — combinators read as a rate table
assert:
any:
- all: [ { cel: env == "prod" }, { cel: new <= 12 } ]
- all: [ { cel: env != "prod" }, { cel: new <= 48 } ]
Verdict: T degrades into and/or algebra with backtick numerals. C's ternary is compact. H is the most verbose but self-documents which regime failed. C/H tie; guidance stays "prefer bindings".
6. Hard case — cross-field consistency, old vs new entry¶
"If cleanup.policy flips to compact, retentionMs must not change in the same MR."
# T — the tree matches ONE payload; comparing two states forces the whole predicate
# into a single root-level JMESPath expression. The tree is now pure ceremony.
assert:
all:
- message: "policy flip and retention change must not be combined"
check:
(entry.config."cleanup.policy" == oldEntry.config."cleanup.policy" || entry.retentionMs == oldEntry.retentionMs): true
# C — one implication, written as ||
assert: >-
entry.config["cleanup.policy"] == oldEntry.config["cleanup.policy"]
|| entry.retentionMs == oldEntry.retentionMs
# H — the any-decomposition reads as "one of these must hold", each explainable
assert:
any:
- cel: entry.config["cleanup.policy"] == oldEntry.config["cleanup.policy"]
- cel: entry.retentionMs == oldEntry.retentionMs
message: >-
cleanup.policy flip combined with a retention change
({{ oldEntry.retentionMs }} -> {{ entry.retentionMs }}) — split the MR.
Verdict: this is each syntax's ceiling probe. T fails structurally (single-payload
projection model); C works but buries the logic in one string; H stays legible and
attributable. Anything harder (cross-entry uniqueness, whole-branch conventions) correctly
graduates to rego per ADR-0002.
Ceiling summary¶
| Archetype | Trees (T) | CEL string (C) | Hybrid (H) |
|---|---|---|---|
| Bounded change + fact | works, tree vestigial | works | works, best errors |
| Ownership membership | works, noisier | best | best |
| Allow-listed fields | quoting collapse | works | works |
| Deletion (envelope) | tie | tie | tie |
| Env-conditional | boolean-algebra hack | ternary | verbose but explicit |
| Cross-field old/new | structural failure | works, opaque blob | works, attributable |
T fails soonest (archetype 3 quoting, archetype 6 structurally). C never fails outright but loses explainability as expressions grow. H tracks C's ceiling while keeping per-leaf attribution — users are forced into Rego at the same point in both C and H: cross-entry and whole-branch logic, exactly where ADR-0002 wants the tier boundary.
Backend facts (checked 2026-07-21 via GitHub API)¶
| kyverno-json | cel-go | |
|---|---|---|
| Latest release | v0.0.3 | v0.29.2 (2026-07-08) |
| Last push | 2025-01-07 (~18 months) | 2026-07-20 |
| Stars | 93 | 3 036 (org: cel-expr) |
| Contributors | 7 non-bot; top holds ~90% of commits | broad; K8s ecosystem |
| Transitive deps | k8s.io/apimachinery, client-go, gin, JMESPath fork (+ cel-go v0.20.1 itself) | self-contained |
| Termination/cost model | JMESPath (pure; extension purity unverified) | non-Turing-complete, cost-budgeted |