Skip to content

SPEC-177 build — FR-5 reversed, and a gate that ate its own write-up

Date: 2026-08-13 · Phase: 3-build

Decision: FR-5 reversed — the classification artifact must carry the reminder's row

FR-5 said the artifact must not name the update reminder, even in prose. The reasoning was sound and checkable: guard 37's A2 greps every backticked *.sh in docs/solutions/patterns/enforcement-delivery-classification.md and fails on any name absent from plugins/vt-base/hooks/hooks.json; the reminder deliberately never enters hooks.json, because a hook that reports "the plugin did not load" cannot live inside the plugin. Naming it would turn the guard red. Both halves were true, and the DoR recorded the same verdict at spec time.

The conclusion only followed while the guard's source set was hooks.json — and that was itself the defect. The artifact exists to state which enforcement survives the plugin being disabled. Pinning it to hooks.json alone made it structurally unable to name the one hook that does. It could describe every control that dies with the plugin, and not the single control that outlives it.

So T3 widened commands_in_file() to hooks.json ∪ the shipped configs/user-global/settings.json. A1 now requires the row A2 previously forbade. The extractor did not change — both files walk event → matcher-block → hooks[].command, so this was "run it over a second file and union".

The owner decision's substance is untouched: the reminder stays in the shipped settings.json, the two bd prime entries go. Only the artifact-must-stay-silent consequence is withdrawn.

Lesson (durable): when a gate's constraint forces an artifact to omit exactly the case the artifact was written to record, suspect the gate's input set before rewriting the artifact around it. A constraint that reads as "we cannot document this" is often a scope bug in the checker.

The new row also inverts the table's fourth column, so it says so in the cell and in a note above the table: everywhere else "plugin-independent path" names a second delivery beside the plugin hook; here the shipped settings.json is the only surface.

Learning: a gate that fires on a literal fires on your write-up of the literal

Guard 37's D1 greps the repo for one literal string — a stale hook-count claim it exists to stamp out. (Not reproduced here. That is the whole point; see below.) T5's state.yaml build note explained what D1 gates, and quoted the trigger string verbatim to do it. The note became a violation of the check it documented.

Worse, the same task had just argued the opposite in guard 37's own comment: plan.md is exempt as one file, not the directory, precisely so spec.md and state.yaml stay guarded. The commit that made that claim falsified it.

Fixed by describing the string instead of reproducing it — deliberately not by widening the exemption, which would have made the argument true by making it vacuous.

How it was caught, and why the per-task runs could not: guard 37 was green standalone at T3 and red in the full suite at T5. Same file, different point in history — not a suite-ordering artifact. T5 re-ran guards 47 and 54 but not 37, because "edit spec.md and state.yaml" did not look like it touched enforcement classification. It did.

Lesson (durable): after editing any prose that discusses a gate, re-run that gate. The blast radius of a documentation edit is not the documentation.

It happened a third time, writing this entry. The paragraph above originally quoted the trigger string in order to explain the trap, and D1 caught the journal file. Three occurrences in one build — plan.md at planning time, state.yaml at T5, this entry at close-out — which is enough to call it structural rather than careless: anyone documenting a literal-matching gate will reproduce its literal, because that is how you explain a literal. Write the description, not the string. If a future artifact genuinely must quote it, exempt that file by name at section D with its reasoning, never the directory.

Learning: the widening created a real comm collation hazard

Guard 37 compares two sets with comm, which requires identically-sorted inputs and reports wrong sets in silence when they are not. One side came from Python's sorted() (codepoint order), the other from sort -u (locale order). Latent for as long as no pair disagreed.

The widening created that pair: session-start.sh and session-start-toolkit-check.sh now sit in the same set, and they are exactly what the two orderings disagree about — GNU sort under a UTF-8 locale ignores punctuation at the first level and puts session-start.sh first; codepoint order puts it second. Measured: BSD sort on this machine agrees with codepoint order, so the defect would not have reproduced locally.

Both streams now pass through one LC_ALL=C sort -u. C is safe here specifically because the extractor's regex is ASCII-only — this is not the case where LC_ALL=C byte-truncates multi-byte text during generated-doc regeneration.

Activity: red before green, and mutation-proofing what was already green

Guard 59 landed failing (90a4fd6c, 8 of 24) before configs/user-global/settings.json was touched. Rows 3a/3c/4a/6a/6b/6c/10b flipped at T1 and row 11b at T3, as the plan predicted.

The rows that were already green pre-T1 prove nothing on their own, so each was mutation-proven able to fail for its stated reason: --update leaves no snapshot (so row 9a's "exactly one backup dir" is not automatic); an up-to-date fixture makes the reminder silent (so row 10a is not matching noise); the row-8 sentinel is present in the seed; and a deliberately broken tree copy reports PASS=7 FAIL=1 rather than the clean summary row 11a matches.

That last one also answered T3's open question by measurement rather than argument: guard 37's EXPECTED_ASSERTIONS is 8 and stays 8, because T3 adds a table row, not a check() call.

Final: run-all.shPASS=55 FAIL=2, both pre-existing and outside the branch diff (vt-product-dev manifest drift; site/, which is gitignored build output with zero tracked files). tests/spec-167/test_statusline.bats:294 is red by design until merge — SPEC-167's merge-base byte-pin, not weakened.