Generated from cat-harness/skills/sdlc/sdlc-core/gate-tree-mutation.md — do not edit here.

✎ Edit this page’s source

“NOT clean” is a verdict about the RUN, not about your diff

bun run cat gates snapshots the tree before the first gate and after every one, so a gate that repairs something on its way past is named. Every gate can pass and the run still exit 1:

✗ every gate passed, and the run is NOT clean — 1 gate(s) changed the tree.

That is not tidiness. The gates that ran after the mutation were handed the repaired tree, so their verdicts describe a state you have not committed — bean ymsu, where 152 gate(s) pass was printed 152 of 152 times over a value nobody had committed.

No gate can observe what another gate did, which is why the detector can only live in the runner (gate-tree-guard.ts) and why its predicate is a per-gate delta rather than “the tree is dirty”: running gates over your own uncommitted work is the normal case. A failure to read the tree is carried as undetermined and reported rather than thrown, because undetermined is not clean.

Two causes, and they are answered differently. Read git status --short before you decide which one you have.


Cause 1 — you changed an input and did not regenerate

The ordinary case, and platform-gates §”What a *:check failure is telling you” is the whole of it: run the generator, commit its output, and run the gates after the last edit. Never hand-edit the artefact into agreement.


Cause 2 — the artefact records the RUN, and your run differs

This is the one that looks like cause 1 and is not. The generator is behaving correctly, the input has not changed, and the file still gets rewritten — because a field in it describes the run that produced it rather than the subject it is about, and your run is not the last one.

The question that settles it:

Does this field describe the SUBJECT the artefact is about, or the RUN that produced it? Only the first is a reason to write.

A field that records the environment — a timestamp, the checked-out HEAD, the runtime version — will differ on every machine, so counting it as a reason to write makes the artefact churn everywhere and reports that churn as though an input had moved.

The worked example, and it is fixed

Bean 3ozg, 2026-09-27. The committed QA script sidecars under cat-harness-tools/pipeline/script-sidecars/ carry three such fields:

last_run_at     wall-clock time of this run
last_run_sha    the checked-out HEAD
engine_version  `bun-${Bun.version}` — the local runtime

saveQaScriptSidecar already skipped the write unless a substantive field moved, and it already excluded the two last_run_* fields. It counted engine_version, so a run under any other Bun rewrote every sidecar whose recorded engine disagreed — 72 of 86 from a container at 1.3.11 against CI’s 1.3.14. init-folio-qa.test.ts runs a real sweep (found by bisecting 432 test files), which is how bun test came to dirty the tree, and bun run cat gates therefore ended NOT clean on every branch, pristine main included.

Two changes landed, and the order matters for reading the history:

#1442pinned Bun in .bun-version, with check:bun-pin holding all 22 oven-sh/setup-bun sites to it. Makes CI consistent — and a container image is not something a repository can pin, so it could not reach an agent container.
#1452dropped engine_version from the comparison, joining the two last_run_* fields. Closes the residual: no engine difference rewrites anything.

The owner kept both. They answer different halves: the pin makes the recorded engine consistent where the repository controls it, the skip stops any other engine from rewriting a file whose checker did not change.

The argument that decided it, because the wrong one is tempting

#1442 defended keeping engine_version substantive with “a verdict produced by a different engine is a different verdict”. That is true and it is about a different artefact. A script sidecar holds no verdict — it records a checker’s source file, its hashes and its dependencies. Verdicts live in block sidecars. And no reader consults a script sidecar’s engine_version for freshness: entryIsFresh compares field_hash and the script hashes, nothing else. So the field is a record of the last real change’s engine, not an input to any decision.

The generalisable half is not “engine versions do not matter”. It is that a soundness argument has to be checked against the artefact in front of you: the same field can be load-bearing in one sidecar and pure provenance in another.


The rule is convergent, not novel — and 3ozg was the LAST family to get it

Swept 2026-09-27 across every committed artefact family carrying a run field. This repository already implements the rule in three independent places, each with the measurement that bought it — so a reader meeting this skill should not treat it as a proposal:

wherethe guardwhat it cost to learn
scripts/qa-results.ts writeQaResultcompares withoutTimestamp(prior), so only updated_at differing is not a write“run the check, get a modified file, commit nothing of substance” — 2026-09-19, across kg-export and avatar-coverage
pipeline/qa-sweep.tswroteSomething = verdictChanged \|\| metadataDrifted, and updated_at advances only thenrestamping “made an otherwise no-op sweep dirty the whole corpus”
qa-utils.ts saveQaScriptSidecarcontent-derived fields only; since #1452 engine_version joins the two last_run_*3ozg — the churn this skill is named for

writeQaResult is shared by every *.qa-results.json family, which is why library-qa’s 34 files are safe while carrying updated_at. Its docblock states the principle in the same terms: the timestamp answers “when were these findings established”, not “when did somebody last run the script”.

Two things follow. A churn you meet is more likely a family that MISSED an established rule than a new question — look for the nearest of those three before designing anything. And the rule has been arrived at independently three times, which is itself the argument for it being written down once.

What the sweep did not measure, stated rather than implied: the 125 block-qa sidecars were verified by READING qa-sweep.ts’s guard, not by running it. qa_sweep is an MCP tool with no npm script and the platform carries no folio, so there was no control to run. Six writers that could be run were, twice where cheap, and all were idempotent on a clean tree.

If you meet a churn whose writer has not been fixed yet

Discard it rather than committing it, and then fix the writer:

git checkout -- <the churning paths>

Committing such a churn stamps whatever your environment happens to be as though it were a fresh measurement — with an older local runtime, a downgrade. That is sfjo’s rule (read what a regeneration writes before committing it) applied to a regeneration that is faithful and still wrong to keep: the generator records your container correctly, and your container is not the repository.

But discarding is a workaround, and the habit is the defect. 3ozg was filed three times in one day — as 3ozg, as rmcf, and ymsu is a different defect over the same files — because every session that ran bun run cat gates on a clean tree saw the same always-red line, discarded the churn, and moved on. A signal that is always red is one nobody reads, and then it cannot report the next real in-run repair, which is the whole reason the detector exists.

So the sequence is: discard, ask the question at the top of cause 2, and fix the comparison. Do not add the paths to an ignore list — that removes the signal instead of the cause.


Do not read past the line

Cause 2 has a narrow signature. If NOT clean names paths outside the artefact family you expect, or fields other than the run-provenance ones, it is not this: the gate is telling you something new, and cause 1 is the likelier answer.


platform-gateswhich gates to run, and cause 1 in full
scripts/gate-tree-guard.tswhy the detector cannot live in a gate
pipeline/qa-utils.tssaveQaScriptSidecar’s comparison, with 3ozg’s reasoning beside it
generalise-the-fixthe same shape one level up: fix the class, not the instance
upstream-version-adoptionmoving a pin forward — a person’s decision, never a gate’s

folio-assistant — code under the Apache License 2.0, documentation under CC BY 3.0. Generated schema and API references are rebuilt from source on every push to main.

deployed b08c961 · 2026-10-23T23:11:09Z