The implementation landed in the preceding WIP commit had never been executed. This finishes it: the arithmetic is re-measured, the guard and its allowlist are attacked, and the header's one factual error is corrected. WHAT ENVSUBST ACTUALLY IS. The header credited `github.com/drone/envsubst`. The pinned flux 2.4.0 binary in the dev image embeds `github.com/fluxcd/pkg/[email protected]` — Flux's own fork of drone/envsubst ("This package is a fork of drone/envsubst", its README) — read straight out of the binary with `grep -ao`. Same behaviour, different module; the header now says so and says how to check, because a claim about a dependency that nobody can re-derive is how the last wrong assumption survived. RE-MEASURED, NOT ASSERTED. Every row of the DOLLAR-RUN ARITHMETIC table was re-run through `flux envsubst` (flux 2.4.0, DEFINED=VALUE) and every row holds: `$$`->`$`, `$$$`->`$$`, `$${D}`->`${D}`, `$$${D}`->`$VALUE` (odd run: the brace is LIVE), `$$$${D}`->`$${D}`, `DO $$ x $$;`->`DO $ x $;`, and `$$${ARR[0]}` dies with "missing closing brace" while `$${ARR[0]}` is fine. `--strict` output was byte-identical to non-strict, exit 0 both ways (`cmp`) — strict mode is blind to this whole class, which is why a source assertion is the only guard available. The header now carries the 30-second reproduction, flagged to be re-measured on any FLUX_VERSION bump. END TO END ON THE REAL PATH. With the pre-16b7fcd manifest restored, `kubectl kustomize apps/wave-0` emits `DO $$` — valid SQL, and exactly what the `kubectl apply` verification saw — while the same stream through `flux envsubst --strict` emits `DO $` and `$;` and exits 0. That two-command contrast IS the incident. THE GUARD IS NOT TRIVIALLY SATISFIABLE — 16 adversarial cases, all as intended, run against a throwaway copy of the tree so nothing could be left behind: * an unmarked `$$` under a substituted path FAILS (DOLLAR-COLLAPSE); so do an odd `$$${...}` (DOLLAR-HALF-ESCAPED) and an even `$$$${...}` (OVER-ESCAPED); * a stale entry FAILS three ways — context no longer in the file, context that only covers an already-accepted `$${` escape and so excuses nothing, and a path that is not a file in the repo; * the allowlist cannot blanket-exempt a path: a 2-char context is rejected (<24), a `.*` wildcard is matched VERBATIM and therefore rejected as stale, a 24-char context carrying no dollar run is rejected, a 4-char reason is rejected (<40) — and, the central case, a genuine entry covers only the span its context matches, so a SECOND `$$` elsewhere in the same file still fails. Two genuine entries for two real occurrences do pass, so the mechanism is usable rather than merely unsatisfiable; * the CLASS, not the instance: the pre-fix `DO $$ ... $$;` reconstructed in a NEW ConfigMap under apps/wave-2 fails at both dollar runs, and restoring the real pre-16b7fcd apps/wave-0/nextcloud-db.yaml verbatim fails the check at lines 384 and 391. The guard rejects the exact commit that broke production. The scratch fixtures lived only in the throwaway copy; `git status` on this branch stayed clean throughout, which is the other half of the last attempt's unfinished business. LEGITIMATE USES EXIST AND STAY GREEN. 18 `$${...}` escapes (unifi cloud-init, the gitea and nextcloud OIDC bootstrap scripts, two longhorn comments) pass unremarked, and the 2 non-braced `$$` occurrences are the two allowlist entries. Those two are shell comments inside block scalars, and `kubectl kustomize` confirms the comment text really does survive into the stream envsubst reads — so they are genuine, reaching content whose collapse is cosmetic, not a hypothetical the allowlist was invented to excuse. STILL NOT PROVEN, AND STILL NOT PROXIED: that a change was verified through a substituting path. Which command a human ran is a fact about a shell session; git stores the manifest, never the invocation, so it is not statically decidable here and no honest formulation exists. Grepping commit prose for `kubectl apply` would be satisfiable by rewording and would fire on the break-glass runbooks whose whole content is that command. The answer is to move the proof off the verification path and onto the source, which is what assertion (7) does. No ci/l0.sh stage: the `conformance.home.k3s/scope: "whole-repo"` annotation is what wires it, and whole-repo-invariants-wired fails the gate on a hand-written stage. Co-Authored-By: Claude Opus 5 <[email protected]>
conformance/ — the home-k3s conformance harness (WS-TEST)
This subtree is the definition of "done" for the whole platform. Nothing is accepted
on a demo, a screenshot, or "it reconciled": every requirement is proven by an
automated, reproducible, behavior-based test tagged with the requirement ID(s) it
verifies (spec docs/14,
ADR-0010,
ADR-0011).
Tooling is fixed by ADR-0011: Kyverno for L0 (the CI policy gate + in-cluster admission enforcer — owned by task T-0.5) and Chainsaw for L1 rendering / L2 behavioral / L3 fault-injection (this harness).
The single command
./run.sh # full suite (skips L1/L2/L3 if no cluster)
./run.sh --requirements R-NET-01,R-NET-06 # merge-gate subset (tagged)
./run.sh --layer l2 # one layer: l1 | l2 | l3 | meta | all
./run.sh --coverage-only # regenerate the coverage report (no cluster)
./run.sh --status # derived requirement-STATUS report (no cluster)
./run.sh --lint # structural-validate test defs (no cluster)
./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
./run.sh --require-all # phase-exit gate: every requirement must be covered
./run.sh --record # run AND capture the evidence (see "Proof of pass")
./run.sh --layer l0 --include-scope whole-repo # union: the tagged subset PLUS every test
# declaring `conformance.home.k3s/scope: whole-repo`
./run.sh --layer l0 --print-selection # print the dirs the selection resolves to; run nothing
Gating is scoped, not "all 51 always" (R-TEST-03). Three distinct checks:
-
--strict-coverage— hygiene (always-on, WS-TEST owns it): fails only on tag/annotation drift or a tag absent from the registry. Uncovered future-phase requirements are visible gaps in the report, not a harness failure. -
--requirements R-…— merge gate: selects the tagged tests to run and asserts those touched requirements are covered (a listed safety-critical one must have an L3 test). -
--require-all— phase-exit gate: every registry requirement must be covered. -
--include-scope whole-repo— the escape hatch scoping needs, because scoping has one blind spot and it is the worst-shaped kind. Some L0 invariants quantify over the WHOLE tracked tree (no plaintext secrets anywhere; no RBAC wildcard anywhere), or over subtrees the coarse path→requirement map keys to nothing (apps/,infra/, most ofplatform/). For those, the change most likely to break the invariant attributes no requirement at all, so requirement-scoped selection filters the check out of exactly that change. Such a test declares its scope:metadata: annotations: conformance.home.k3s/scope: "whole-repo"and
--include-scope whole-repounions every declaring test into the selection. The CI stages (ci/l0.sh,ci/l1.sh,ci/conformance.sh) all pass it, so adding the annotation is the only step — there is nothing to remember and nothing to hand-write. Two consequences, both deliberate: a declaring test always runs, and a SKIP of a declaring test is UNWIRED, not green (the runner exits 3, whichci/l0.shturns into the gate's own rc 3 — never a pass).tests/l0-static/whole-repo-invariants-wiredproves the mechanism end-to-end and falsifiably, so it cannot rot silently. This replaced a hand-written "UNCONDITIONAL" stage per check inci/l0.sh; seelib/common.shfor the full rationale and the incidents behind it. -
Documented out-of-scope gaps (e.g.
R-ENV-02dev→prod promote andR-GITOPS-01state-in-git, owned by WS-INFRA/WS-GITOPS and keyed wholesale by the coarse path map) are exempted from the completeness gates via--allow-gap, wired inrun.shasDOCUMENTED_GAPSso the exemption is explicit and reviewable. (R-STATE-02node-loss used to be here; it now has a real L3 test —tests/l3-fault-injection/node-loss— and is covered, not exempted.)
Coverage vs status — two different questions
coverage.md answers "does a requirement-tagged test EXIST for R-x?" (R-TEST-04).
status.md (./run.sh --status, generator lib/status.py) answers the operator's
question — "what is the real state of each requirement given what is actually in the
repo?" — deriving three orthogonal axes per requirement:
- implementation — is there a tracked artifact naming the requirement?
- reconciliation — is that artifact reachable by walking
resources:fromclusters/<env>/kustomization.yamland expanding every FluxKustomization'sspec.path? This is what separates authored from actually reconciled: a commented-outresources:entry (e.g.platform/image-automation) is correctly reported as in-git-but-not-on-any-cluster. - verification — is the tagged test EXECUTABLE, or a TODO scaffold? Coverage counts
both as "covered"; status does not. TODO markers in a body always outrank an
executable: "true"annotation, so ambiguity never upgrades a test.
It deliberately refuses to derive proof-of-pass. Nothing tracked in git records a test
result — _artifacts/ and results/ are git-ignored and DECISIONS-LOG.md prose is not a
signal — so every requirement's proof state is reported as not-derivable rather than
defaulting to green. A status report that overstates completion is worse than none
(agents/definition-of-done.md). Closing that gap needs a committed, machine-readable
run ledger; until then the strongest claim the report makes is wired and testable.
--strict-coverage also applies status's hygiene gate (registry↔spec drift,
annotation/body conflicts, a safety-critical requirement whose only L3 test is a scaffold).
Exit codes follow ci/lib.sh: 0 pass / 1 fail / 3 skipped — a skip is never green.
run.sh is the only entrypoint. Local runs and the in-cluster CI Chainsaw Job call
the same script with the same config, so results are identical.
Artifacts (always emitted, under _artifacts/, git-ignored)
| Path | What | Consumer |
|---|---|---|
_artifacts/report.junit.xml |
JUnit — Chainsaw results, or a skipped-scaffold shim when no cluster | CI gate |
_artifacts/coverage/coverage.md |
Human requirement-coverage report | PR reviewers / logs |
_artifacts/coverage/coverage.json |
Machine coverage: {requirement: [tests]}, gaps, safety gaps |
CI gate |
_artifacts/status/status.md |
Human requirement-STATUS report derived from repo state | operator / PM |
_artifacts/status/status.json |
Machine status: implementation / reconciliation / verification per requirement | tooling |
Reproducibility contract (R-TEST-02 — what the harness guarantees)
- Single command.
./run.sh(or a tagged/layer subset) runs everything and emits the artifacts above — locally and in CI. - Hermetic. Each Chainsaw test provisions its own namespace + fixtures/probes and tears
them down (
config/chainsaw-config.yamlauto-creates/auto-deletes the namespace; tests addfinallyfor anything out-of-namespace). It never relies on pre-existing state. - Deterministic. Tool versions pinned in
versions.env; eventual consistency handled by bounded polling with explicit timeouts (config), never sleeps or wall-clock/ordering assumptions. - Isolated & parallel-safe. Unique generated namespaces + per-requirement labels; tests run concurrently without cross-talk.
- Machine-readable & requirement-tagged. JUnit + coverage, every result carrying its
R-*tags (convention intests/README.md). - Self-cleaning. Fixtures removed on pass or fail; a leaked namespace/fixture is
itself a failure (
skipDelete: false, enforced delete/cleanup timeouts). - Versioned with the code. Tests live next to what they verify and change only when the human-owned spec they encode changes — never weakened to go green (R-TEST-06).
Layout
run.sh single-command runner (the entrypoint)
versions.env pinned toolchain (Chainsaw/kubectl/Kyverno) — determinism
config/chainsaw-config.yaml shared execution contract (timeouts, ns isolation, JUnit)
requirements/registry.yaml mirror of EVERY spec R-* ID (coverage enumerates from here)
lib/
common.sh tagging convention + test discovery/selection helpers
coverage.py requirement-coverage generator (stdlib only)
status.py requirement-STATUS generator (stdlib only; --selftest)
record_run.py run-ledger writer: PROOF-of-pass (stdlib only; --selftest)
emit_skipped_junit.py Phase-0 skipped-JUnit shim (no cluster)
runs/
ledger.jsonl TRACKED append-only proof-of-pass ledger (one row per
testcase per run) — the only source status.py trusts
<UTC>-<short-sha>/ TRACKED per-run evidence: run.log + report.junit.xml
tests/
l1-rendering/ l2-behavioral/ l3-fault-injection/ meta/ (see tests/README.md)
incluster/
chainsaw-job.yaml rbac.yaml in-cluster L2/L3 Job + scoped test SA (ADR-0011)
Proof of pass — the run ledger (runs/)
--status used to report PROVEN: 0 for all 51 requirements, and said so honestly:
proof-of-pass was not recoverable from repo state. _artifacts/ is gitignored and the
root .gitignore kills *.junit.xml globally, so no run result ever reached git; the
only record of a green run was DECISIONS-LOG.md prose. runs/ is the fix lib/status.py
asked for — a committed, machine-readable run ledger.
./run.sh --record # runs normally, and captures the evidence
# -> conformance/runs/<UTC>-<short-sha>/run.log (tee'd, carries the sentinels)
# -> conformance/runs/<UTC>-<short-sha>/report.junit.xml (that run's JUnit)
# -> conformance/runs/ledger.jsonl (one appended row per testcase)
python3 lib/record_run.py --run-dir runs/<id> [--dry-run] # or record an existing run dir
python3 lib/record_run.py --selftest # deterministic, no cluster
Recording is explicit, never implicit. Plain ./run.sh writes no ledger row: a run
that silently records itself is how a bad run becomes a permanent false proof. Commit
the run dir and the ledger — an uncommitted ledger proves nothing.
Why a committed JUnit alone would be WORSE than nothing
The L2/L3 tests are runnable and self-skipping: when a precondition is missing (no
longhorn StorageClass, no Velero CRDs, fewer than 3 Ready nodes) they echo a skip token
and exit 0, and their check: accepts either outcome —
(contains($stdout, 'DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK')
|| contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true
So a self-skip exits 0 and Chainsaw records passed exactly as a real run does, and
Chainsaw's JUnit carries no stdout — its <testcase> elements hold only <properties>.
A committed JUnit on its own would therefore record DR_RESTORE_SKIPPED_OK as proof of
R-STATE-03: fake green, and worse than no ledger because it would look authoritative.
The sentinel token in run.log is the only thing that separates proven from skipped.
record_run.py derives each test's tokens from its own chainsaw-test.yaml (every
contains($stdout, 'TOKEN'); *_SKIP*_OK is a skip token, the last non-skip token is the
verdict token), so the table below is never hand-maintained:
| Test | pass token (proof) | skip token (never proof) |
|---|---|---|
l3/node-loss |
NODE_LOSS_VOLUME_AVAILABLE_OK |
NODE_LOSS_SKIPPED_OK |
l3/dr-restore |
DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK (+ OFF_CLUSTER_TARGET_OK, BACKUPS_ARE_SCHEDULED_OK) |
DR_RESTORE_SKIPPED_OK, DR_SCHEDULE_SKIPPED_OK |
l3/data-survival |
DATA_SURVIVED_OK |
DATA_SURVIVAL_SKIPPED_OK |
l3/vm-data-survival |
VM_DATA_SURVIVED_OK |
VM_DATA_SURVIVAL_SKIPPED_OK |
l3/hands-off-update |
HANDS_OFF_UPDATE_OK |
HANDS_OFF_UPDATE_SKIPPED_OK |
l2/vm-lifecycle |
VM_LIFECYCLE_OK |
VM_LIFECYCLE_SKIPPED_OK |
l2/vm-default-deny |
VM_DEFAULT_DENY_OK |
VM_DEFAULT_DENY_SKIPPED_OK |
A row reaches proof: "proven" on one path: JUnit passed, every declared pass
token present in that test's log lines, and no skip token anywhere in them. A skip
token beats a pass token (worst wins). Anything else is skipped, failed,
junit-skipped, unknown-no-sentinel-declared or unknown-sentinel-absent — all
non-green. L0/L1 need no sentinel because lib/run_l1_offline.py already renders their
skips as JUnit <skipped/>, never as a green pass.
Staleness — a proof is a claim about a tree, not a badge
--status honours a proven row only while the run's git_sha is a descendant of the
last commit touching that requirement's implementing manifests or its verifying test.
Otherwise the row degrades to stale-proof, which is not green. A run captured from a
dirty working tree is recorded as <sha>-dirty, which no ancestry check can satisfy — so
it never counts. Re-run with --record to refresh.
A requirement reads proven only when every verifying test does; one skipped, failed
or unrecorded test drags the whole requirement down. With an empty ledger --status
behaves exactly as before: every requirement not-derivable, headline PROVEN 0.
How CI invokes it
CI (Gitea Actions, wired in T-0.2 / migrated in T-6.1b) calls this same run.sh:
- No-cluster stage (every PR, fast):
./run.sh --lintand./run.sh --coverage-only --strict-coverage --requirements <IDs the change touches>. Validates every test parses- is tagged, regenerates coverage, fails the PR on tag/annotation drift (hygiene) and on any touched requirement lacking a test (merge gate). (L0 Kyverno CLI runs alongside — task T-0.5.)
- Cluster stage (L2/L3): CI does not hold a kubeconfig. It triggers the
in-cluster Chainsaw Job (
incluster/), which runs./run.shunder the scopedconformance-runnerServiceAccount and writes JUnit + coverage to a results volume CI collects.- Merge gate: the Job runs
--requirements <IDs the change touches>; that subset runs green and those requirements are covered to merge (R-TEST-03). - Phase-exit gate: the Job runs
--layer all --strict-coverage; add--require-allonly at the milestone where the full requirement set is expected covered. The phase suite must be green before the phase's exit criteria are met.
- Merge gate: the Job runs
Phase 0 status (no cluster yet)
No cluster exists, so L1/L2/L3 cannot execute. The behavior tests are valid
scaffolds (steps + assertions authored, tagged, TODO(T-x.y) where a later phase
delivers the fixture). In this phase run.sh:
- always generates the coverage report (cluster-independent), and
- emits a skipped JUnit listing every scaffold (so the gate is green and the artifact shape is real), and
- the
meta/harness-selfcheckchecks (reproducible output, JUnit+coverage emitted, everything tagged, zero safety-critical gaps) are whatrun.sh --strict-coverageactually enforces today.
As later phases deliver capabilities, each scaffold's TODOs are filled and the test flips
from skipped to executing green — landing coverage without re-architecting the harness.