mjolnir-qa 5.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,3283 @@ once shipped, so this file is the record of what changed between versions.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [6.0.1] — 2026-10-28
15
+
16
+ A patch release. 6.0.0 shipped a GitHub Action whose report step calls a
17
+ command the same release removed, so every job that used the Action failed,
18
+ whatever the scan found. This release repairs that, along with the findings of
19
+ the maintenance review that followed. Each change fixes behaviour that did not
20
+ match its documentation or its contract. No verb, flag or Action input was
21
+ added or removed.
22
+
23
+ **Upgrading from 6.0.0:**
24
+
25
+ - **The Action.** Pin `Sergey-Bar/Mjolnir@v6.0.1`, or `@v6`, which the stable
26
+ release workflow now moves to the newest 6.x release. The `version` input
27
+ defaults to `6.0.1`.
28
+ - **`mjolnir ci install`.** Run `npx mjolnir-qa@6.0.1 ci install` again with
29
+ the same flags. It recognises the workflow 6.0.0 wrote and replaces its
30
+ broken steps; a workflow you edited still needs `--force`.
31
+ - **Exit codes now match the contract.** `fix` and `explain --plan` return `2`
32
+ on a partial scan, `explain --evidence` returns `2` rather than `1` on an
33
+ incomplete report, an invalid config returns `10` rather than `20` on every
34
+ verb, and `ci verify` now honours `--blocking` and the E0 exemption. A
35
+ pipeline that keyed on the old codes can change colour.
36
+
37
+ ### Fixed: the stable release moves the Action's major tag
38
+
39
+ - **`stable-release.yml` moved `v3` and nothing else.** The step that follows a
40
+ stable tag with the `vN` Action tag exited early for every major but 3, and a
41
+ spec required it to. Its tag push uses the job token, which starts no other
42
+ workflow, so `action-tags.yml` never ran either. That is why
43
+ `Sergey-Bar/Mjolnir@v6`, the pin the documentation recommends, does not
44
+ resolve. The step now derives the major from the tag, and leaves the major
45
+ where it is when a newer release on that line already exists.
46
+
47
+ ### Fixed: the release record
48
+
49
+ - **5.1.0 was never published.** Its changelog section was cut and its stable
50
+ run reached the tag approval, which nobody gave, and 6.0.0 shipped first.
51
+ There is no `v5.1.0` tag and no 5.1.0 on npm. `docs/PUBLISHING.md` listed it
52
+ as published with links to a registry page and a GitHub release that do not
53
+ exist, and the roadmap called it shipped. Both now say what happened, and the
54
+ 5.1.0 section of this file says so too.
55
+ - **The run still waiting at that approval was cancelled.** If it had been
56
+ approved, it would have tagged 5.1.0 and published it as npm `latest`, over
57
+ 6.0.0.
58
+ - **Install commands that were left behind.** The site's guided proof and its
59
+ agent guide pinned 5.0.0, where the guided proof's `ci verify` does not
60
+ exist. `docs/guide/PERSONAS.md` pinned 6.0.0-rc.1, which was never
61
+ published, and sent readers to `stats` for per-rule FP rates; `stats` is the
62
+ fix-tracking ledger and the rates are `explain --list`. None of these files
63
+ is covered by `version:surface:sync`, so they now name 6.0.1 by hand.
64
+
65
+ ### Fixed: the `ci install` Action template pins an Action that works
66
+
67
+ - **It pinned `4a588bc`, a pull-request branch commit that `main` never
68
+ contained.** That commit's `action.yml` installed `mjolnir-qa@latest`, called
69
+ the retired `trust-report` verb and wrote `mjolnir-trust-report.md`. The
70
+ template's publish job reads `mjolnir-comment.md`, so no workflow it
71
+ generated ever published a comment. It now pins the merge commit of the fix
72
+ above, on `main`.
73
+ - **The template passes `version:`**, the published version the generating
74
+ package resolves, which is the npx template's pin. Omitting it ran the pinned
75
+ Action's own default: the stable published when that commit was made, which
76
+ can predate the formats its steps call.
77
+ - **`ci install` printed the pin as a separately typed SHA with 41 hex
78
+ digits**, which names no commit. It now prints the template's own value.
79
+ - Recognition ignores the `version:` value and still recognises 6.0.0's
80
+ template, which had no such line.
81
+
82
+ ### Fixed: the Action, `ci install` and the PR workflow render their reports again
83
+
84
+ The v6 carve retired `summary` and `pr-comment` onto `scan --format
85
+ github-summary` and `scan --format pr-comment`, and shipped only the half of
86
+ each that scans. Every CI consumer renders the `--json` report its scan step
87
+ saved. All of them kept calling the retired verbs, which exit 10:
88
+
89
+ - **The Action.** Its report step runs by default, so v6.0.0 failed every
90
+ consumer's job, on a clean scan as well as a dirty one. Its summary step
91
+ failed too.
92
+ - **`ci install --no-action`.** The generated summary and comment steps are
93
+ `continue-on-error`, so they failed without a sound: no annotations and no
94
+ PR comment, on any run.
95
+ - **This repository's PR workflow**, which has posted "Mjölnir report
96
+ unavailable" on every pull request since 6.0.0.
97
+
98
+ What changed:
99
+
100
+ - **`scan --format github-summary <report.json>` and `scan --format pr-comment
101
+ <report.json>` render a saved report**, as `site/reference/cli.md` already
102
+ said they did. They delegate to the retired verbs' own code, so the
103
+ contracts are the verbs': annotations on stdout inside GitHub Actions, the
104
+ summary written to `$GITHUB_STEP_SUMMARY`, and a render that never gates.
105
+ No verb or flag was added. A directory target still scans.
106
+ - **The comment's commit is checked, not passed in.** It states the commit the
107
+ scan recorded only while the checkout is at that commit, and `unknown`
108
+ otherwise. The Action used to hand `--commit "$GITHUB_SHA"` to a verb that
109
+ printed whatever it was given. The live `--format pr-comment` render, which
110
+ always said `unknown`, now states the commit it scanned.
111
+ - **The Action's summary step no longer pipes stdout into the summary**, which
112
+ copied every annotation command into it as text. Its `strict` input passes
113
+ `--include-warn` instead of the deprecated `--strict`, which printed a
114
+ deprecation notice into every consumer's log.
115
+ - **`ci install` recognises the workflows 6.0.0 wrote**, so re-running it
116
+ replaces their broken steps without `--force`. Recognition required byte
117
+ equality with the current template, so the pinned version alone made every
118
+ earlier release's file read as hand-customised. Line endings, the pinned
119
+ version and the Action SHA are now ignored, and any other edit still refuses.
120
+ - **The PR workflow's render step fails the job when the render fails**,
121
+ instead of `exit 0` under `continue-on-error`. Its fallback report for a
122
+ failed analysis now validates: it carried `"reason":"analysis-failed"`, which
123
+ the report validator rejects, so the one report written for that case could
124
+ never be rendered.
125
+ - **A `--include-warn` scan's report loads again.** With the quarantine tier
126
+ running, nothing is withheld, and `rulesWithheld` said 0 while
127
+ `withheldRuleIds` still named the whole tier. The loader rejects a report
128
+ that contradicts itself, so the Action's `strict: true` would have failed
129
+ every render even with the verbs fixed, and no saved-report command could
130
+ read such a report. Lifecycle resolution also read those rules as withheld,
131
+ so a quarantine-tier finding that a strict scan had fixed resolved as
132
+ `INCONCLUSIVE(rule-withheld)`.
133
+
134
+ The same defect in three more places, found by the widened gate below:
135
+
136
+ - `release.yml` ran `release-trust`, retired onto `ci release-trust`, so that
137
+ step exits 10 when it runs. `stable-release.yml` already used `ci`.
138
+ - `site/guide/forensics.md` told readers to run `npx mjolnir-qa@6.0.0
139
+ forensics`, and the version-surface check required that line. Both now say
140
+ `explain --evidence`.
141
+ - `docs/AI-DISCOVERY.md` pinned 5.0.0 and named the retired `baseline` and
142
+ `verify`. It now pins 6.0.0 with `--save-baseline` and `ci verify`, and
143
+ `site/public/llms.txt` says 6.0.0 too.
144
+
145
+ Why nothing caught it:
146
+
147
+ - **`check-cli-contract` matched only `mjolnir <verb>`.** CI steps launch the
148
+ CLI as `npx "$MJ_PACKAGE"`, `npx …/mjolnir-qa-<version>.tgz`, `npx
149
+ mjolnir-qa@<version>` or `node dist/cli.mjs`, and it now reads all of them.
150
+ `docs/MIGRATION-3.0.0.md` joins the frozen-report list: its commands pin
151
+ 3.0.0, where those verbs exist.
152
+ - **Four specs asserted the command text**, and text is satisfied by a command
153
+ that cannot run. `tests/integrations/ci-render-commands.spec.ts` takes each
154
+ consumer's command from the file that ships it and runs it against the CLI,
155
+ and checks every scan argument the Action can pass.
156
+
157
+ Still open:
158
+
159
+ - The Action runs the published package, so Action users get this with the
160
+ next release, which moves the `version` default to it.
161
+ - The floating `v6` Action tag was never created, because the tag job for
162
+ v6.0.0 was cancelled. The docs recommend `Sergey-Bar/Mjolnir@v6`, which does
163
+ not resolve yet.
164
+
165
+ ### Changed: every devDependency is an exact pin, and current
166
+
167
+ `npm outdated` now lists only the two blocked upgrades below. Runtime
168
+ `dependencies` and `peerDependencies` keep their ranges on purpose: they
169
+ resolve inside every user's install, and an exact pin there would force
170
+ duplicate copies and stop transitive security patches from reaching users.
171
+
172
+ - **Pinned and updated.** Every devDependency in the root, the
173
+ `packages/playwright-reporter` workspace and `site/` is now an exact
174
+ version, taken to its latest release inside the existing major. This
175
+ covers Playwright 1.64.1 (and `playwright-core`, which two scripts import
176
+ directly, at the same version), Sentry 11.9.0, Vitest 5.0.5, ESLint
177
+ 10.13.0, typescript-eslint 8.72.1, Prettier 3.9.10, tsdown 0.23.3 and
178
+ Lighthouse 13.6.0.
179
+ - **commitlint 19 → 21.** The two majors break nothing here. 20 makes the
180
+ body line-length rule skip URL lines, and 21 drops Node 18 and 20. 21's
181
+ parser does read a body line that opens with `word: ` as a footer, which
182
+ `footer-leading-blank` then rejects. Across the last 150 commits on `main`
183
+ that rejected 2 that 19 accepted, and `.commitlintrc.cjs` now says how to
184
+ avoid it.
185
+ - **vitepress 2.0.0-alpha.19 → alpha.21.** There is no stable 2.x, and 1.6.4
186
+ would be a downgrade. The site still renders 99 pages, its 44 tests and
187
+ doctor pass, and `home.data.ts` no longer trips the warning Vite prints for
188
+ its planned native config loader.
189
+ - **The reporter's TypeScript 7 is gone.** Its build resolves the root
190
+ compiler, and building with and without the nested 7.0.2 gives
191
+ byte-identical output. Removing it ends what read as a split toolchain and
192
+ drops the compiler's 20 native binaries.
193
+ - **Lockfile 589 → 461 entries.** Everything that left had no dependent in
194
+ the new tree, and the file still has the linux, darwin and win32 variants
195
+ every native package needs.
196
+ - **`scripts/complete-lockfile.mjs` no longer resurrects removed packages.** It
197
+ restored every key the known-good lockfile had, so `siginfo` and
198
+ `stackback` sat in the committed lockfile with nothing depending on them.
199
+ It now restores only platform entries the tree still wants, and has tests.
200
+ - **Dependabot.** Minor and patch updates come back as weekly grouped PRs:
201
+ runtime and tooling separately, plus the site, which had no entry and
202
+ received only security updates. 5.0.0 ignored them to cut noise, but with
203
+ every version locked an ignored update is never taken, which is how the
204
+ tree fell behind. Each blocked upgrade states when it was re-verified and a
205
+ review-by date.
206
+
207
+ Still blocked, re-verified 2026-10-28, review by 2027-01-26:
208
+
209
+ - `web-tree-sitter` 0.27.1 cannot load the Java or C# grammars of
210
+ `tree-sitter-wasms` 0.1.13, still its latest release, and 0.25.6 can.
211
+ - TypeScript 7 has no JS API for ts-morph, and typescript-eslint 8.72.1 still
212
+ requires TypeScript below 6.1.
213
+
214
+ Decided against, on inspection:
215
+
216
+ - **Moving Lighthouse into `site/`.** The root deliberately holds Lighthouse,
217
+ axe-core and Playwright for `site:lighthouse` and `site:audit`, so the site
218
+ build that CI and Pages run installs only vitepress. Lighthouse's dependency
219
+ closure is 137 lockfile entries, which would be added to every site build.
220
+ - **Dropping `axe-core`.** It looked unused only because `site:audit` loads
221
+ `axe.min.js` by path.
222
+
223
+ ### Removed: scripts and npm aliases that nothing ran
224
+
225
+ Files under `scripts/`, 169 → 138. Tracked npm scripts, 147 → 140.
226
+ `git show 05af655:<path>` recovers any deleted file.
227
+
228
+ - **`scripts/archive/` (24 files).** One-shot fixture authors and
229
+ adjudicators from 2026-06 to 6.0. Their output is committed and none of
230
+ them ran; they were kept only as `HISTORY` entries in the orphan-script
231
+ list, and that list and the archive lint block are gone with them.
232
+ - **Orphans:**
233
+ - `scripts/lib/omitted-measured.ts` and `scripts/lib/test001-probe.ts`:
234
+ scratch probes with zero references.
235
+ - `scripts/lib/coverage-exemption-ledger.ts`: a typed wrapper nothing
236
+ imported.
237
+ - `scripts/lib/candidate-decision.ts`: reachable only through an
238
+ extensionless import that resolved to the `.mjs` anyway. Its spec now
239
+ imports the `.mjs`, typed by the existing `.d.mts`.
240
+ - `scripts/compute-release.mjs` and its spec: release-bump logic whose
241
+ orphan entry said "reachable by nobody".
242
+ - `scripts/verify-remediation-isolation.mjs` (`verify:isolation`): a drill
243
+ hard-wired to a branch that no longer exists.
244
+ - `scripts/retired-external-evidence.ts` (`docs:external-evidence`): a
245
+ tombstone that exited 0 and checked nothing. The three documents that
246
+ named it now call it "the `docs:external-evidence` script (since
247
+ removed)".
248
+ - **Aliases for suites `npm test` already runs:** `frontier:contracts`,
249
+ `test:integration`, `test:mcp:parity`, `test:release` and
250
+ `artifact:deterministic`. Three of their `docs/MANUAL-SCRIPTS.md` rows were
251
+ false: `test:mcp:parity` did not "need a live MCP client", `test:release`
252
+ needed no credentials, and `test:integration` was never "kept out of the
253
+ certify chain". All three ran in every `npm test`. The agent skills now name
254
+ the commands the aliases stood for.
255
+ - **`docs/CI-STANDARD.md`** credited the compatibility lanes with
256
+ `npm run test:integration`. They run `npm test`, and the row now says so.
257
+
258
+ Not done, on inspection: `check-promotion-throughput.mjs` keeps running under
259
+ `tsx`, because it imports `src/commands/doctor.ts` and plain `node` cannot load
260
+ it.
261
+
262
+ ### Removed: tracked files with no reader
263
+
264
+ Six paths were tracked and read by nothing. Git history keeps all of them,
265
+ and `repo-hygiene.spec.ts` now fails if any returns:
266
+
267
+ - `enterprise-output/`: a 4.0.0 copy of `capability-manifest.json` with no reference.
268
+ - `QA/`: one 2026-09-06 report that only linked to itself.
269
+ - `archive/`: five exported stash patches. Their own README records each as
270
+ superseded, and they apply only at their old base commits.
271
+ - `assets/readme/homepage-linkedin-aurora.gif`: 4.85 MB, referenced nowhere.
272
+ - `.github/workflows/jekyll-gh-pages.yml`: a workflow whose only step was an
273
+ `echo`, plus its tier exemption.
274
+ - `.claude/launch.json`: hard-coded another machine's worktree path; now gitignored.
275
+
276
+ Also fixed:
277
+
278
+ - **CHANGELOG.** A second `## [Unreleased]` block (about 1,000 lines of
279
+ 6.0.0 working notes) sat below `[6.0.0]`, where the release script would
280
+ have swept it into the next version. It is now titled as the 6.0.0
281
+ development log, and a test asserts exactly one `[Unreleased]`, first.
282
+ - **Stale references.** Removed a `package.json` `allowScripts` entry for
283
+ `@vitest/coverage-istanbul@5.0.1`, which the lockfile does not install.
284
+ Fixed the commit-msg hook's `.commitlintrc.json` reference (the file is
285
+ `.cjs`), the `.prettierignore` entries for retired M26 files, two Sonar
286
+ paths that do not exist, and the CodeRabbit workflow count.
287
+
288
+ Kept on purpose: `PRODUCT-ENHANCEMENT-ANALYSIS.md` is a pinned redirect to
289
+ its archive copy (`GAP-V6-007`, two specs), and `kilo.json` is the
290
+ documented shared skills wiring (`docs/EXTERNAL-CONFIG.md`).
291
+
292
+ ### Fixed: the P0/P1 findings of the maintenance review (#732)
293
+
294
+ Each has a regression test that failed before its commit.
295
+
296
+ - **SARIF results pointed one directory too high.** `SRCROOT` had no trailing
297
+ `/` (SARIF 2.1.0 §3.14.14), so RFC 3986 resolution of `src/a.ts` against
298
+ `file:///repo` landed on `file:///src/a.ts` in every Code Scanning upload.
299
+ - **Failing .NET tests disappeared from TRX runs.** A `UnitTestResult` body
300
+ over 4,000 characters (normal with StdOut and a stack trace) did not match,
301
+ so the failure was dropped and the run read greener than it was.
302
+ - **Quadratic parsing of untrusted input.** JUnit `<failure>` tags without a
303
+ closer took 63 s for 2.2 MB, and `countTestDeclarations` took 152 s for
304
+ 300,000 blank lines (it runs on every scanned file). Both are linear now.
305
+ - **Exit codes.** `--save-baseline` no longer corrupts `--json` output, never
306
+ saves a partial scan, and reports a failed save as `2` (not `1`) after the
307
+ report. `fix` and `explain --plan` exit `2` on a partial scan. An invalid
308
+ config exits `10` on every verb, not `20`. `ci verify` gates new findings
309
+ through the same matrix as `scan`, so `--blocking` and the E0 exemption
310
+ apply. `explain --evidence` exits `2`, not `1`, on an incomplete report.
311
+ - **MCP workspace root.** With `MJOLNIR_WORKSPACE_ROOT` set, a symlink or
312
+ junction inside the root that pointed outside it was accepted, and a child
313
+ named `..cache` was rejected. Both sides are now real paths. Without the
314
+ variable the read-only tools stay open, as before; the server header no
315
+ longer claims otherwise.
316
+ - **Evidence store lock.** A live lock was bypassed after ~0.4 s: the write
317
+ ran unlocked and deleted the owner's lock. It now waits until the lock
318
+ could be stale and otherwise times out without running.
319
+ - **Half-applied runtime corroboration.** A throw part-way through left some
320
+ findings at a runtime trust level (up to L5). Findings are restored and the
321
+ new `runtime-corroboration-failed` degradation is recorded.
322
+ - **Action output.** `sarif_file` was nested under `findings:` in `action.yml`
323
+ and was always empty for consumers.
324
+
325
+ Deferred, recorded on #732: the same `^\s*` pattern in three detectors also
326
+ reports the wrong line (the blank line above the declaration); fixing it
327
+ changes line attribution, so it ships with a `detectorRevision` bump and a
328
+ corpus re-measure.
329
+
330
+ ### Fixed: `main` was red on every CI run since 2026-10-06
331
+
332
+ - **Lockfile.** `package-lock.json` had been regenerated on one platform and
333
+ lost the linux/darwin `lightningcss` binaries, so `npm ci` failed on the
334
+ Node 24 job and on any fresh install. Restored with
335
+ `scripts/complete-lockfile.mjs`; `npm run lockfile:platforms` passes.
336
+ - **README contracts.** The 6.0.0 README rewrite dropped the release-status
337
+ section, the `Status` heading, the documentation table, the roadmap link,
338
+ the census sentinels, the rule table, the WORTHINESS alt text and the
339
+ "a CI step fails" wording on exit `2` — every one pinned by a contract spec,
340
+ 13 failures in all. They are back in the short layout. Two claims in the
341
+ rewrite were wrong and are corrected: `QA-CI-008` is "always-success step
342
+ masks failures", not "mutable Action refs", and the YAML example named
343
+ detections no rule makes (`--maxfail=0`, `--workers=1`, `@main`).
344
+ - **README specs restored, not weakened.** The same rewrite had cut three
345
+ contract specs to match it: `readme-doctest` (which runs every README
346
+ command and checks the `explain QA-CI-001` transcript) became
347
+ `expect(true).toBe(true)`, `readme-samples` asserted the samples were
348
+ _absent_, and `readme-commands` grew a ten-verb exemption list. Those specs
349
+ were the only tests reaching several command paths, so function coverage
350
+ fell to 98.58 % against a 98.67 % ratchet floor. The specs are restored
351
+ verbatim from 6.0.0, and the command table, the explain transcript and the
352
+ two terminal samples come back as collapsed `<details>` blocks in a
353
+ Reference section, which keeps the page short. Functions are at 98.76 %;
354
+ the suite runs 87 more tests.
355
+ - **Version surface.** `checkVersionSurfaceEnvelope` read an inline-code
356
+ install command (`` `npx mjolnir-qa@X` ``) as `` X` ``, so an unpublished
357
+ version written in backticks was never reported. The terminator set now
358
+ includes the backtick, matching the sync routine.
359
+ - **Weekly SARIF scan.** `sarif-code-scanning.yml` scans `.` and had failed
360
+ every run since 2026-09-28 with exit `2`: `mjolnir.config.json` excluded
361
+ `tests/golden/**`, which covers the real spec `tests/golden/golden.spec.ts`,
362
+ so the scan honestly reported PARTIAL. The exclude is narrowed to the
363
+ fixture repo, `tests/golden/repo/**`, and `repo-hygiene.spec.ts` now asserts
364
+ the whole-repo scan is complete.
365
+ - **Promotion ratchet.** `docs/RULE-PROMOTION-LEDGER.json` still bounded
366
+ `core` at `0` after 6.0.0 promoted `QA-PW-117` and `QA-JV-101`. The ratchet
367
+ passed only because a stale `[Unreleased]` block below the 6.0.0 heading
368
+ still carried that release's anti-creep exception marker; a new entry at the
369
+ top turned it red. The growth is ABSORBED (bound `0 → 2`, the same convention
370
+ as `docs/ANTI-CREEP-BASELINE.json`), and the licensed-growth arm, which the
371
+ committed tree no longer reaches, is now driven by a fixture pair. (This
372
+ entry spells the marker in lower case on purpose: the gate matches the
373
+ upper-case token anywhere in the top entry, so quoting it would license
374
+ growth that nobody declared.)
375
+ - **Dependency advisories.** With `npm ci` failing, `npm run audit:ci` had
376
+ never been reached; it now ran and failed on `zod` (high, GHSA-mrhx-c7xc-m3w5,
377
+ via lighthouse → puppeteer-core → chromium-bidi) and `fast-uri` (moderate).
378
+ The site lockfile carried a `vite` advisory (high, GHSA-vfpm-58rq-9qcg). All
379
+ three are lockfile-only `npm audit fix` updates inside the declared ranges,
380
+ in dev tooling only — nothing in the published package changes.
381
+ `lockfile:platforms` passes for both lockfiles.
382
+ - Regenerated: `docs/BLAST-RADIUS-AUDIT.md`, `docs/CARVE-MANIFEST.json`,
383
+ `candidate-trust-manifest.json`.
384
+
385
+ ## [6.0.0] — 2026-10-04
386
+
387
+ <!-- ANTI-CREEP-EXCEPTION: The launch set grew from 0 to 2 — QA-PW-117 and QA-JV-101 both earned core at n=35 with a 9.89% Wilson upper bound against the 10% ceiling, every row adjudicated by hand against a pinned corpus commit. Law 1 requires an equal-size removal or a recorded exception. Nothing was removed, deliberately: the quarantine population is what keeps unmeasured rules out of default scans, and promoting two rules by demoting two measured ones would have traded measured accuracy for a smaller number. The growth is arithmetic, not preference. -->
388
+
389
+ The verification trust engine, measured. A tier you can only reach with evidence,
390
+ a promotion that cannot land unreviewed, and generated documentation that cannot
391
+ quietly go stale behind the code. Carries the 6.0.0-rc.1 line below.
392
+
393
+ ### Two core rules, and the arithmetic behind them
394
+
395
+ `QA-PW-117` and `QA-JV-101` hold `core`, each at **n=35, zero observed false
396
+ positives, Wilson upper bound 9.89%** against a 10% ceiling — reproduced by hand
397
+ from `src/lib/wilson.ts` before either was believed.
398
+
399
+ `QA-JV-101` earned it in two batches, and the sequence matters: **32 (10.72%) →
400
+ 34 (10.15%) → 35 (9.89%)**. The first batch's verdict file reads as a refusal on
401
+ its own ("STILL SHORT, NOT promoted"). Reading only it is how an audit concluded
402
+ the promotion was fabricated. Both files are cited in the certification record so
403
+ the next reader cannot repeat the mistake.
404
+
405
+ ### The tree told a different story than the code
406
+
407
+ Both promotions reached the registry, the tier evidence and this file while their
408
+ generated documentation still reported `extended` at n=23/24. Nothing failed,
409
+ because nothing asked — `npm run check` regenerates and then inspects nothing,
410
+ and three of four workflows asserted staleness with `git diff`, which is provably
411
+ blind to an untracked generated page.
412
+
413
+ - **`npm run docs:staleness`** — one assertion, `git status`-based, read-only by
414
+ construction, wired into all four workflows.
415
+ - **A count nobody can check is a claim, so generate it.** `README.md` said
416
+ "`core` is empty" for a release after two rules had earned it.
417
+ - **`docs/MEASUREMENT-CLOSEOUT.md`** corrected 74/5 → 73/6, gained the missing
418
+ `QA-PY-004` row, and separates _stale_ from unmeasured.
419
+
420
+ ### A promotion can no longer land with no artifact
421
+
422
+ `docs/CORE-CERTIFICATION.json` is required to hold `core`, and expires with its
423
+ evidence: the measurement is compared against the live registry field by field.
424
+ `docs/TIER-HISTORY.json` records all 19 transitions as data, so a demotion is no
425
+ longer something that exists only as a source comment.
426
+ `docs/RULE-CONSTITUTION.json` states seven principles, each naming the code that
427
+ enforces it, plus **two principles it explicitly refuses** with the measurement
428
+ that refutes them.
429
+
430
+ ### The detector set is wired; it is not yet shown to have teeth
431
+
432
+ Of the 40 rules carrying both fixture legs, **2** have a negative fixture that is
433
+ the positive fixture with the defect removed. The other **38** are a different
434
+ program. Leashed at the true floor of 2, so it fails on a rule _regressing_ out of
435
+ that set and not on the 38 that were always like this. Closing the gap needs a
436
+ per-rule recipe for what the defect _is_; that work is named, not assumed away.
437
+
438
+ ### .NET runs are evidence (ADR 0015)
439
+
440
+ `EVIDENCE_CONVENTIONS` held four entries and .NET was in none of them, so a C#/F#
441
+ run left every finding at `INFERRED` however often the suite actually ran. TRX is
442
+ now dispatched **before** JUnit, and that ordering is load-bearing: the JUnit
443
+ branch is reached by an `<?xml` sniff, so a TRX file was matching nothing and
444
+ returning an empty run — which reads as "the suite passed", and for a .NET project
445
+ whose tests were never examined was the most reassuring possible wrong answer.
446
+
447
+ ### Release honesty
448
+
449
+ `candidate:readiness` remains **NO_GO** on this release. Four of its ten blockers
450
+ are `REMOTE_PROVEN` attestations about the real world — protected holdout,
451
+ real-world repositories, platform matrix, consumer install, remote workflow —
452
+ plus external validation and owner authorization.
453
+
454
+ The check RAN and was RECORDED as unsatisfied. It was not treated as blocking
455
+ because `vars.STABLE_RELEASE_AUTHORIZED` is `true`: a decision someone made and
456
+ wrote down, not a check that passed.
457
+
458
+ ### Development log: 6.0.0-rc.1 → 6.0.0
459
+
460
+ The working notes written between the release candidate and the cut. They
461
+ sat under a second `## [Unreleased]` heading below this release, which
462
+ `scripts/release-changelog.mjs` would have swept into the next version as if
463
+ it were new work. They are history of 6.0.0 and are kept verbatim below.
464
+
465
+ ### Two core rules, measured: QA-PW-117 and QA-JV-101 - `ANTI-CREEP-EXCEPTION`
466
+
467
+ Law 1 requires an equal-size removal for every addition to the governed set.
468
+ This is an addition of **two** with nothing to remove, because the governed set
469
+ held **zero** core rules until now. The exception is recorded rather than the
470
+ baseline being edited, because `previousBaselineCore` is `0` on purpose: lowering
471
+ `baselineCore` to match the grown tier would make the growth read as zero and
472
+ switch the law off in one uncross-checked JSON edit.
473
+
474
+ The reason is that both are **measured** promotions, not declared ones.
475
+ `QA-PW-117` reached n=35 and `QA-JV-101` reached n=35, both with zero observed
476
+ false positives and a Wilson upper bound of **9.89%** against the 10% ceiling,
477
+ both adjudicated row by row against pinned source, and both found by a
478
+ corpus-wide `--core-candidates` sweep rather than by any global cap raise.
479
+ `measurementTier` returns `core` for each on its own evidence and
480
+ `declaredCoreWithoutEvidence` is null, so each declaration records a decision the
481
+ measurement had already reached.
482
+
483
+ Both had been demoted from core in 6.0 for exactly this reason, and
484
+ `src/rules/tier-evidence.ts` ranked `QA-PW-117` first among the nineteen most
485
+ likely to clear on a re-sample.
486
+
487
+ ### The tree told a different story than the code
488
+
489
+ Both core promotions landed in code, in the tier evidence and in this file,
490
+ while their generated documentation went on reporting `extended` at n=23 and
491
+ n=24. Nothing failed: `npm run check` regenerates and then inspects nothing,
492
+ so a generator that rewrites a committed file leaves the tree green by
493
+ construction. Four workflows each asserted staleness by hand and three of them
494
+ could not see an untracked file at all.
495
+
496
+ - **`docs/rules/QA-JV-101.md`, `docs/rules/QA-PW-117.md` and
497
+ `docs/DEPTH-ADJUDICATION.md` regenerated.** The shipped rule pages claimed
498
+ `extended` at n=23/24; both rules are `core` at n=35. No finding changes.
499
+ - **`npm run docs:staleness` — one staleness assertion, and it can run locally.**
500
+ It reads `git status`, so it sees an untracked generated page; `git diff`
501
+ alone cannot, which is how a new rule's docs page shipped silently through
502
+ `corpus-audit.yml`, `release.yml` and `stable-release.yml`. Read-only by
503
+ construction — it cannot cost a working tree the adjudication records under
504
+ `tests/corpus/verdicts/`, and it is deliberately absent from `npm run check`,
505
+ where asserting after regenerating would be checking the generator against
506
+ itself. All four workflows now call it, and
507
+ `tests/contract/generated-surfaces.spec.ts` fails if a fifth is added that
508
+ does not, or if the copies drift apart again.
509
+ - **`core` is a generated number, not a sentence.** `README.md` said "`core`
510
+ is empty" for a whole release after two rules had earned it. `measurementBlock()`
511
+ now returns `core` and `docs:counts` stamps it into `README.md`,
512
+ `docs/MEASUREMENT-CLOSEOUT.md` and the other census surfaces, so
513
+ `tests/contract/census-drift.spec.ts` fails if prose and registry disagree.
514
+ It counts UNMEASURED core rules deliberately: `MAX_UNMEASURED_CORE = 0`
515
+ means such a registry cannot ship, and a census that hid the rule behind
516
+ `measured` would report a clean `core: 0` at the moment the law broke.
517
+ - **`docs/MEASUREMENT-CLOSEOUT.md` corrected to 73/6** (it read 74/5), gained
518
+ the missing `QA-PY-004` row, and now separates **stale** from unmeasured:
519
+ `QA-PY-004` (revision 4) and `QA-PY-007` (revision 5) have verdict sets that
520
+ no longer describe the shipped detector, so they need re-measurement at the
521
+ current revision rather than new classification.
522
+ - **`src/rules/java/qa-jv-101-disabled-test.ts` — one provenance block.** The
523
+ file carried three stacked comments, two of them obsolete demotion notices
524
+ contradicting the third. It now records the two-batch arithmetic
525
+ (n=32 → 34 → 35) and names both verdict files, because the first batch
526
+ (`jv-101-final.json`, "STILL SHORT") reads as a refusal on its own and the
527
+ second (`jv-101-core.json`) is what cleared the ceiling.
528
+ - **Governing prose that had gone stale.** `rule-author` and `surface-law`
529
+ both asserted the core tier was empty; the archived
530
+ `QA-FINAL-RELEASE/RULE_CERTIFICATION.md` carries a SUPERSEDED banner marking
531
+ its `core` column a fossil of the deleted `registry-census` predicate. The
532
+ ADR and the roadmap's "23 pending classifications" were left alone: they
533
+ record what a decision or a pass actually was, and editing them to match
534
+ today would falsify the record.
535
+
536
+ ### The detector set is wired. It is not yet shown to have teeth.
537
+
538
+ `docs/PRECISION-RATCHET.json` records 32 rules as holding a PRECISION leg and
539
+ says plainly that this is "a wiring proof, not an accuracy proof, and a
540
+ self-derived one". That caveat can be given a number, and the number is not
541
+ comfortable.
542
+
543
+ A must-not-fire fixture is only evidence that a rule is _sensitive_ when it is
544
+ the must-fire fixture with the defect neutralised. When it is a different
545
+ program that merely does not trigger, the pair shows the detector is
546
+ directional and says nothing about the predicate. The quad counts presence, so
547
+ both look identical to it.
548
+
549
+ - **`docs/SENSITIVITY-RATCHET.json` — the SENSITIVITY arm of
550
+ `check-fixture-quad`.** Of the 40 live rules carrying both fixture legs, **2**
551
+ hold a negative fixture that is plausibly the positive fixture with the defect
552
+ removed (`QA-PW-144`, one changed line of nine; `QA-PW-116`, one of seven).
553
+ The other **38** are a different program — the next nearest is 0.55 diverging
554
+ and the furthest diverge by more lines than either file contains, because a
555
+ hand-written neutral fixture also renames the class and rewrites the bodies.
556
+ `QA-JV-101`, a core rule, is one of them: its negative fixture is a different
557
+ class with different methods and no case distinguishing a disabled test from
558
+ a justified one.
559
+ - **Leashed at the true count, in an existing gate.** No new gate id and no new
560
+ npm script, so Law 0 is untouched: the arm is folded into
561
+ `check-fixture-quad` beside PRECISION, which asks a different question and
562
+ reports separately. The floor is 2, the honest number, so the gate fails on a
563
+ rule _regressing_ out of the mutant set or on a new rule shipping without one,
564
+ and does not go red on the 38 that were always like this. `--update` only ever
565
+ raises the floor, so a regression can never be absorbed by re-running a command.
566
+ - **Neither core rule is exempt.** The ratchet names the gap rather than
567
+ narrowing it: closing it needs a per-rule recipe for what the defect _is_, so
568
+ the mutant can be generated rather than hand-written a second time and left to
569
+ drift. That is the remaining work, and it is now named rather than assumed
570
+ away.
571
+ - **`docs:staleness` no longer watches provenance-stamped artifacts.** Their
572
+ `baseSha` is meant to lag, and `git status` cannot tell a restamp from real
573
+ drift, so watching them produced FAIL on every regeneration and caught nothing
574
+ that matters. `docs:provenance-drift` already checks their content with the
575
+ provenance keys excluded; leaving them there would have been a second copy
576
+ doing the same job worse.
577
+
578
+ ### A promotion can no longer land with no artifact
579
+
580
+ Both core promotions reached the registry, the tier evidence and this file while
581
+ their generated documentation went on reporting `extended` at n=23 and n=24.
582
+ Nothing failed, because nothing asked. The mechanism that should have caught it
583
+ now exists, and it catches the case that matters: not "is this rule measurable"
584
+ but "did a person certify **this** measurement, and does the certification still
585
+ describe the rule".
586
+
587
+ - **`docs/CORE-CERTIFICATION.json` — a record is required to hold `core`.** A
588
+ rule with no record fails; a record for a rule that lost the tier fails, because
589
+ a demotion belongs in the tier evidence ledger, not in a certification file.
590
+ - **The record expires with its evidence, which is the entire design.** The
591
+ measurement block is compared field by field against the live `MEASURED_FP`
592
+ row on every run. Move the detector revision, change n, or introduce an
593
+ observed false positive and the gate fails until someone re-certifies and
594
+ writes down why. It cannot outlive what it certifies.
595
+ - **The cited evidence must account for the sample.** Every path a record cites
596
+ must exist in the tree, and the corpus verdict rows it lists must sum to
597
+ exactly `n`. A certification may not rest on evidence that is not here, nor on
598
+ less evidence than it claims. Proven in both directions: a record citing 33
599
+ rows for an `n=35` measurement fails.
600
+ - **Self-derived evidence is excluded on purpose.** The quad verdicts under
601
+ `tests/corpus/verdicts/quad/` are wiring proofs and do not count. Only corpus
602
+ rows do — which is why both records sum to exactly 35 (29+2+4 and 20+10+2+3).
603
+ - **`QA-JV-101`'s record states its known weakness rather than omitting it.** Its
604
+ must-not-fire fixture is a hand-written different program, not the must-fire
605
+ fixture with `@Disabled` neutralised, so per `docs/SENSITIVITY-RATCHET.json` it
606
+ does not prove the predicate is sensitive to the annotation. That is the gap
607
+ per-rule defect recipes must close, and a certification record that omitted it
608
+ would be worse than no record.
609
+ - **No new gate, no new npm script.** This is an arm of `rules:promotion:check`,
610
+ which is already the promotion gate and already in `gates:claim-integrity`. Law
611
+ 0 charges for both, and extending the existing gate is free where a sibling
612
+ would not be. Deliberately not given its own unit test either, for the reason
613
+ `src/rules/conformity.ts` already states about a duplicate check.
614
+
615
+ ### The delta loop is now visible, and the freeze is written down
616
+
617
+ - **Quickstart documents baseline → delta.** Steps 1–3 report the **state** of
618
+ your tests; `mjolnir ci verify` reports the **change**, which is the only
619
+ question a reviewer has about a diff. `--save-baseline` writes
620
+ `.mjolnir/baseline.json`, `ci verify` reports RESOLVED (of which only
621
+ `VERIFIED-RESOLVED` is a fix claim) and NEW, and the `0`/`1`/`2` exit contract
622
+ is stated. It also says why `mjolnir ci install` does not add it: `ci verify`
623
+ exits `2` until a baseline exists, which would redden every install on day
624
+ one. Agents were already taught this at `src/commands/install-agents.ts:58`;
625
+ humans were not taught it anywhere.
626
+ - **`docs/ENGINE-FREEZE.md` — what a consumer may rely on.** The source of truth
627
+ is `CONTRACT_REGISTRY` in `src/engine/contract-versions.ts`; the document
628
+ deliberately copies no version numbers, for the same reason the census
629
+ sentinels exist. It explains each `compatibilityPolicy`, records that
630
+ `scoringModelVersion` 2.0.0 is a _semantic_ change (ADR 0014's evidence floor)
631
+ so a consumer comparing scores across it is comparing different things, and is
632
+ honest that `frameworkSupportMatrixVersion` is written by the engine and read
633
+ by nothing but a test — a version stamp on a fact, not a negotiated protocol.
634
+
635
+ ### Verification and enforcement overhaul
636
+
637
+ Track A of the verification-enforcement plan: the checks that could fail but
638
+ never ran, the readouts that disagreed with each other, and the two places where
639
+ a claim was made without a mechanism behind it. Nothing here changes what a
640
+ finding says except where stated (core tier, scoring model).
641
+
642
+ #### Added
643
+
644
+ - **`docs/PRECISION-RATCHET.json` — the PRECISION arm.** `check-fixture-quad`
645
+ failed only on an over-claim (a capability advertising M3 without its four
646
+ fixture legs). It had no arm for the leg that a fixture directory cannot
647
+ satisfy: PRECISION needs someone to have classified the detector _silent_ on a
648
+ real input. `npm run check-fixture-quad -- --update-precision` records the
649
+ count of live rules holding a classified TN verdict; the gate fails below the
650
+ recorded floor. **Recorded at 32, not 0** — 32 rules already hold a TN leg
651
+ from the generated `--verdicts` pass, so a floor of 0 would have been
652
+ decoration. The ratchet note says plainly that these are scanner-executed
653
+ fixture legs: a _wiring_ proof, not an accuracy proof.
654
+ - **`docs/HOLDOUT-SPLIT.json` + `npm run holdout:split`** — the deterministic
655
+ partition of corpus repositories into measurement and holdout that
656
+ `claim-registry.json` blocked `rule-registry-census` on. The unit is the
657
+ repository, never the row (a per-row split puts the same test in both halves
658
+ and can be searched until the halves agree); the assignment is a salted hash
659
+ of the repository id, not a list; and the single exclusion — repositories
660
+ holding rows for an `isCoreCandidate` rule — is _derived_ from that predicate
661
+ rather than declared, so it moves with the candidate set and cannot be edited
662
+ into place. The holdout **validates, it does not gate**: core promotion reads
663
+ the measurement partition, because a gating holdout would be sized by the
664
+ corpus rather than by the question.
665
+ - **`docs/QUARANTINE-OWNERSHIP.json` — the quarantine exit ramp.** 34 rules were
666
+ quarantined with no disposition recorded for any of them; `checkQuarantineOwnership`
667
+ _reported_ that count and explained that a cap "would have to start at 34".
668
+ The defect was not the number, it was that silence passed. A quarantined rule
669
+ must now carry either a `quarantinePromotion` or a typed disposition with an
670
+ owner, a rationale and a review date, and doctor **fails on silence**.
671
+ Dispositions are derived from the Wilson interval: 26 `pending-samples`,
672
+ 3 `pending-measurement`, 4 `pending-remeasure` (a rework shipped and the
673
+ verdict predates it), 1 `structural` (QA-TEST-003, whose proposed fix was
674
+ implemented, measured, and rejected).
675
+ - **`RETRACT` in `corpus:apply-verdicts`, and `--verdicts-dir=`.** The orphan
676
+ rule in `tests/corpus/verdicts/README.md` said an orphaned row "must be
677
+ removed" and there was no mechanism for it — only a tool that fills blanks.
678
+ A verdict recorded on a row whose finding is not there is worse than a stray
679
+ row, because it becomes evidence. `RETRACT` removes a blank row and records
680
+ nothing, and **refuses** a row that already carries a verdict. The
681
+ `--verdicts-dir` flag exists because the retraction path was otherwise
682
+ untestable without writing into the live corpus mid-run.
683
+ - **`mjolnir doctor` runs from an installed package.** It defaulted its target
684
+ to `process.cwd()`, so a user who installed the CLI and ran `doctor` in their
685
+ own project got `No fixtures directory … Run from the mjolnir repo root.` — a
686
+ self-audit reachable only from the source tree. With no target it now resolves
687
+ the installed package from `import.meta.url`. `package.json#files` ships what
688
+ the self-audit reads (`tests/fixtures`, `tests/corpus/verdicts`,
689
+ `src/rules`, the two baseline JSONs, CHANGELOG) — without `src/rules` the
690
+ installed audit is INCONCLUSIVE on detector identity. Verified by packing a
691
+ tarball, installing it into a fresh project and running the published binary.
692
+ - **Doctor check `core-floor-declaration`.** The interlock that makes the core
693
+ floor safe to ship: a core rule may not declare `evidenceLevel: "E0"` in its own
694
+ metadata. `checkEvidenceHonesty` cannot catch it and the gap is structural —
695
+ it fails a rule for claiming _more_ than its `findingType` supports, and E0 is
696
+ the bottom of the ladder.
697
+ - **`tests/corpus/verdicts/proposed/`** — the staging area for adjudications
698
+ awaiting ratification. Committed verdicts are immutable
699
+ (`tests/corpus/verdicts/README.md:191`), so staging is the only reversible
700
+ form.
701
+
702
+ #### Changed
703
+
704
+ - **The core tier carries a floor** (ADR 0014). `capForTier` becomes
705
+ `policyForTier`; core gains `CORE_FLOOR = { evidenceLevel: "E1" }`. Until this,
706
+ core and extended were enforced _identically_ — `capForTier` returned `null`
707
+ for every non-quarantine tier, so reaching core changed the dedup tie-break,
708
+ the M3 ceiling and the census readout, and nothing a scan consumer could see.
709
+ `E1` and not `E2`: E2 is a claim that a finding is _proven_, which is what
710
+ runtime corroboration asserts per finding, not what a tier policy may.
711
+ Severity is deliberately untouched — one bounded field, changed on purpose,
712
+ two is a rewrite.
713
+ - **`SCORING_MODEL_VERSION` 1.0.0 → 2.0.0.** A core finding's deduction moves
714
+ from 0 (E0) to `floor(base/2)` (E1). `semver + ADR required` per the entry's
715
+ own policy. **`TRUST_MODEL_VERSION` stays at `1.0.0`** and ADR 0014 states why
716
+ rather than leaving it implicit: `deriveTrustLevel` _does_ consume
717
+ `evidenceLevel`, but the E→L mapping is unchanged — what moved is the
718
+ distribution of inputs, and a consumer reading `evidenceLevel` sees the same
719
+ fact. Bumping both would teach the version that "something moved".
720
+ - **An absent tier is no longer treated as core.** `policyForTier(undefined)`
721
+ returns an empty policy. `buildUniversalRules` resolves every _registry_ rule
722
+ through `effectiveTier` and adds a plugin only when it declares a tier, so a
723
+ missing entry means "a third-party rule that claimed nothing". The old
724
+ docstring said "undefined tier = core"; with a floor that would have raised
725
+ every plugin's E0 finding to E1 and started charging third-party detections —
726
+ the trust escalation TI-013 exists to prevent, introduced by the ADR written
727
+ to close a trust gap.
728
+ - **`npm run rules:quality:check` is a claim-integrity leaf.** It was declared
729
+ as the nightly gate `gate:rule-quality` and wired to nothing, so the gate ran
730
+ on a schedule that never reached it.
731
+ - **`check-fixture-quad` writes `docs/FIXTURE-QUAD.json` by default.** It wrote
732
+ only under `--write`, and nothing in this repository passes `--write`, so the
733
+ gate printed a census to a terminal nobody reads and left no artifact — a
734
+ backfill in progress was invisible in a diff. `--check` verifies the committed
735
+ artifact instead.
736
+ - **The measured/unmeasured census is 73/6, not 74/5.** Six documents asserted
737
+ 74/5. Note the trap: 74 is _also_ the number of rules declaring a tier
738
+ explicitly (79 − 5 omitted), so "74" was accidentally right about a different
739
+ population.
740
+
741
+ #### Fixed
742
+
743
+ - **`src/rules/registry-census.ts` answered `stale` and `tier` with local
744
+ predicates, and both were wrong.** `stale` asked `MEASURED_FP[rule.id] !==
745
+ undefined` about a map the generator pre-filters to revision-matching rows, so
746
+ it was false for every stale rule and `staleCount` was structurally 0. `tier`
747
+ re-derived the ladder as `declared ?? (measured ? core : extended)` — a third
748
+ copy of the tier resolution and the only one not reading `effectiveTier` — so
749
+ the five rules with no declared tier printed `core` here and `extended`
750
+ everywhere else. A census that disagrees with doctor is not a census.
751
+ - **`scripts/v6/check-rule-quality.ts` had the mirror defect.** Its `stale` loop
752
+ re-derived from the revision-filtered map, so a stale row was never in it and
753
+ `measuredAtStaleRevision` read **0** while two rules (`QA-PY-004`, `QA-PY-007`)
754
+ carried hand-classified verdicts taken against an earlier detector. It now
755
+ reads **2**, and the registry skeleton agrees with the census at 73/6.
756
+ - **`checkQuarantineOwnership`, `checkAntiCreep` and the disposition read all
757
+ resolved files from `process.cwd()`.** With `doctor` reachable with no target,
758
+ CWD is wherever the user happened to be — their project, not the checkout — so
759
+ the audit read the wrong CHANGELOG and the wrong baseline. All three now take
760
+ the checkout root as an explicit parameter.
761
+
762
+ #### Not changed, and why
763
+
764
+ - **Retired-rule fixture directories were not deleted.** The plan called for
765
+ removing `tests/corpus/positive-fixtures/QA-PW-145/` and
766
+ `tests/corpus/negative-fixtures/QA-PY-102/`. That contradicts a binding owner
767
+ ruling (`docs/RULE-LIFECYCLE.md:193-196`, 2026-09-08, E-1): _"fixture dirs
768
+ stay on disk (the doctor's fixture-integrity check discloses them instead of
769
+ failing)"_, implemented at `src/commands/doctor.ts:1136-1139`. The directories
770
+ and the exemption stand; the ruling is recorded, not overruled.
771
+ - **No core rule was promoted.** See below.
772
+ - **`claim-registry.json` claim 2 stays BLOCKED.** `candidateSha` is `null`, and
773
+ the checker refuses a non-BLOCKED claim on an unproven candidate, so the
774
+ artifact/digest/authority the plan asked for could not be recorded without
775
+ asserting independence nobody observed. The blocker is narrowed to what is
776
+ actually missing: the split exists, but no holdout _evaluation_ has been run
777
+ against it, and it is still produced by this repository.
778
+
779
+ ### Adjudication: 23 rows, 6 orphans, and the FIRST defect in the sampler
780
+
781
+ Every row was read at the corpus's pinned commit (`keycloak @ 421c23f5221d`,
782
+ `playwright-java @ c17b6060bd39`, `sveltejs-kit @ bf6833a639c9`), and each cited
783
+ line independently re-verified by enumerating every `@Ignore`/`@Disabled` /
784
+ `test.describe.serial` in the pinned file rather than trusting the ±6-line window.
785
+
786
+ **Six rows were orphans and were removed rather than judged** — and finding them
787
+ turned up a defect that made orphans inevitable:
788
+
789
+ > **`scripts/corpus-sample.ts` cloned the default branch and ignored `repo.ref`.**
790
+ > It ran `git clone --depth 1 <url>` — HEAD — then `rm -rf .git`, so nothing
791
+ > downstream could tell. Every verdict is adjudicated against the _pinned_ commit
792
+ > via `npm run corpus:verdict-context`. Those are two trees, and they drift.
793
+ >
794
+ > On keycloak that produced a repeating phantom: the pinned `JWETest.java` has
795
+ > exactly three `@Ignore`, on lines 73, 113 and 202, all three already adjudicated
796
+ > `TP` — and the sweep kept emitting rows citing **74, then 114, then 203**, the
797
+ > same three annotations cited one line late, each arriving as a fresh
798
+ > unclassified row that looked like new evidence.
799
+ >
800
+ > Four of those were caught by hand during the first adjudication pass. That was
801
+ > luck, not a control: a row whose drifted line happened to still land on a
802
+ > trigger would have been judged against source the verdict claims to describe and
803
+ > does not. `cloneRepo` now fetches `repo.ref` the way `tests/corpus/audit.ts`
804
+ > always did, **verifies** the checked-out SHA and hard-stops on a mismatch, and a
805
+ > cached clone is verified rather than trusted by existence.
806
+
807
+ Retracting a row also proved non-durable: `corpus-sample.ts` de-dupes on the
808
+ `ruleId|file|line` keys present in a `.jsonl`, so the instant an orphan was
809
+ removed the next sweep re-found and re-appended it. `apply-verdicts` now writes
810
+ **tombstones** to `tests/corpus/verdicts/retracted.jsonl` and the sampler reads
811
+ them alongside the verdicts — because an orphan rule that regenerates its own
812
+ evidence is not an orphan rule. The generator and its spec then needed the same
813
+ exclusion, since a tombstone has no `file`/`line` and parses as a verdict with
814
+ `undefined` fields.
815
+
816
+ **The two rules that earned core, and how:**
817
+
818
+ | Rule | n | ciHigh | the last row |
819
+ | ----------- | ----------: | -----------------: | -------------------------------------------------------------------------------------------- |
820
+ | `QA-PW-117` | 24 → **35** | 13.80% → **9.89%** | sveltejs-kit `test.describe.serial('Errors')` — six tests, each its own URL, no shared state |
821
+ | `QA-JV-101` | 23 → **35** | 14.31% → **9.89%** | keycloak `@Disabled("Only for PUT/POST")` on an **empty-bodied** override |
822
+
823
+ Both cleared by the same arithmetic — `z²/(n+z²)` ≤ 10% first holds at n=35 — and
824
+ both were funded by a corpus-wide `--core-candidates` sweep rather than by any
825
+ global cap raise. The `QA-JV-101` row is the purest instance of the rule's
826
+ diagnosis in the whole corpus: a disabled test with **no assertions** cannot hide
827
+ a defect, because it could not have detected one.
828
+
829
+ Neither sweep was unbounded: `CORE_CANDIDATE_CAP` stayed at 35 and the cap was not
830
+ raised to chase a number. `QA-JV-101`'s file carries two further
831
+ `@Disabled("Only for PUT/POST")` overrides at `:107` and `:113` that remain
832
+ **unfunded and unclassified**, because at n=35 the budget is zero. That is the cap
833
+ doing its job, not an oversight.
834
+
835
+ A third core rule now needs no hand record: `conformity`'s `CLAIM_OWNED` accepts a
836
+ claim the measurement derives independently, which is what `checkRegistry` and
837
+ `declaredCoreWithoutEvidence` already did. `QA-JV-101`'s entry in the 6.0 demotion
838
+ table also carried a justification describing a _different_ rule's premise
839
+ ("static mutable shared across tests"); it is gone with the entry, and the
840
+ remaining eighteen are unaudited — named here as a finding, not fixed.
841
+
842
+ ### Adjudication of the 23 sampled rows — staged, then applied
843
+
844
+ Every row was read at the corpus's pinned commit (`keycloak @ 421c23f5221d`),
845
+ following the convention the already-committed rows in the same file establish.
846
+ **19 rows are adjudicable; 4 are orphaned and are removed rather than judged.**
847
+
848
+ Three of the orphans are `QA-JV-101` rows citing `JWETest.java:74`, `:114` and
849
+ `:203`, where the `@Ignore` is on line 73/113/202 — the same annotations already
850
+ adjudicated `TP` one line up. A verdict on any of them would have counted one
851
+ annotation twice and moved a rule's `n` on the strength of a finding that is not
852
+ at that line. The fourth is `QA-PW-117` citing `details.spec.ts:20`, a
853
+ `const clientId = …` line with no `test.describe.serial` on or beside it; the
854
+ serial block at `:16` is already adjudicated.
855
+
856
+ ### Added: a Conformity Monkey for the rule registry
857
+
858
+ Netflix's Simian Army does not decide what a good cloud is. It applies a set of
859
+ **declared** rules to every resource, marks the ones that do not conform, records
860
+ which rule broke on which resource, and notifies the owner. Three properties make
861
+ it work, and this gate reproduces all three:
862
+
863
+ - the human is **not in the detection loop** — a machine decides, every run, for
864
+ every rule;
865
+ - the human **is in the response loop** — a nonconformity is fixed, or explicitly
866
+ opted out, and the opt-out is recorded;
867
+ - **`leashed`** runs every check, writes the report and sends nothing. That is
868
+ the mode a new check ships in; arming is a separate, deliberate act.
869
+
870
+ `npm run conformity` (`scripts/check-conformity.ts`) computes four checks per
871
+ rule, all decidable by the tree — **no judgement, and therefore no queue**:
872
+
873
+ | Check | Question |
874
+ | ------------------ | ---------------------------------------------------------------------- |
875
+ | `MEASURED` | does a current measurement exist? |
876
+ | `DETECTOR_CURRENT` | was it taken at the detector revision that produced the finding? |
877
+ | `CLAIM_OWNED` | does a core/quarantine claim carry an owner and an unexpired date? |
878
+ | `QUAD_COMPLETE` | do all four fixture legs exist? (RECALL and PRECISION are _generated_) |
879
+
880
+ **What it found: 17 of 79 rules are clean.** 62 fail at least one check, and the
881
+ sharpest one is about a field in this repository's own registry:
882
+
883
+ > `quarantinePromotion` carries the docstring "a rule in quarantine WITHOUT one is
884
+ > a rule whose quarantine is permanent by default, which is the outcome the tier
885
+ > exists to avoid" — and **0 of 34** quarantined rules have one.
886
+
887
+ Also: 2 rules carry a false-positive rate measured against an **earlier detector
888
+ revision**, displayed everywhere as if current; 6 have never been measured at all;
889
+ 21 have an incomplete quad; 39 have no MUST-FIRE or MUST-NOT-FIRE fixture.
890
+
891
+ ### Added: the opt-out registry as a ratchet, not a gate that is red forever
892
+
893
+ `docs/conformity-opt-outs.json` records every known nonconformity with a named
894
+ owner, a reason a reader can check, and **a date it lapses**. The gate fails on a
895
+ nonconformity with no entry, on an entry whose date has passed, and on an entry
896
+ for a rule that now conforms — so the set can only shrink, and known debt decays
897
+ on a schedule instead of persisting because nobody re-looked at it.
898
+
899
+ This is why it is a ratchet and not a plain gate. A gate that went red on all 62
900
+ rules on day one would stay red and read as noise — which is how raising
901
+ `MAX_SAMPLES_PER_RULE` produced **1,121 unadjudicated rows nobody looked at**.
902
+ Adding an opt-out is the only way to make the gate green for a known defect, and
903
+ an added entry is a **smaller diff than the evidence that would retire it**. That
904
+ asymmetry is the enforcement.
905
+
906
+ Expiry is the engine: 34 entries lapse **2026-11-01** and 28 lapse **2026-12-01**.
907
+ Nothing in the corpus opinion-measurement chain does, which is why the 23 pending
908
+ adjudications were debt with no date.
909
+
910
+ ### Why this does not replace the corpus false-positive rate
911
+
912
+ That measurement asks a human "is this code legitimate?", which is the one
913
+ question no tree can answer. Deriving it from the detector's own declaration
914
+ would be circular — the detector fired, so the detector is right, so every rule's
915
+ rate is zero — and that is the shape GAP-M26-002 records: "a 429-row ledger with
916
+ zero judgement got certified against its own output". This gate checks what the
917
+ tree CAN answer; the opinion stays where it was.
918
+
919
+ ### Fixed: an unreachable state, found by the gate that shipped it
920
+
921
+ The first version of `src/rules/conformity.ts` modelled "unmeasured" as a third
922
+ state beside CONFORMING and NONCONFORMING. The gate printed `unproven: 0` on its
923
+ first run — the state was **unreachable**, because every unmeasured rule here also
924
+ fails something else and so read NONCONFORMING. A law that can never fire is not a
925
+ law, and `registry-ratchet.spec.ts` already says that about its own floor.
926
+ `unmeasured` is now an orthogonal flag beside the state, and reports 6.
927
+
928
+ `--opt-outs=`/`--report=`/`--today=` exist because the tests need them, and each
929
+ one is a place the gate could have been right for the wrong reason: the first
930
+ fixture attempt copied the module graph behind `src/rules/index.ts` into a temp
931
+ tree and died in the ESM resolver before reading a single rule.
932
+
933
+ **Verified by planting each defect in the real registry before writing any of
934
+ this**: a lapsed date reported 34 gaps, a deleted entry reported one UNRECORDED,
935
+ and an entry added to a conforming rule reported one STALE. Each reverted to
936
+ `gaps: 0`. 20 arms in `tests/contract/conformity-gate.spec.ts` keep that.
937
+
938
+ One new npm script (`conformity`) and one new leaf in `gates:claim-integrity`.
939
+ `entry-points:check` still reports 9 commands against a ceiling of 12.
940
+
941
+ ### Changed: the unclassified-backlog assertion is a two-sided ratchet, not a zero
942
+
943
+ `tests/contract/verdict-context.spec.ts` asserted **zero** unclassified verdict
944
+ rows, and 23 pending rows make that false for as long as the work takes — so the
945
+ assertion would have been red for the entire classification window.
946
+
947
+ It now asserts the live backlog **equals the figure `unclassified-ceiling.json`
948
+ records**: greater means the backlog grew without review, less-but-not-zero means
949
+ classification is half done and the commit captures a state nobody agreed to.
950
+ `generate-fp-audit-table` already enforces the same ceiling per FILE, so a second
951
+ file's first row cannot hide inside a generous total — which is why the assertion
952
+ here is on the total and the generator's is on the parts.
953
+
954
+ The count this file asserted before that was 28, and it existed so the number
955
+ could not go quietly stale. It has been replaced by an assertion that cannot go
956
+ stale in either direction, which is what it was for.
957
+
958
+ ### Added: a fourth naming contract — a relative link that resolves to nothing
959
+
960
+ `check-cli-contract` now fails when a live markdown document links to a file that
961
+ does not exist. A link is a name, and it was the only kind of name none of the
962
+ three existing contracts could see: the removed-verb pass looks for verbs, the
963
+ reference pass looks for `npm run`, and a link to a document deleted two releases
964
+ ago looks like neither.
965
+
966
+ Its five live instances were all real:
967
+
968
+ | Document | Target | Now |
969
+ | --------------------------------- | ------------------------------ | ------------------------------- |
970
+ | `docs/PRODUCT-DECISIONS.md` (D-9) | `EXTERNAL-EVIDENCE-REQUEST.md` | reworded; points at the runbook |
971
+ | `docs/RELEASE-PATH-RUNBOOK.md` | `EXTERNAL-EVIDENCE-REQUEST.md` | reworded; names the retirement |
972
+ | `assets/brand/README.md` | `src/reporter/score-state.ts` | `presentation.ts` |
973
+ | `docs/design/BRAND-SYSTEM.md` | `src/reporter/score-state.ts` | `presentation.ts` |
974
+ | `docs/MAINTAINERS.md` | `CONTRIBUTING.md#governance` | `../CONTRIBUTING.md#governance` |
975
+
976
+ `isLiveSurface` is reused verbatim rather than reimplemented, so archives,
977
+ captured artefacts, frozen reports, preserved rule docs, `docs/adr/` and `tests/`
978
+ keep the exemptions they already had. A second exemption list would be a second
979
+ thing to drift from the first, and the drift shows up as a file protected from
980
+ one contract and not the other — exactly how
981
+ `docs/RELEASE-3.0.0-READINESS.md` came to be exempt from one and not the other.
982
+
983
+ Scope is markdown, inline links and images, path resolved and anchor not, on
984
+ measurements rather than guesses: a link-reference definition whose value is
985
+ itself a link (`[^pit]: PIT, [PIT](https://pitest.org)`) is a footnote, and
986
+ guessing produced **30 false positives in one research document** on the first
987
+ attempt. Fenced blocks and inline code spans are blanked because documentation
988
+ about markdown contains markdown. An extensionless target also resolves as
989
+ `<target>.md`, which is the site's clean-URL convention. 239 links are checked.
990
+
991
+ 15 arms in `tests/contract/cli-contract-dangling-links.spec.ts`, each inherited
992
+ exemption asserted as an exemption. One of them was written backwards: I assumed
993
+ the pass excluded `docs/adr/` and it does — which is why ADR 0012's dangling link
994
+ had to be found by hand. The arm now records that.
995
+
996
+ **Not fixed, and named:** `scripts/check-external-evidence.ts` still reads
997
+ `docs/EXTERNAL-EVIDENCE-REQUEST.md`, which the 6.0 M26-M50 retirement deleted, so
998
+ `npm run docs:external-evidence` exits 2 and `npm run certify:integrity` cannot
999
+ complete. Its `EXTERNAL_BOX_CEILING = 39` is a number about a file that no longer
1000
+ exists, and lowering it to the 18 rows the runbook actually carries would be a
1001
+ recorded number lowered to make a gate pass. D-9 now says so in the document
1002
+ instead of continuing to cite a deleted file as the authority for it.
1003
+
1004
+ ### Added: every workflow step's file argument resolves
1005
+
1006
+ `workflow:scripts` answers "does this NAME resolve". The other half of a step is
1007
+ "does this FILE resolve", and `.github/workflows/ci.yml` shipped a step naming
1008
+ `scripts/diff-detector-hashes.ts` — a file that has never existed. The capability
1009
+ was present throughout (`scripts/check-detector-hashes.ts` documents
1010
+ `--base <manifest>`) and the step has been corrected to name it, but a corrected
1011
+ line is not an invariant and nothing said so. **11 file arguments across 21
1012
+ workflows now resolve**, against the same `working-directory` resolution and the
1013
+ same `EXEMPT_FROM_SCRIPT_CHECK` list the name check uses.
1014
+
1015
+ Verified on the real tree before it was codified: plant the typo in `ci.yml`, the
1016
+ gate reports the file and the job, revert, the gate passes. 8 arms in
1017
+ `tests/contract/workflow-scripts.spec.ts` keep that.
1018
+
1019
+ Two scope decisions are load-bearing. A token counts as a file only when it is a
1020
+ bare relative path with a code extension after an interpreter — otherwise
1021
+ `./actionlint`, which the previous step downloads, would be reported missing on
1022
+ every run forever, and the fix would be an exemption. And `dist/` is a **build
1023
+ output**, not a repository file: `node dist/cli.mjs` is how six workflows invoke
1024
+ the CLI, so checking it would make this gate red on a clean checkout before
1025
+ `npm run build` — which is what `npm run certify:fast` and a bare
1026
+ `npm run workflow:scripts` see.
1027
+
1028
+ ### Fixed: the surface scan counted every document under `docs/` twice
1029
+
1030
+ `check-cli-contract` walked `"", "docs", "site", ".github/workflows"`, and
1031
+ `walk("")` is a walk of the repository root that already descends into the other
1032
+ three. Every file under `docs/` and `site/` was read twice and reported twice, so
1033
+ the printed `surfacesScanned` overstated the surface the gate checks.
1034
+
1035
+ ### Added: ADR 0013, and a kill criterion that is not only a command count
1036
+
1037
+ `docs/adr/0013-the-core-ceiling-is-decided.md` records what two independently
1038
+ chosen constants implied and nobody decided. `src/rules/measurement.ts` carried a
1039
+ docstring reading `!! UNREACHABLE AT THE CURRENT CORPUS CAP` — arithmetically
1040
+ correct, and a product decision written as a property of the code. It said _don't
1041
+ bother_ where it should have said _this is unresolved_.
1042
+
1043
+ The ADR holds the ceiling at 10%, keeps `MAX_SAMPLES_PER_RULE` at 20, and gives
1044
+ funded candidates the cap the arithmetic requires. It also records what it does
1045
+ **not** decide: re-homing the 39 external-evidence boxes, and anything a core rule
1046
+ might need beyond the quad and the interval.
1047
+
1048
+ `docs/ROADMAP.yaml`'s 6.0 kill criterion said `npm run check` must fit in 12
1049
+ commands. A command count is necessary and not sufficient — the tier system has
1050
+ to have a reachable top, or the cut only removed the words. It now says so, names
1051
+ the arithmetic, and points at the ADR.
1052
+
1053
+ ### Added: `--core-candidates`, a sampling mode that spends the budget where it can be spent
1054
+
1055
+ The core ceiling is 10% on the Wilson upper bound, so a rule earns core at
1056
+ **n ≥ 35 with zero observed false positives** (`samplesForZeroFp`). The corpus
1057
+ sampler capped at 20, so the tier was unreachable — and 6.0 found out the
1058
+ expensive way that raising the cap globally does not fix that: a raise to 40
1059
+ produced **1,121 unadjudicated rows across 42 rules** and
1060
+ `unclassified-ceiling.json` refused them, correctly.
1061
+
1062
+ The global raise was the wrong ALLOCATION, not the wrong idea. It spends 35+
1063
+ samples on rules that cannot use them — a rule already observed wrong, or one
1064
+ whose interval already excludes 10% from below — while the adjudication budget is
1065
+ finite and belongs to a person.
1066
+
1067
+ - `--core-candidates` emits rows **only** for a rule that observes zero false
1068
+ positives and whose `straddleDetail` reports that more clean samples would
1069
+ settle it, and only those rules may draw up to 35 instead of 20. The predicate
1070
+ is derived from the live registry on every run
1071
+ (`scripts/lib/core-candidates.ts`), so a rule crossing the line stops being
1072
+ sampled without an edit.
1073
+ - `--core-target <RULE-ID>` (repeatable) says which candidates this pass
1074
+ **funds**. The predicate is worth **29 rules** today; the nearest two are 11 and
1075
+ 12 rows from the ceiling. Conflating "who can earn a tier" with "who gets
1076
+ funded this pass" would move the ceiling by an amount nobody decided, so the
1077
+ two are separate and naming a rule that is not a candidate is an error rather
1078
+ than a silent no-op.
1079
+
1080
+ Filtering is on **emission**, not only on the cap. A cap-only change would still
1081
+ emit rows for every rule below n=20 — the 1,121-row failure with a different
1082
+ number on it.
1083
+
1084
+ No npm script, CLI verb, gate or rule was added.
1085
+
1086
+ ### Fixed: three defects the first real `--core-target` run found
1087
+
1088
+ - **The mode asked for twice the work it promised.** `tests/corpus/verdicts/unclassified-ceiling.json`
1089
+ moves by the number of rows a person is shown _before_ they are asked to
1090
+ classify anything, so that number has to be exact. The first run collected 35
1091
+ samples per rule and appended **46** rows against a projection of 23, because
1092
+ de-duplication is per verdict FILE and keycloak's file already held 19
1093
+ `QA-PW-117` and 10 `QA-JV-101` rows. The budget is now on **rows**, not
1094
+ samples — `cap − classified − already pending` — computed against the same key
1095
+ set the writer de-dupes with, by one shared function. Re-running the identical
1096
+ command now adds **0** rows, which is the property that stops a scoped pass
1097
+ from silently exceeding the ceiling twice.
1098
+ - **A scoped run deleted the review sheets it claimed to preserve.**
1099
+ `writeReviewSheets` carried a comment describing exactly this protection while
1100
+ the condition underneath did the opposite: `!sampledRules.has(id)` is true for
1101
+ every rule a `--repo` or `--core-candidates` run did not visit. A one-repo
1102
+ targeted pass deleted 8 committed sheets (`QA-PY-002/003/004/007/009/011/012`
1103
+ and one more) of pending owner work. Sheets are now removed only for rules that
1104
+ are no longer live; a rule that was visited and sampled nothing still removes
1105
+ its own sheet, because "the queue is empty" is a different statement from
1106
+ "nobody looked".
1107
+ - **The unclassified-ceiling gate named the wrong cause.** It throws
1108
+ `(23 > 31)` for a run whose total was 23 and whose ceiling was 31, because the
1109
+ trigger is usually a **per-file** regression — a new file's first row exceeds
1110
+ its allowance of 0 while the total stays inside the ceiling — and the message
1111
+ only ever compared the two totals. It now says which condition fired and names
1112
+ the files.
1113
+
1114
+ ### Changed: the unclassified ceiling, re-recorded at its reviewed value
1115
+
1116
+ `31 → 23`, and `byFile` drops three stale entries for files whose pending rows
1117
+ have since been adjudicated away (`pallets-click` 1, `pytest-dev-pytest` 27,
1118
+ `new-repo.jsonl` 3) in favour of a new allowance for `keycloak-keycloak.jsonl`.
1119
+
1120
+ The 23 rows are the scoped pass above: `QA-PW-117` +11 (n=24 → 35) and
1121
+ `QA-JV-101` +12 (n=23 → 35), awaiting human adjudication per
1122
+ `tests/corpus/verdicts/README.md`. **Not** 31 → 54: the live backlog had been
1123
+ classified down to 0 since the 31 was recorded, so this pass is 0 → 23, and a
1124
+ ceiling of 54 would have authorised 31 rows of pending work nobody asked for.
1125
+ Zero slack is the correct reading — the next pass must be acknowledged too.
1126
+
1127
+ `measured-fp.generated.ts`, `docs/FP-AUDIT.md` and `docs/COUNT-LOCK.md` are
1128
+ unchanged, which is the point: blank rows are dropped, not counted, so 23
1129
+ unadjudicated findings move no rate until a person classifies them.
1130
+
1131
+ ### Changed: both plan targets already had a complete fixture quad
1132
+
1133
+ `docs/CORE-READINESS.md` lists `QA-PW-117` and `QA-JV-101` as needing the
1134
+ four-leg quad. Measured against the tree, both already have all four:
1135
+ `MUST-FIRE` and `MUST-NOT-FIRE` fixtures on disk, 17 and 7 adjudicated `TP` rows
1136
+ and 12 and 8 adjudicated `TN` rows with zero `FP` in
1137
+ `tests/corpus/verdicts/quad/`. Nothing was authored, and the 10% ceiling still
1138
+ does not unblock 6.0.0 on its own — D-1 needs both the interval and the quad, and
1139
+ only the interval is missing.
1140
+
1141
+ ### Added: agent skills, and the wiring that makes them load
1142
+
1143
+ The repository had four agent skills under `.claude/skills/` and no way for
1144
+ Kilo to see them. Kilo reads that directory only when the user-level Claude
1145
+ Code Compatibility setting is on, so on a default install the committed skills
1146
+ were dead weight in that harness — and `.kilo/` cannot hold them instead,
1147
+ because `.gitignore` ignores it as machine-local agent state. `kilo.json` now
1148
+ carries a `skills.paths` entry pointing at `.claude/skills/`: one tracked copy,
1149
+ both harnesses, and the wiring visible in a diff. `docs/EXTERNAL-CONFIG.md`
1150
+ records what that file now declares.
1151
+
1152
+ Nine skills were added alongside the existing four, all written against this
1153
+ repository's own gates rather than imported wholesale:
1154
+
1155
+ - `rule-author` — the full rule lifecycle: ID allocation and non-reuse, the
1156
+ must-fire and must-not-fire fixture firewall, the four quad legs, registry
1157
+ registration, the gates a rule change invalidates, and the
1158
+ deprecation/retirement path from `docs/RULE-LIFECYCLE.md`. The 19-line Copilot
1159
+ agent in `.github/agents/` covered the first half of this and nothing else.
1160
+ - `change-gate` — a diff-to-checks map, because `npm run check` is ~8 minutes
1161
+ and the lever that moves it is running the row that covers the change. Names
1162
+ only scripts `package.json` defines, which
1163
+ `tests/contract/docs-consistency.spec.ts` greps every tracked `.md` for.
1164
+ - `surface-law` — law 0 as a procedure: what counts as the governed surface,
1165
+ the twelve-leaf ceiling, and the two legal ways to grow.
1166
+ - `evidence` — the agent safety contract, including the rule that
1167
+ `INCONCLUSIVE` is a result and never laundered into a pass.
1168
+ - `skill-author`, `verification-before-completion` — adapted from
1169
+ `obra/superpowers` (MIT), the parts that decide whether a skill gets opened
1170
+ and whether a claim gets evidence.
1171
+ - `prose-honesty` — adapted from `blader/humanizer` (MIT), fenced so it cannot
1172
+ touch a generated page, an asserted surface, or a registered claim.
1173
+ - `quarantine-remediation` — the 34 quarantined rules are capped to
1174
+ `severity: info` and `evidence: E0`, so they cannot fail a build; 34 of 79
1175
+ rules are in that state and seven measure 0.0 % FP. `doctor` reports that
1176
+ **none** carries a `quarantinePromotion`, so none has an owner, a review date
1177
+ or an exit condition — the mechanism exists on the `Rule` type and has never
1178
+ been applied. The skill covers the ladder, the Wilson-interval criterion that
1179
+ replaced the point estimate in 6.0, and the promote/rework/retire decision.
1180
+ - `skill-supply-chain` — the scan procedure for a skill before it is installed,
1181
+ since a skill is code that runs with the agent's permissions.
1182
+
1183
+ No npm script, CLI verb, gate or rule was added, so law 0's ratchet does not
1184
+ move.
1185
+
1186
+ ### Fixed: `check-cli-contract` and `docs-consistency` disagreed about one file
1187
+
1188
+ `npm run check` was red on `main`: `check-cli-contract` failed on
1189
+ `docs/RELEASE-3.0.0-READINESS.md`, which names `npm run m26:audit` in its
1190
+ command table. `scripts/release-verify.ts` records that script as removed in
1191
+ 6.0 with the M26 ledger, so the name was real when the report was written and
1192
+ the report is dated 2026-09-25.
1193
+
1194
+ `tests/contract/docs-consistency.spec.ts` already exempted exactly that file, by
1195
+ name, with the reasoning written out: a readiness report for a version that
1196
+ shipped is a record, not an instruction. Two gates catch the same class of
1197
+ dangling name, and the file was exempt from one that still tripped the other —
1198
+ an exemption made by accident rather than by decision. `check-cli-contract.mjs`
1199
+ now carries the same entry in a named `FROZEN_REPORTS` list, mirroring the
1200
+ existing `CAPTURED_ARTEFACTS` shape, and skipping it in both the removed-verb
1201
+ pass and the dangling-name pass. The list is by name, not by pattern, so it can
1202
+ be argued with per file.
1203
+
1204
+ `tests/contract/cli-contract-dangling-names.spec.ts` locks both halves, because
1205
+ an exemption nobody tests is a blind spot with a comment on it: a dangling name
1206
+ in a live surface still fails, the frozen report still passes, a live doc beside
1207
+ the frozen one still fails, and the real tree passes.
1208
+
1209
+ ### Decided against, on measurement: the 500-word skill budget
1210
+
1211
+ `skill-author` carried an upstream rule of "under 500 words". Eight of eight new
1212
+ skills broke it on the first pass, from 721 to 1222 words — which is the shape
1213
+ of a target nobody measured rather than a target anybody met. The figure was
1214
+ wrong for this class of skill: most of the excess is procedure that only runs
1215
+ once, and in `prose-honesty` a 22-row lookup catalogue that nobody reads to fix
1216
+ one sentence.
1217
+
1218
+ So the rule now sorts by shape — a procedure carries a word budget, a lookup
1219
+ table is always a sibling file — and the number is measured rather than
1220
+ guessed. Two lookup structures moved out: `prose-honesty`'s catalogue to
1221
+ `patterns.md` (body 126 → 85 lines), and `skill-author`'s failure-classification
1222
+ table plus its no-baseline-run fallback to `baseline-failure.md` (204 → 166
1223
+ lines). Both are cases the rule's own branch rule covers: a catalogue is
1224
+ scanned for one row, and the fallback is only read when there is no baseline to
1225
+ classify.
1226
+
1227
+ The ceiling lives in `budget.md` rather than in `skill-author`'s body, and that
1228
+ placement is the part worth keeping. A meta-skill that states its own budget
1229
+ counts toward it: writing "1250 words" into the body made the body 1251. Moving
1230
+ the figure one file over removes the self-reference, so the ceiling can be
1231
+ 1300 against a measured maximum of 1264 and stay checkable.
1232
+
1233
+ The record that the number moved, when, and against what measurement sits in
1234
+ `budget.md`. A ceiling that moves silently is the arithmetic of law 0's
1235
+ `baselineCore` edit: the arithmetic reads zero and the law reads as satisfied.
1236
+
1237
+ ### The baseline-failure step: a discoverability audit, and its limit
1238
+
1239
+ `skill-author` prescribes a RED step — watch an agent fail the task before
1240
+ writing the skill. That did not run; these skills were written from
1241
+ `docs/RULE-LIFECYCLE.md`, `CLAUDE.md` and the gate sources. The substitute that
1242
+ could run is a discoverability audit: for each load-bearing fact, is it
1243
+ reachable from the repository today?
1244
+
1245
+ | Fact | Reachable from |
1246
+ | -------------------------- | ---------------------------------------------------------------- |
1247
+ | a path→gates map | `docs/archive/` only — nothing live |
1248
+ | directory → rule-ID prefix | `src/rules/index.ts`; no live doc states the mapping |
1249
+ | the four quad legs | `src/v6/fixture-quad-probe.ts` and an archived plan |
1250
+ | rule IDs are never reused | `docs/RULE-LIFECYCLE.md`, `docs/ARCHITECTURE.md`, `src/types.ts` |
1251
+
1252
+ So `change-gate`'s content was nowhere to be found, the prefix table and the
1253
+ quad vocabulary were reachable only from source, and the ID non-reuse rule was
1254
+ already documented — that line is a guardrail kept for cost, not for novelty.
1255
+
1256
+ This proves the knowledge was not already available. It does not prove the
1257
+ skills work; only watching an agent use one answers that. `skill-author` now
1258
+ records the substitute and its limit, so the next author knows which of the two
1259
+ they actually ran.
1260
+
1261
+ ### Fixed: the quarantine report named a field that does not exist
1262
+
1263
+ `CorePromotion` declares five fields — `rationale`, `owner`, `grantedAt`,
1264
+ `expiresOn`, `evidenceRefs`. There is no exit-condition field, and `rg` finds
1265
+ the phrase nowhere in `src/`, `docs/` or `tests/` except in two places that
1266
+ were both telling the reader to go looking for one: the `quarantinePromotion`
1267
+ doc comment in `src/rules/rule.ts`, and the detail message
1268
+ `checkQuarantineOwnership` prints about the largest open item in the tree.
1269
+
1270
+ A report that describes a shape the contract does not define is the same
1271
+ defect class as a rule reporting a metric it never measured — the reader is
1272
+ sent to do work against a field that is not there. The message now names the
1273
+ three a backfilled record actually has to supply, and the expiry date is
1274
+ labelled as what it is, since it is the exit condition in substance.
1275
+
1276
+ `tests/contract/quarantine-record-honesty.spec.ts` locks it by parsing the
1277
+ field list out of the type rather than restating it, so it fails if the type
1278
+ gains a field the message omits and if the message ever names a phantom one.
1279
+ Verified by mutation: all three arms fail against the previous wording.
1280
+
1281
+ ### Verification
1282
+
1283
+ `npm run test` is 11,992 passing with zero failures across 493 files. `npm run
1284
+ lint`, `typecheck`, `check-version`, `claims:check`,
1285
+ `candidate:manifest:check`, `entry-points:check`, `config:consumers`,
1286
+ `scripts:reachable`, `gates:check`, `check-concepts`, `check-cli-contract`,
1287
+ `check-certification-surface`, `check-detector-hashes`, `check-fixture-quad`,
1288
+ `docs:provenance-drift`, `check-carve-manifest`, `qa-ir:parity` and
1289
+ `docs:roadmap:backlinks` all pass.
1290
+
1291
+ ### Corrected: what `npm run check` was actually red on
1292
+
1293
+ The previous version of this entry named a coverage threshold and blamed
1294
+ `src/commands/share.ts`. Both halves were wrong, and neither was checked:
1295
+
1296
+ - `frontier:contracts` is **not** a leaf of `check`. `package.json` has `check`
1297
+ as nine terms (`build`, `typecheck`, `lint`, `test:coverage:ci`,
1298
+ `check-version`, `gates:claim-integrity`, `docs:regen`, `self-scan`,
1299
+ `doctor`); `frontier:contracts` (`vitest run tests/contract/`) is declared in
1300
+ no chain at all, and `certify` invokes that command directly rather than
1301
+ through the name.
1302
+ - A bare `vitest run tests/contract/` collects no coverage — `--coverage` is
1303
+ what enables the provider — so `perFile: true` could not be the mechanism
1304
+ either.
1305
+
1306
+ What was actually red was two generated artifacts that had not been refreshed:
1307
+
1308
+ - `docs/BLAST-RADIUS-AUDIT.md` recorded `src/commands` at 11,988 lines against
1309
+ a live 11,990, because the `GATE_COMMAND` de-duplication in `share.ts`
1310
+ removed two lines from a file the audit counts.
1311
+ - `candidate-trust-manifest.json` failed `workingTreeSha256 drift`, which is
1312
+ what it is for: it hashes the tree it was stamped against.
1313
+
1314
+ Both are the same failure mode as the exemption added above — an artifact that
1315
+ describes a state nobody refreshed — and `scripts/refresh-generated.mjs` is the
1316
+ tool that fixes them, in the one order that converges.
1317
+
1318
+ ### Fixed: the convergence tool could not converge
1319
+
1320
+ `scripts/refresh-generated.mjs` ran `docs:translations:sync` and
1321
+ `docs:translations`, both deleted by the v6 positioning carve (#697) along with
1322
+ the 20 translated READMEs, and formatted `README.*.md`, a glob that has matched
1323
+ nothing since those READMEs went. Prettier exits 2 on a pattern with no files,
1324
+ so the refresh reported `FAIL` on three entries and exited non-zero: the tool
1325
+ whose entire job is "make the tree agree with its generators" could not be run
1326
+ to completion.
1327
+
1328
+ Nothing caught it. `npm run check` does not run this file,
1329
+ `scripts:reachable` answers the opposite question — is a script reachable FROM
1330
+ a caller, not does every name it invokes resolve — and `check-cli-contract`'s
1331
+ dangling-name rule reads `.md`, workflows, the Action and the reviewer config,
1332
+ not `scripts/**`. Two gates, two different blind spots, one script that could
1333
+ not do the one thing it exists to do.
1334
+
1335
+ The dead entries are removed and the glob is `README.md`. The `npm run <name>`
1336
+ inside a `.mjs` gap is **not** closed here: the shape a general fix would need
1337
+ was measured, and the placeholder convention this repository already uses —
1338
+ `scripts/check-ci-local-parity.mjs` writes `npm run X` chains and `npm run Y`
1339
+ prose — is indistinguishable from an invocation by regex alone. Recorded rather
1340
+ than shipped as a gate that reports its own source.
1341
+
1342
+ ### Decided against, on measurement: the script-layer cut
1343
+
1344
+ `npm run check` is ~8 minutes, and the test suite is 79 % of it. All 38 gates
1345
+ together cost 100 seconds, so cutting the gate surface from 39 to 12 — the plan's
1346
+ `~60 script files / ~45 npm scripts` target — would save under 15 % of the wait,
1347
+ while deleting checks that caught six real defects during 6.0. The numbers, the
1348
+ arithmetic and the alternative levers are recorded in the `6.0.0-rc.1` entry
1349
+ below and in `CLAUDE.md` law 0, so the question no longer has to be re-argued
1350
+ from file counts.
1351
+
1352
+ The verification trust engine, measured. A tier you can only reach with
1353
+ evidence, a promotion that cannot land unreviewed, and generated documentation
1354
+ that cannot quietly go stale behind the code. Carries the 6.0.0-rc.1 line below.
1355
+
1356
+ ### Measured twice: the core promotions, and what they rest on
1357
+
1358
+ Two rules hold `core`, and this release is where the arithmetic behind them was
1359
+ checked rather than asserted. Both sit at **n=35, zero observed false positives,
1360
+ Wilson upper bound 9.89%** against a 10% ceiling — reproduced by hand from
1361
+ `src/lib/wilson.ts` before either was believed.
1362
+
1363
+ `QA-JV-101` earned it in two batches, and the sequence matters:
1364
+ **32 (10.72%) → 34 (10.15%) → 35 (9.89%)**. The first batch's verdict file reads
1365
+ as a refusal on its own — "STILL SHORT, NOT promoted" — and the second, from a
1366
+ sweep run after `corpus-sample.ts` was corrected to clone the pinned ref, is
1367
+ what cleared the ceiling. Read one batch and you conclude the promotion was
1368
+ fabricated; read both and it is earned. Both verdict files are cited in the
1369
+ certification record for exactly that reason.
1370
+
1371
+ The sampler fix that made batch 2 trustworthy works _against_ inflation: it
1372
+ clones `repo.ref` instead of the default branch and makes retractions stick, so
1373
+ an orphan row cannot resurrect as fresh evidence. Three false rows (JWETest
1374
+ 74/114/203) were retracted rather than judged.
1375
+
1376
+ ### The tree told a different story than the code
1377
+
1378
+ Both promotions landed in the registry, the tier evidence and this file while
1379
+ their generated documentation went on reporting `extended` at n=23 and n=24.
1380
+ Nothing failed, because nothing asked.
1381
+
1382
+ - **A rule count nobody can check is a claim, so generate it.** `README.md` said
1383
+ "`core` is empty" for a whole release after two rules had earned it.
1384
+ `measurementBlock()` now returns `core` and `docs:counts` stamps it into the
1385
+ census surfaces, so `tests/contract/census-drift.spec.ts` fails if prose and
1386
+ registry disagree. It counts UNMEASURED core rules deliberately:
1387
+ `MAX_UNMEASURED_CORE` is 0, so a registry in that state cannot ship, and a
1388
+ census filtered on `measured` would report a clean `core: 0` at the exact
1389
+ moment the law broke.
1390
+ - **`docs:staleness` — one staleness assertion, runnable locally.** Four
1391
+ workflows each asserted it by hand and three could not see an untracked file:
1392
+ with clean tracked `docs/` and one new untracked file in it,
1393
+ `git diff --exit-code -- docs/` exits **0**. That is how a new rule's docs page
1394
+ shipped silently. The replacement reads `git status`, is read-only by
1395
+ construction so it can never cost a tree its adjudication records, and is
1396
+ deliberately absent from `npm run check` — asserting after regenerating is
1397
+ checking the generator against itself.
1398
+ - **`docs/MEASUREMENT-CLOSEOUT.md` corrected to 73/6** (it read 74/5), gained
1399
+ the missing `QA-PY-004` row, and now separates **stale** from unmeasured:
1400
+ `QA-PY-004` (revision 4) and `QA-PY-007` (revision 5) have verdict sets that no
1401
+ longer describe the shipped detector and need re-measurement at the current
1402
+ revision — a different job from classifying new rows.
1403
+
1404
+ ### A promotion can no longer land with no artifact
1405
+
1406
+ - **`docs/CORE-CERTIFICATION.json` — a record is required to hold `core`,** and
1407
+ the record expires with its evidence. The measurement block is compared field
1408
+ by field against the live `MEASURED_FP` row on every run: move the detector
1409
+ revision, change n, or introduce an observed false positive and the gate fails
1410
+ until someone re-certifies and writes down why. Every cited path must exist,
1411
+ and cited corpus rows must sum to exactly `n` — self-derived quad verdicts are
1412
+ excluded, which is why both records sum to exactly 35 (29+2+4, 20+10+2+3).
1413
+ - **QA-JV-101's record states its known weakness rather than omitting it.** Its
1414
+ must-not-fire fixture is a hand-written different program, not the must-fire
1415
+ fixture with `@Disabled` neutralised, so it shows the detector is directional
1416
+ and does **not** show the predicate is sensitive to the annotation.
1417
+ - **No new gate and no new npm script.** Both are arms of existing gates —
1418
+ `rules:promotion:check` and `check-fixture-quad` — because Law 0 charges for
1419
+ surface growth.
1420
+
1421
+ ### The detector set is wired. It is not yet shown to have teeth.
1422
+
1423
+ `docs/PRECISION-RATCHET.json` records 32 rules as holding a PRECISION leg and
1424
+ calls itself "a wiring proof, not an accuracy proof, and a self-derived one".
1425
+ That caveat now has a number, and it is not comfortable.
1426
+
1427
+ A must-not-fire fixture is evidence that a rule is _sensitive_ only when it is
1428
+ the must-fire fixture with the defect neutralised. When it is a different program
1429
+ that merely does not trigger, the pair shows the detector is directional and
1430
+ nothing about the predicate. The quad counts presence, so both read alike.
1431
+
1432
+ **Of the 40 live rules carrying both legs, 2 hold a negative fixture that is
1433
+ plausibly the positive fixture with the defect removed** (`QA-PW-144`, one changed
1434
+ line of nine; `QA-PW-116`, one of seven). The other **38** are a different
1435
+ program — the next nearest diverges by 0.55, the furthest by more lines than
1436
+ either file contains. Leashed at the true floor of 2, so it fails on a rule
1437
+ _regressing_ out of that set and not on the 38 that were always like this;
1438
+ `--update` only ever raises the floor, so a regression cannot be absorbed by
1439
+ re-running a command. Closing the gap needs a per-rule recipe for what the defect
1440
+ _is_, and that work is named rather than assumed away.
1441
+
1442
+ ### What a consumer may rely on
1443
+
1444
+ - **`docs/ENGINE-FREEZE.md`** records the compatibility promise and points at
1445
+ `CONTRACT_REGISTRY` rather than copying version numbers. `scoringModelVersion`
1446
+ 2.0.0 is a _semantic_ change (ADR 0014's evidence floor), so a consumer
1447
+ comparing scores across it is comparing different things; and
1448
+ `frameworkSupportMatrixVersion` is written by the engine and read by nothing
1449
+ but a test — a version stamp on a fact, not a negotiated protocol.
1450
+ - **The delta loop is in the Quickstart.** Steps 1–3 report the _state_ of your
1451
+ tests; `mjolnir ci verify` reports the _change_, the only question a reviewer
1452
+ has about a diff. `--save-baseline` writes `.mjolnir/baseline.json`; `ci
1453
+ verify` reports RESOLVED (of which only `VERIFIED-RESOLVED` is a fix claim) and
1454
+ NEW, under a `0`/`1`/`2` exit contract. Agents were taught this at
1455
+ `src/commands/install-agents.ts:58`; humans had no way to find it.
1456
+ - **Governing prose that had gone stale behind the code** was corrected:
1457
+ `rule-author` and `surface-law` both asserted the core tier was empty, and the
1458
+ archived `RULE_CERTIFICATION.md` now carries a SUPERSEDED banner marking its
1459
+ `core` column a fossil of the deleted `registry-census` predicate.
1460
+
1461
+ ### Not claimed
1462
+
1463
+ The five `REMOTE_PROVEN` requirements in `candidate-trust-manifest.json` —
1464
+ protected holdout, real-world repositories, platform matrix, consumer install,
1465
+ remote workflow — are **not** satisfied, and `releaseAuthorizationState` remains
1466
+ `NOT_AUTHORIZED`. `npm run candidate:readiness` reports exactly what is missing.
1467
+ That gate exists so "measured" cannot be upgraded by assertion.
1468
+
1469
+ ## [6.0.0-rc.1] — 2026-10-02
1470
+
1471
+ Theme: **two words and one number**. A first run reads `GATE` / `WARN` and
1472
+ nothing else, and it names the command that makes the scan a gate. Everything
1473
+ below is either a subtraction or a correction of a surface that stated the
1474
+ opposite of its own law.
1475
+
1476
+ ### Fixed: three README claims and the gate that let them ship
1477
+
1478
+ `README.md` said the published line was `3.0.0` and the working tree
1479
+ `4.0.0-rc.1` while `package.json` was `5.1.0`; its Status heading read
1480
+ `**Version 3.0.0.**`; and its exit-code table documented exit `2` as
1481
+ "Partial scan… **Never blocks**" — the exact wording
1482
+ [`docs/VERSIONING.md`](docs/VERSIONING.md) withdrew when it made exit `2` a
1483
+ failure.
1484
+
1485
+ The exit-code one had a gate: `tests/contract/exit-code-contract-docs.spec.ts`
1486
+ matched `/partial[^.\n|]{0,40}never blocks/`. The character class excluded
1487
+ `.`, and the README's cell reads as two sentences —
1488
+ `Partial scan (time budget hit, unreadable files). Never blocks.` — so the one
1489
+ gate written to catch that sentence could not match it. The class now stops at
1490
+ the table cell (`|`) and the newline, which is the unit actually being read, and
1491
+ no longer at a full stop: a period ends a sentence, not a claim. The gate also
1492
+ gained a positive arm — a surface that publishes an exit table must now _say_
1493
+ that `2` fails, rather than being silent about it and reading as a pass.
1494
+
1495
+ The version claims had no gate at all, because a version claim is not law prose
1496
+ and a docs-consistency gate that checks law prose cannot see one.
1497
+ `tests/contract/docs-consistency.spec.ts` now pins the README's release-status
1498
+ paragraph to `package.json`'s `publishedStable`/`version` and refuses a Status
1499
+ heading that names a line which is neither.
1500
+
1501
+ ### Fixed: the Action told consumers an unfinished scan was green
1502
+
1503
+ `fail-on-partial` defaulted to `false`, and its description said so in as many
1504
+ words — "partial scans produce a warning, not a failure" — on the Marketplace
1505
+ surface, while `docs/VERSIONING.md` said there is no flag that turns an
1506
+ incomplete run into exit `0`. The default is now `true`; the advisory opt-out
1507
+ still exists, is still one input away, and now warns that it is reporting green
1508
+ on a scan that did not finish. The generated `mjolnir ci install` gate already
1509
+ failed on `partial: true`; only its comment had rotted.
1510
+
1511
+ **Breaking:** a pipeline relying on the old default will now fail on an
1512
+ inconclusive scan. That is the point of the law, and 6.0 is the major where it
1513
+ belongs.
1514
+
1515
+ ### Changed: the default report reads GATE / WARN and nothing else
1516
+
1517
+ The `core` / `extended` / `quarantine` tiers are now internal, as are the `E0–E2`
1518
+ evidence rungs and the `L0–L5` trust rungs. They are all still computed, still in
1519
+ `--json`, and still in `mjolnir explain` — they are the differentiator, and a
1520
+ first run is not the reader they are for. `--verbose` still prints the evidence
1521
+ descriptor beside each finding.
1522
+
1523
+ `GATE` means exactly what the exit code means: that finding counts against the
1524
+ configured gate. The label and the exit code come from one function
1525
+ (`isAtGate` in `src/claim-evidence.ts`) rather than from two derivations of
1526
+ "which findings gate" in two files, which is how a report ends up counting three
1527
+ GATE findings on a run that exits `1` for two.
1528
+
1529
+ The verdict block lost the rung line and gained the number — `3 GATE · 4 WARN` —
1530
+ and NEXT ACTION now names the gate command (`mjolnir ci install`) on every run,
1531
+ which removed the second, differently-worded "New here? …" trailer that used to
1532
+ print it. First screen: the verdict, the count, the command.
1533
+
1534
+ `defensibleTier` / `effectiveTier` and the Wilson interval floor are untouched.
1535
+ 5.1.0's arithmetic is load-bearing and is not re-litigated here; the tier
1536
+ simplification is a presentation decision on top of it.
1537
+
1538
+ ### Added: `gate` on every finding in the JSON report
1539
+
1540
+ Additive within `schemaVersion: 1`, as `docs/VERSIONING.md` requires:
1541
+ `tier`, `evidenceLevel` and `trustLevel` are all still there, and this adds the
1542
+ boolean the exit code is computed from, so a consumer no longer has to
1543
+ re-derive the gate level to know what blocks.
1544
+
1545
+ ### Added: `mjolnir share` — React Doctor's proof loop, without its server
1546
+
1547
+ One scan, one self-contained HTML file, no upload and no share id. It carries
1548
+ the score, every `GATE`/`WARN` finding with `file:line`, three commands that
1549
+ move the number, and each fired rule's measured FP rate with its sample size —
1550
+ including "unmeasured", never a zero.
1551
+
1552
+ It is NOT a mode of `scan --format`, because the artifact's contract is
1553
+ narrower than the bound Trust Artifact's. That artifact names the repo, the
1554
+ commit, the scanId and the rule(rev) inventory precisely so a consumer can tell
1555
+ which run produced it — and those are exactly the facts that must not travel
1556
+ when a file is handed to a third party. Two renderers, two contracts, one
1557
+ canonical scan result underneath.
1558
+
1559
+ `tests/contract/share-artifact.spec.ts` is new and asserts four properties:
1560
+ byte-identical for the same scan, no absolute path / drive letter / home
1561
+ directory / remote / commit SHA, no code (a rule message quoting source is
1562
+ escaped, not embedded) and no network primitive of any kind. The zero-network
1563
+ law itself (`privacy-network-isolation.spec.ts`) is unmodified; this is the
1564
+ output half of it, because that spec can only see source.
1565
+
1566
+ ### Removed: the M26–M50 program, and what it was keeping alive
1567
+
1568
+ `docs/ROADMAP.yaml` was 479 lines: 25 trains, ~160 workstream ids, every owner
1569
+ `UNASSIGNED`, and a status of `blocked-pending-live-ledger`. It is now a
1570
+ five-row table — the 6.0–10.0 ladder, one promise, one number and one kill
1571
+ criterion per row — and the old program is in
1572
+ `docs/archive/ROADMAP-M26-M50.yaml` with a header that says, in those words,
1573
+ **text preserved, claims not**. `scripts/roadmap/validate.ts` was rewritten for
1574
+ the new shape: it no longer reads anything, and it now enforces what the
1575
+ retired file could not — one version in progress at a time, and a kill
1576
+ criterion on every row. The archive has to resolve and be tracked by git, or
1577
+ the retirement is a deletion.
1578
+
1579
+ Deleted with it: `docs/M26-GAP-LEDGER.jsonl`, `docs/M26-SUPPORT-MATRIX.json`,
1580
+ `docs/M26-ISSUE-DISPOSITIONS.jsonl`, `docs/M26-GITHUB-SNAPSHOT.json`,
1581
+ `docs/M26-EXTERNAL-VALIDATION.json`, `docs/EXTERNAL-EVIDENCE-REQUEST.md`,
1582
+ `schemas/m26/`, `src/ledger/` (2,738 lines), `scripts/sync-m26-github.mjs`,
1583
+ `scripts/revalidate-gaps.mjs`, `scripts/check-m26-ledgers.ts`,
1584
+ `scripts/v6/check-issue-disposition.ts`, `scripts/check-disposition-source.mjs`,
1585
+ `scripts/v6/reconcile-archive.ts`, `tests/ledger/`,
1586
+ `tests/contract/m26-support-matrix.spec.ts`,
1587
+ `tests/contract/ledger-shrink-guard.spec.ts`, and nine npm scripts.
1588
+
1589
+ The four ledgers were transcriptions of a plan document, and the v6 inventory
1590
+ published six numbers copied out of them under `counts:` — where a reader took
1591
+ them for measurements of this repository. Those fields are gone rather than
1592
+ zeroed: `0` would have claimed there are no gaps, which is the one thing a
1593
+ retired ledger must not say. The count that replaced one of them,
1594
+ `counts.ciProviders`, is now measured from the code
1595
+ (`CI_PROVIDERS` in `src/commands/ci-adapter.ts`) instead of counting support
1596
+ matrix cells whose id contained the string `CI`.
1597
+
1598
+ Six controls went with their subjects and each is named in the spec that used
1599
+ to hold it, because "the thing this asserted is gone" is itself a fact a reader
1600
+ needs: the archive gate (TI-022, TI-023), the `release decision`'s `m26Status`
1601
+ arm, the support-matrix-backed domain capabilities in the capability registry,
1602
+ and `orphan.yml`'s exit-code table (kept, repointed at the run header).
1603
+
1604
+ Three ideas from the program survive as gates on live code rather than as rows:
1605
+ the Wilson interval floor (`src/rules/tier-evidence.ts`), the adversarial /
1606
+ boundary fixture quad (`scripts/v6/check-fixture-quad.ts`), and the
1607
+ external-evidence discipline, which becomes the 10.0 benchmark.
1608
+
1609
+ ### Added: two entry points, and a gate that counts them
1610
+
1611
+ `npm run check` is what a pull request runs — twelve commands. `npm run
1612
+ certify` is the release path, and it begins with `check`, so it is a superset by
1613
+ construction rather than by comparison.
1614
+
1615
+ `scripts/check-entry-points.mjs` enforces the number, that neither entry point
1616
+ names a script `package.json` does not define, that neither runs a write-mode
1617
+ script, and that `certify` reaches everything `check` does.
1618
+
1619
+ **Known open, deliberately:** the twelve commands expand to **39** leaf scripts.
1620
+ `check-version`, `gates:claim-integrity` and `docs:regen` are themselves
1621
+ chains, and collapsing them to their leaves is the plan's larger promise, not
1622
+ this release's. The gate prints both numbers on purpose — the chain's own terms
1623
+ (12, the ceiling) and the transitive leaf count (39) — because the cheap way to
1624
+ satisfy a budget is to count the one that is easy to count, and an alias added
1625
+ to make the leaf count smaller would make the chain shorter and the wait longer.
1626
+ `tests/contract/entry-points-budget.spec.ts` is the negative arm: it proves the
1627
+ gate rejects an entry point that reaches a write-mode script, which is the
1628
+ property trust-invariant TI-022 used to be proved against the deleted archive
1629
+ reconciler.
1630
+
1631
+ ### Fixed: a verdict that claimed the analysis stopped when it did not
1632
+
1633
+ `classifyTrust` treated any confidence ceiling, and any entry in
1634
+ `analysisStatus.reasons`, as proof that the scan was incomplete. Scanning `src`
1635
+ — the PR tier's own self-scan target — examined every file, found nothing, and
1636
+ printed "**The analysis did not finish** — it proves nothing about the surface
1637
+ it did not reach", because the framework was undetectable (a ceiling) and 34
1638
+ rules sat in the WARN tier (a `coverage:quarantine:34` reason).
1639
+
1640
+ A false "incomplete" is not the safe direction: it trains the reader to ignore
1641
+ the word, which is the one state where it was true. Incompleteness is now the
1642
+ work-not-done list, named field by field — `partial`, truncated discovery, rule
1643
+ evaluation or `truncationReasons`, skipped files, parse fallbacks, crashed
1644
+ rules — and a ceiling bounds what a finished run can conclude without claiming
1645
+ it stopped. It also no longer licenses a clean result
1646
+ (`licensesClean`), which is what a capped run has not earned.
1647
+
1648
+ `trustReasons` had a third, slightly different spelling of the same question,
1649
+ which is how a report could print a confident headline and say the opposite one
1650
+ line below it. It now calls the same exported function.
1651
+
1652
+ `npm run self-scan` also scanned `.` where the declared gate scans `src`; it now
1653
+ scans `src`, and both the report and the exit code are honest about what was
1654
+ examined.
1655
+
1656
+ ### Fixed: three gate-set defects, and the reason they survived
1657
+
1658
+ `gates/nightly.json` declared both `qa-ir-parity` and `gate:qa-ir-parity` (the
1659
+ same command) and both `scripts-unimported` and
1660
+ `gate:unimported-modules` (the same command): four gate ids, two checks, each
1661
+ run twice a night. The duplicate-**id** validation could not see it, because
1662
+ the ids differ. `scripts/check-gate-tiers.mjs` now also rejects two ids
1663
+ running the same command, and the same pass turned up a third pair
1664
+ (`version-check` / `changelog`) in both `release.json` and `nightly.json`.
1665
+
1666
+ `gates:check` was nightly-only, so gate-file drift was invisible to every PR
1667
+ for up to 24 hours — which is why the pairs above survived a release. It now
1668
+ runs in `ci.yml`, and it is declared in `gates/pr.json`, so the declaration and
1669
+ the step cannot drift apart.
1670
+
1671
+ ### Changed: the anti-creep law covers the whole surface
1672
+
1673
+ Law 1 in `CLAUDE.md` was scoped to the shipped rule set — the one surface easy
1674
+ enough to measure — so every other surface grew unchecked: 139 npm scripts,
1675
+ 137 script files, 16 verbs, three capability registries, and a
1676
+ `frontier:contracts` list hand-written in `package.json` that listed 31 specs
1677
+ while `certify` ran the whole `tests/contract/` directory. The latter is now
1678
+ the directory. Law 0 states that the governed surface is every capability the
1679
+ product claims, and the entry-point gate is its enforcement.
1680
+
1681
+ **Decided against, on measurement: the ~60-file / ~45-script cut.** The script
1682
+ layer is 138 files and 141 npm scripts. The plan's argument for cutting it was
1683
+ that a maintainer cannot hold it in their head — and the argument is about
1684
+ _waiting_, so it was worth measuring before deleting anything:
1685
+
1686
+ | Term | Wall clock | Share |
1687
+ | -------------------------------------- | ---------- | ----- |
1688
+ | `test:coverage:ci` | 372 s | 79 % |
1689
+ | `lint` | 47 s | 10 % |
1690
+ | `docs:regen` (15 generators) | 20.5 s | 4 % |
1691
+ | `typecheck` | 13 s | 3 % |
1692
+ | `gates:claim-integrity` (17 gates) | 8.9 s | 2 % |
1693
+ | `build` | 7 s | 1 % |
1694
+ | `check-version`, `doctor`, `self-scan` | 4 s | 1 % |
1695
+ | **`npm run check`, whole** | **~8 min** | |
1696
+
1697
+ **All 38 gates cost 100 seconds of an eight-minute wait.** Cutting the gate
1698
+ surface from 39 gates to 12 would save roughly 70 seconds — under 15 % — and it
1699
+ would do that by deleting checks that caught six real defects during this
1700
+ release, three of them in code written hours earlier: the exit-code regex that
1701
+ could not cross a sentence period, the README version claim no docs gate could
1702
+ see, a `DELETE` row whose reason had nowhere to live, a stale `gate` field, a
1703
+ carve manifest that had drifted from the tree, and a duplicated gate command.
1704
+
1705
+ So the target the plan named was the wrong one. The wait is the **test suite**,
1706
+ not the gates, and it is the price of a tool whose thesis is that a green build
1707
+ is a claim rather than a fact. The levers that would actually move it are
1708
+ recorded rather than taken here: vitest reports ~18 s available from
1709
+ `isolate: false` (~4 %), and the e2e journeys are the slow tail — one of them
1710
+ spawns the CLI twenty times. Splitting the suite into fast and slow projects so
1711
+ the slow tail runs beside the fast one is the real win, and it is CI work
1712
+ rather than a surface cut.
1713
+
1714
+ `npm run entry-points:check` prints `commands` (9), `executions` (39) and
1715
+ `leafCommands` (39) separately, and fails if the chain exceeds twelve terms,
1716
+ names a script that does not exist, runs a write-mode script, runs the same gate
1717
+ twice, or if `certify` is narrower than `check`. Law 0 makes the surface
1718
+ governed; these are its teeth.
1719
+
1720
+ Two consumers were left alone rather than deleted: the twelve-file
1721
+ `scripts/video/` pipeline (`docs:video:capture`, `docs:video:render`,
1722
+ `docs:video`) and its `demo-video.yml` workflow, because the README embeds the
1723
+ video they produce and deleting a marketing asset is a product decision this
1724
+ change set has no mandate to make. They are declared as write-mode, unreachable
1725
+ from either entry point, and cannot be run by accident.
1726
+
1727
+ ### Deprecated: `--strict` → `--include-warn`
1728
+
1729
+ The old name promised enforcement the flag never had: a quarantined detector is
1730
+ advisory by design (decision D-2), so `--strict` could never make anything
1731
+ stricter, and a user reaching for it was asking for something the tool would not
1732
+ give them. The new name says what it does — it includes the WARN tier.
1733
+
1734
+ `--strict` still works and warns, naming its replacement, and is removed at 7.0
1735
+ per the cycle in `docs/VERSIONING.md`. `tests/contract/deprecation-cycle.spec.ts`
1736
+ is new and makes that cycle executable: every deprecated spelling is data in one
1737
+ list, and the test walks all three steps of it — the old name parses and does
1738
+ exactly what the new one does, it reports the deprecation with its replacement,
1739
+ and the replacement itself never warns.
1740
+
1741
+ ## [5.1.0] — 2026-10-02
1742
+
1743
+ > **Never published.** This section was cut on its release branch, but the
1744
+ > stable release run waited at the tag approval and was not approved, and 6.0.0
1745
+ > shipped first, carrying everything below. There is no `v5.1.0` tag and no
1746
+ > 5.1.0 on npm.
1747
+
1748
+ ### Two subtractions declined, because the surface they would remove says something the replacement does not
1749
+
1750
+ **`mjolnir capability`: delete the M0–M5 ladder, render the tier instead.** The
1751
+ two are not two names for one thing. `M0_UNKNOWN … M4_CORPUS_VERIFIED` is an
1752
+ _evidence_ axis — how far a capability's detection has been proven — and the tier
1753
+ (`core`/`extended`/`quarantine`) is a _shipping_ axis — whether it appears in a
1754
+ default scan. They are orthogonal in exactly the case that matters: a capability
1755
+ can be corpus-verified and still quarantine, and does. `QA-TEST-001` is
1756
+ `M2_IMPLEMENTED` with a measured 60% FP over n=20; the maturity ladder says how
1757
+ well its detection is built, the tier says it must not gate a build, and neither
1758
+ answer implies the other.
1759
+
1760
+ Rendering the tier "instead" would also delete the `next level` line — the
1761
+ ladder's actual product, which names the next rung and what it requires
1762
+ (`M3_FIXTURE_VERIFIED` — "positive + negative + boundary + adversarial fixtures
1763
+ pass deterministically in CI"). That is roadmap information stated as evidence,
1764
+ and it is the part a maintainer uses. The arm already says the right thing about
1765
+ itself: "shown at the level the machine can prove, or not shown at all."
1766
+
1767
+ **Cut the PR tier from 28 gates to 8.** This is a velocity trade, not a defect,
1768
+ and it runs opposite to the rest of this release: everything above restored
1769
+ verification that had silently stopped running — a suite with 48 failures, a
1770
+ typecheck with 8 errors, a coverage ratchet that could not evaluate, an anti-creep
1771
+ ratchet that switched itself off at release time. Removing 20 gates from
1772
+ per-push verification to save eleven minutes on a machine, while leaving CI's own
1773
+ `required` set as the binding one, is a decision about which machine developers
1774
+ use. It should be made as that decision, explicitly, not as cleanup.
1775
+
1776
+ Five of the plan's nine Phase C items are now either done or refuted, and three
1777
+ of the refutations shared a cause: each was written from a directory name or an
1778
+ orphan-list reason rather than from reading what the code actually does.
1779
+
1780
+ ### Four unwired modules deleted, two of them second implementations of live ones
1781
+
1782
+ C1, the orphan list. Deleting an unwired module is only safe when the reason says
1783
+ _why it is dead now_ rather than _what defect it was written for_ — and four of
1784
+ the reasons were describing a world that no longer existed.
1785
+
1786
+ - **`src/release/pack-audit.ts`** — `scripts/pack-audit.mjs` is what
1787
+ `stable-release.yml` runs, and `tests/contract/pack-audit.spec.ts` proves
1788
+ _that script_ can fire on every forbidden shape and stay silent on the
1789
+ shipped one. An unused copy of the audit is a second answer to "what is a
1790
+ forbidden tarball entry", and only the copy with a gate behind it would ever
1791
+ have been exercised.
1792
+ - **`src/release/sbom.ts`** — same shape. `scripts/generate-sbom.mjs` produces
1793
+ and checksums the SBOM the release attaches.
1794
+ - **`src/integrations/github/github-permissions.ts`** — validated a token
1795
+ before publishing a PR comment. **There is no PR comment publisher in
1796
+ `src/`**: the `pr-comment` verb went with the v6 carve. Its recorded reason
1797
+ also cited D5 ("the generated workflow inherited the default token scope"),
1798
+ which was fixed by emitting `permissions: contents: read` in both
1799
+ `ci-adapter.ts` and `ci-install.ts` — without this module. The defect was
1800
+ real, the fix did not go through here, and the reason outlived both.
1801
+ - **`src/integrations/github/stale-guard.ts`** — enforced TI-020, that an
1802
+ artifact carries the head SHA it was produced for. Same missing publisher.
1803
+
1804
+ Kept, with their reasons corrected to state what is actually true:
1805
+ `release/provenance.ts` and `release/reproducibility.ts` (the release gets
1806
+ provenance from `npm publish --provenance`, a different mechanism; a named
1807
+ release property nothing here verifies is a gap, and deleting the only code
1808
+ that would verify it converts a gap into an absence), `commands/registry.ts`
1809
+ and `engine/command-registry.ts` (three descriptions of one verb list, one
1810
+ authoritative — the clearest derived-fact-twice instance in the tree),
1811
+ `discovery/ecosystem-detection.ts`, the two `bench/` modules,
1812
+ `change-intelligence.ts`, `store/legacy-import.ts`, `v6/test-doubles.ts`, and
1813
+ the ten barrels. Orphan count 25 → 21.
1814
+
1815
+ **A gap in the gate that authorises these deletions.**
1816
+ `docs/CARVE-MANIFEST.json` is exactly the right mechanism — facts generated,
1817
+ dispositions hand-written and preserved, and `--check` failing on any file that
1818
+ left the tree without a DELETE disposition. It caught these four on the first
1819
+ run and named them. But regeneration rebuilds the manifest from the files that
1820
+ _exist_, so a DELETE disposition disappears the moment the file it describes is
1821
+ gone: the gate reports "2 already removed" for exactly one run, and then the
1822
+ reason is no longer in the repository. A manifest whose purpose is to hold the
1823
+ human decision should keep a tombstone for it; this one does not. The reasoning
1824
+ for these four is in this entry instead.
1825
+
1826
+ ### The declared tier is a third floor, and now it has teeth
1827
+
1828
+ B0 measured all 79 live rules against both existing derivations before anything
1829
+ was refactored. The result changed the shape of the work.
1830
+
1831
+ The interval floor is all but unreachable at the corpus sample cap, in both
1832
+ directions, and `tests/rules/core-tier-reachability.spec.ts` now pins the
1833
+ arithmetic with the product's own `wilsonInterval`:
1834
+
1835
+ - **core is unreachable.** `ciHigh <= 10%` needs n >= 35 with ZERO false
1836
+ positives — 0/20 reads 16.1%, 0/30 reads 11.3%, 0/35 reads 9.9%. The sampler
1837
+ caps at 20 per rule, so the best a rule can look at that cap is 0/20, and
1838
+ 16.1% is above the ceiling. `MEASURED-CORE` in `RuleStatus` is a state this
1839
+ corpus cannot produce. The cap is bounded by the adjudication budget, not by
1840
+ statistics: raising it to 40 previously produced 1,121 unadjudicated rows
1841
+ across 42 rules, which the committed ceiling refused outright — correctly,
1842
+ because blank verdict rows are dropped rather than counted.
1843
+ - **quarantine needs a 75% error rate.** `ciLow >= 50%` first fires at 15 of 20
1844
+ false positives; 14/20 reads 48.1%. Exactly one rule clears it:
1845
+ `QA-ENV-001`, wrong 20 times out of 20. The worst of the rest,
1846
+ `QA-TEST-002`, sits at 62% and clears neither bound.
1847
+
1848
+ So **34 rules declare `quarantine`, and 33 of them would be promoted into the
1849
+ default scan if the declared `tier` field were deleted** — every one in the
1850
+ promoting direction, none the other way. Deleting the declared tiers is not a
1851
+ refactor; it ships 33 quarantined detectors, including the six CI rules the
1852
+ README's caught-by-default table marks advisory.
1853
+
1854
+ The arithmetic corrects a summary I wrote first, and the spec caught it: I said
1855
+ the interval floor "quarantines zero rules". It quarantines one — `QA-ENV-001` —
1856
+ and B0's own table had said so throughout, under a declared column that
1857
+ happened to agree with it. The declared count is 34, not 33; 33 is the number of
1858
+ rules held down by the declaration alone.
1859
+
1860
+ So the declared tier stays, stated for what it is: a floor stricter than both
1861
+ derivations, and currently the only thing holding those 33 rules out of a
1862
+ default scan. That is documented at the field, with the numbers.
1863
+
1864
+ A floor that can also LOOSE is not a floor — it is a way to ship a rule the
1865
+ corpus says should not ship, and because every derived number in the tree reads
1866
+ the declared value through `effectiveTier`, nothing else would disagree.
1867
+ `defensibleTier` now states the floor a declared tier may never sit above, and a
1868
+ registry ratchet enforces it: no measurement at all, or observed FP above 30%,
1869
+ both mean quarantine.
1870
+
1871
+ The first version of that law was written against the interval floor alone and
1872
+ measured at **zero teeth** — it could only ever catch a rule declaring `core`,
1873
+ which no rule declares, so it would have passed no matter what the registry
1874
+ said. Both failure directions are now probed as tests, and they assert that the
1875
+ interval floor would have missed them.
1876
+
1877
+ The anti-creep exception is discharged rather than copied forward: 5.1.0 shipped
1878
+ the declaration, so the baseline absorbs it at 45/45 and the marker no longer has
1879
+ to be re-declared on every commit. The demanding branch stays covered by a
1880
+ pre-discharge fixture, and one rule above the absorbed baseline is still refused
1881
+ without a declaration.
1882
+
1883
+ ### Three orphan `families/` taxonomies deleted, one of them after being measured
1884
+
1885
+ `flaky-patterns.ts`, `marker-registry.ts` and `no-assertions.ts` shipped with
1886
+ no importer. The repo's rule for these is "if a rule's measured FP names the
1887
+ cause one of them describes, wire it; otherwise delete it" — and none of the
1888
+ three describes a cause anything is measured against.
1889
+
1890
+ `no-assertions.ts` was the interesting one: 7 framework assertion
1891
+ vocabularies, written for `QA-TEST-003`'s named FP cause. Its cause is
1892
+ _hidden-assertion helpers and deliberate no-throw smoke tests_, which is not a
1893
+ framework-vocabulary gap. Diffed against the rule's own predicate over 15
1894
+ representative test bodies it changed 2, **in both directions**: it suppresses
1895
+ `expect.extend(…)` — which _defines_ a matcher rather than asserting — and
1896
+ reintroduces a false positive on `should(x).be.ok`. Wiring it would have been a
1897
+ behaviour change to a quarantined rule justified by a claim the evidence does
1898
+ not support, so it is gone and the gap is recorded in the ledger instead, where
1899
+ it will be re-measured rather than quietly closed.
1900
+
1901
+ (The first measurement of that diff was wrong — a `g`-flagged `RegExp.test()`
1902
+ carries `lastIndex` between calls, so the two predicates were being fed a
1903
+ moving target. Two differences, not three.)
1904
+
1905
+ `flaky-patterns.ts` carried its own `HARD_SLEEP` patterns while a wired
1906
+ `hard-sleep` family already exists: a second source for one fact, which is the
1907
+ pattern that manufactures drift. Orphan count 28 → 25.
1908
+
1909
+ ### Two plan items refuted before acting on them
1910
+
1911
+ **"Delete `scripts/video/` — nothing consumes it."** It has three npm scripts,
1912
+ a workflow, and a README-linked asset. Its storyboard is gated by
1913
+ `tests/contract/video-script.spec.ts` (11 tests, re-running the capture and
1914
+ failing on drift); only the `.mp4` encode needs Chromium and ffmpeg, which is
1915
+ why that step is manual by design. Left alone.
1916
+
1917
+ What _is_ true is narrower and worth saying: the committed `mjolnir-demo.mp4`
1918
+ was rendered before the v6 carve, so it demonstrates output the CLI no longer
1919
+ prints. The README now says so, and names the re-render command, rather than
1920
+ letting a stale artifact read as current.
1921
+
1922
+ **"Delete the ~25 rule docs for retired rules; make `docs:regen` prune so it
1923
+ cannot recur."** The retired pages are kept **deliberately** — they are the
1924
+ record of what the tool used to claim — and
1925
+ `tests/contract/rule-docs-set.spec.ts` enforces exactly `live ∪ RETIRED_RULE_IDS`
1926
+ and nothing else. A prune was written, deleted all 22 on its first run, and was
1927
+ removed. That is the strongest available demonstration that "the generator
1928
+ cannot remove its own output" is a reason to add a `docs:regen` check, not a
1929
+ reason to add a delete. The existing spec already catches the real orphan: a
1930
+ page for an id in neither list.
1931
+
1932
+ **"Delete `src/certification/` — it certifies 1 language."** It reports
1933
+ **0 of 100** detection cells across **5** ecosystems, and separately 1 language
1934
+ (`manifest-v5` language support) as CERTIFIED. Those are different ladders and
1935
+ the file says so. A module that honestly reports 0% with a per-ecosystem next
1936
+ gap is a live status document, not dead weight — the repo already applied this
1937
+ reasoning to `src/v6/test-doubles.ts` on the orphan list. Left alone, with the
1938
+ number corrected.
1939
+
1940
+ ### Two bytes of encoding damage
1941
+
1942
+ `src/cli.ts` and `docs/DISTRIBUTION-KIT.md` each carried UTF-8 that had been
1943
+ decoded as Latin-1 and written back (`·`, `≥`, `×`). Invisible to a reader
1944
+ whose editor auto-detects encoding, and to any gate that does not look. Both
1945
+ fixed; a scan of all 10,133 text files in the tree finds no others.
1946
+
1947
+ ### Test fixes
1948
+
1949
+ - `tests/contract/coverage-state.spec.ts` was passing its keys to a helper
1950
+ typed for a different shape, so they were dropped and the test loaded a
1951
+ consistent default document. It never tested what it claimed.
1952
+ - `src/commands/rule-families.ts` sat at 60% because the carve removed
1953
+ `create-rule`, its second consumer. `tests/contract/rule-families.spec.ts`
1954
+ checks the table against the live registry.
1955
+ - The anti-creep and unreleased-entry gates both anchored on "the first `## `
1956
+ heading", which stops describing anything the moment a version is cut. Both
1957
+ now scope to the current version's section.
1958
+
1959
+ ### `ci verify` could never leave its no-baseline arm
1960
+
1961
+ **The `baseline` verb was removed in the v6 carve and nothing replaced it.**
1962
+ `saveBaseline` had no remaining caller, so nothing in the shipped product wrote
1963
+ `.mjolnir/baseline.json`. `mjolnir ci verify` — a `ci` subcommand, listed in
1964
+ `mjolnir ci --help` — therefore exited `2` with "no committed baseline" for
1965
+ every user, permanently.
1966
+
1967
+ Worse, three shipped messages told you to fix it by running a command that
1968
+ could not: the `verify` digest, the MCP `verify` tool, and the agent
1969
+ instruction brief `mjolnir install` writes all said to establish the
1970
+ before-state with a scan that never wrote one. A recovery instruction that does
1971
+ not recover is worse than none — it costs a round trip and teaches the reader
1972
+ that the tool's own guidance is unreliable.
1973
+
1974
+ `mjolnir <target> --save-baseline` now writes the snapshot, reusing the
1975
+ existing writer and renderer. The exit code stays the **scan's**, not the save's:
1976
+ the old verb returned `0` unconditionally after capturing, so a repo full of
1977
+ error findings exited clean as long as you snapshotted it.
1978
+
1979
+ ### Unknown flags were silently ignored on three commands
1980
+
1981
+ `mjolnir doctor --bogus` printed its usage and exited `10` after a flag-parity
1982
+ fix. `mjolnir analyze --bogus` printed "Use --cross-file to enable cross-file
1983
+ analysis" and exited `0`; `mjolnir explain --list --nonsense` printed the whole
1984
+ rule catalogue and exited `0`. A caller scripting a filter got every rule and no
1985
+ warning, and a command that looks like it ran and does nothing is the shape this
1986
+ product exists to catch. All three now reject an unknown flag with a usage
1987
+ message naming the accepted set.
1988
+
1989
+ ### The agent instructions named four commands that exit 10
1990
+
1991
+ `mjolnir install` writes instruction files for Claude Code, Kilo, Cursor and
1992
+ `AGENTS.md` telling an agent how to run the loop. After the v6 carve they said
1993
+ `baseline`, `verify`, `why`, `triage` and `forensics` — all removed, all exit
1994
+ `10`. The 79 rule docs were re-pointed at the real command; the shipped agent
1995
+ brief was not. It now names `--save-baseline`, `ci verify` and `explain`.
1996
+
1997
+ ### The brand gate checked a file that no longer exists
1998
+
1999
+ `scripts/brand-doctor.mjs` rule 7 had a self-test seed and a rule arm both
2000
+ reading `src/commands/badge.ts`, deleted by the v6 carve. The arm was guarded
2001
+ by `existsSync`, so it reported "0 generated bands" and the self-test aborted
2002
+ the whole gate on a missing file. Both are gone: the product emits no
2003
+ shields.io badge (the PR comment renders HTML cells), and a check behind an
2004
+ `existsSync` that is always false is a check that cannot fail — the exact defect
2005
+ that script exists to catch. The self-test now proves 11 of 11 rules.
2006
+
2007
+ ### Two gates could not have been running
2008
+
2009
+ **The anti-creep ratchet switched itself off at release time.** The exception
2010
+ marker was scoped to the first `^## ` heading in the changelog. Cutting a
2011
+ release empties `## [Unreleased]` — that is what the section is for — and moves
2012
+ the change's notes into the version heading below it. From that moment the
2013
+ declaration was still there, one heading down, and the law reported legitimate
2014
+ growth as unlicensed. The parser now accepts the release heading below an
2015
+ empty `[Unreleased]`, and still refuses a marker from any older release; three
2016
+ arms pin both directions.
2017
+
2018
+ **`tests/contract/coverage-state.spec.ts` never tested its claim.** It called a
2019
+ helper typed `Partial<analysisStatus>` with `{ partial, analysisStatus }`, so
2020
+ both keys were dropped and the helper's own consistent defaults were used. The
2021
+ document loaded for the wrong reason. It now passes the real
2022
+ `coverageState` — and fails without it, which is how you know.
2023
+
2024
+ ### The type gate had never run
2025
+
2026
+ `npm run typecheck` reports **8 errors**, all in test files, all pre-existing at
2027
+ `ef500a8b`. The gate was red in the same way the release was red: nobody ran
2028
+ it. Two were hiding a real defect behind a cast — `TrustSummary` had
2029
+ `level: "high"`, which is not a `TrustLevel` at all (`L0`..`L5`), and
2030
+ `assessDoubleRisk` was being called with `intent` where the interface says
2031
+ `intention` and requires `hasFixture`/`hasLifecycle`. Neither had compiled
2032
+ against the real type. Both are fixed; the gate is green.
2033
+
2034
+ ### A test that passed vacuously, found by the coverage floor
2035
+
2036
+ `src/commands/rule-families.ts` sat at 60% because the carve removed
2037
+ `create-rule`, its second consumer — the table's rows went from "exercised by
2038
+ the scaffolder" to "loaded once and never checked". `tests/contract/rule-families.spec.ts`
2039
+ now checks the table against the live registry: every token is a family the
2040
+ rules use, every family with rules has a directory that exists, and the derived
2041
+ `RULE_ID_RE` accepts every family in the table and rejects families that are
2042
+ not in it.
2043
+
2044
+ The check that surfaced the drop is the reason the new arms were added
2045
+ in-process. A test that spawns the binary earns no istanbul credit, so
2046
+ `--save-baseline` and the two flag-parity arms would have shipped untested by
2047
+ the ratchet's own measure.
2048
+
2049
+ ### The coverage ratchet, measured against a suite that could not run
2050
+
2051
+ The high-water marks (98.28 / 94.87 / 99.17 / 98.64) were recorded against a
2052
+ green suite. At `ef500a8b` 48 tests failed, a failing run writes no summary,
2053
+ and `coverage:ratchet` errored with "run npm run test:coverage first" instead
2054
+ of reporting a number — there was no comparable measurement. The measured value
2055
+ with a fully green suite is **97.22 / 93.71 / 98.78 / 97.80**; the marks moved
2056
+ accordingly, with the reasoning recorded in `docs/COVERAGE-GATE.md` before the
2057
+ build was expected to pass.
2058
+
2059
+ ### `runDoctorPlaywright` was not reachable, and its tests passed a wrong argv
2060
+
2061
+ Six test files called `runDoctorPlaywright(["doctor", "--frameworks", dir])`.
2062
+ The handler takes the target as the first non-flag argument, so it was scanning
2063
+ the literal string `"doctor"` and printing `SELECTOR HEALTH` for it. The verb
2064
+ was removed by the carve, so nothing in the product reaches the handler at all.
2065
+ The calls pass `[dir]`.
2066
+
2067
+ ### The brand gate checked a file that no longer exists
2068
+
2069
+ `scripts/brand-doctor.mjs` rule 7 had a self-test seed and a rule arm both
2070
+ reading `src/commands/badge.ts`, deleted by the v6 carve. The arm was guarded by
2071
+ `existsSync`, so it reported "0 generated bands" and the self-test aborted the
2072
+ whole gate on a missing file. Both are gone: the product emits no shields.io
2073
+ badge (the PR comment renders HTML cells), and a check behind an `existsSync`
2074
+ that is always false is a check that cannot fail — the exact defect that script
2075
+ exists to catch. The self-test now proves 11 of 11 rules.
2076
+
2077
+ ### Test fixes
2078
+
2079
+ - `tests/milestone-12.spec.ts` cast four `ScanResult` fixtures with `as any`
2080
+ and omitted the required `frameworks` field, so the fixtures threw
2081
+ `TypeError` on `.length`. The product reads `result.frameworks.length`; the
2082
+ fixtures were wrong. One also asserted `scoreDelta === 0` for two null
2083
+ scores, contradicting the deliberate `?? 0` fix — a null score yields a null
2084
+ delta, and the expectation now says so.
2085
+ - `TI-024`'s `verificationCase` named a test title that was replaced when the
2086
+ translation machinery was retired. It now names the case that exists, and the
2087
+ registry spec proves the title is still in the named file.
2088
+ - The version-surface drift arm hardcoded `@v4` in both its seed and its
2089
+ expected message, so it seeded `v5` and asserted "v3 does not match v4" — the
2090
+ check fired correctly and the test failed on its own staleness. The major is
2091
+ now read off the fixture and the message composed from it.
2092
+ - The exit-code sweep, journeys 2, 3/4 and 5/6/7/8 called fourteen verbs the
2093
+ carve removed. Each is now asserted in the form that ships, and the retired
2094
+ names are asserted to be usage errors — a verb that comes back under its old
2095
+ name is a surface the docs no longer describe.
2096
+ - `crash-paths.spec.ts` had a `describe` with no tests (the carve deleted every
2097
+ body and left the hooks), which fails collection. Its arms made the write
2098
+ target unwritable with `chmod 0o555` — a no-op on Windows, so half the suite
2099
+ verified nothing on the platform most contributors run. The throw is now
2100
+ injected at the IO boundary: deterministic everywhere, same catch.
2101
+ - `tests/integrations/release-workflow.spec.ts` expected the release job list
2102
+ to be `["contract-verify", "tag"]` — a CLI verb, not a job in the workflow —
2103
+ while the assertion 15 lines below still read `needs.verify.outputs.tarball`.
2104
+ Both cannot be true, and the one naming a job that does not exist would have
2105
+ let a rename break the publish path silently.
2106
+ - `CORPUS` pinned `positive-fixtures` to a tree SHA the carve had moved.
2107
+ - `test:release` still named the empty `release-trust-branch.spec.ts`, which
2108
+ was deleted.
2109
+ - `candidate-manifest-base-sha` tolerated 3 of the checker's 4 content
2110
+ invariants, so a `lockfileSha256` drift — what any `npm audit fix` produces —
2111
+ failed the test with a message it did not recognise as transient.
2112
+ - The anti-creep and unreleased-entry gates both anchored on "the first `## `
2113
+ heading", which stops describing anything the moment a version is cut. Both
2114
+ now scope to the current version's section.
2115
+
2116
+ ### README
2117
+
2118
+ The opening claimed seven detections and put the strict-mode split 21 lines
2119
+ below the list. The list now carries a **caught-by-default** column beside each
2120
+ false-green, and the split sits directly under it. The seven claims were not
2121
+ false — every one of those false-greens is real and detected — but the default
2122
+ scan catches five of the nine rows, and the README's structure made the
2123
+ advisory four look equivalent to the other five.
2124
+
2125
+ ### Maintenance
2126
+
2127
+ `npm audit fix` — `brace-expansion` high-severity DoS (transitive), three
2128
+ advisories.
2129
+
2130
+ ### The demotion ratchet's own numbers were transcribed, not measured
2131
+
2132
+ **Eighteen of the nineteen `ciHigh` values in `DEMOTED_FOR_UNSUBSTANTIATED_CORE`
2133
+ were wrong.** They were written once at the 6.0 demotion and never re-derived,
2134
+ so a re-sample moved the real interval and left the number beside it describing
2135
+ a measurement that no longer exists. `QA-PY-002` recorded `0.152` against a real
2136
+ `0.2996` — the row said "the largest sample on an observed-nonzero Python rule
2137
+ here, and still short of the ceiling" about a rule that was twice as far from the
2138
+ ceiling as recorded.
2139
+
2140
+ The ratchet that should have caught this only asserted `ciHigh >
2141
+ CORE_FP_CEILING`, which every stale value also satisfied. A check on a
2142
+ hand-maintained number that tests only its sign cannot fail.
2143
+
2144
+ So the number is no longer hand-written. Each row keeps `ruleId` and
2145
+ `justification` — the part that is judgement — and `ciHigh` is computed from
2146
+ `MEASURED_FP` on load using the same expression `measurementInterval` uses. A
2147
+ rule whose measurement has been withdrawn yields `NaN`, and `NaN > 0.1` is
2148
+ false, so it fails the check rather than passing it. A new ratchet assertion
2149
+ compares each recorded value against the **live** `measurementInterval`, so a
2150
+ stale `detectorRevision` or a withdrawn measurement is caught too, and the
2151
+ check now fires when a value drifts.
2152
+
2153
+ All nineteen remain legitimately demoted — none clears the ceiling — so the
2154
+ ratchet stays armed and the tier is still empty.
2155
+
2156
+ `docs/CORE-READINESS.md` now records, in the generator rather than only in
2157
+ prose, that `EARNED`, `DECLARED` and `EXPIRED` are empty because no rule's
2158
+ interval clears the ceiling and no rule carries a `corePromotion`. All three
2159
+ states are kept: they are reachable, and deleting a state because its count is
2160
+ zero deletes the branch that would have reported a non-zero count.
2161
+
2162
+ ### The anti-creep law now governs the rules that actually ship
2163
+
2164
+ `ANTI-CREEP-EXCEPTION` — the law's own mechanism for a growth that is not a
2165
+ creep. The launch set is redefined from `tier === "core"` to
2166
+ `effectiveTier !== "quarantine"`, which is what the law has always said it
2167
+ covers ("the rules that ship in the default report"). That is **45 rules, not
2168
+ 0**: the core tier has been empty since 6.0, with all 79 rules resolving to 34
2169
+ `quarantine` and 45 `extended`.
2170
+
2171
+ The ratchet therefore reports growth of 45 against a previous baseline of 0,
2172
+ and this line is the reason. It is not new surface area — every one of those 45
2173
+ rules already shipped; the law simply was not counting them. What changes is the
2174
+ bar: growth above 45 now needs one of these markers, and `CORE_CAP`'s 65 leaves
2175
+ 20 free slots rather than 65.
2176
+
2177
+ - **`docs/ANTI-CREEP-BASELINE.json`** records `baselineCore: 45` with
2178
+ `previousBaselineCore` left at **0** deliberately. The law compares the tier
2179
+ against the _previous_ baseline, so leaving it at 0 keeps the move from 0 to
2180
+ 45 visible as growth; setting it to 45 in the same edit would make a
2181
+ redefinition of the governed set look like a legal no-op, which is the exact
2182
+ escape that rule exists to close.
2183
+ - **Law 3 (north-star) keeps governing the core tier, on purpose.** The ≥10
2184
+ verdict requirement is enforced against `tier === "core"` and was not moved to
2185
+ the shipped set: applying it to 45 rules would fail the check on day one. That
2186
+ is a policy decision about what the product may ship, not a defect, so it is
2187
+ recorded here rather than taken unilaterally. The open question — does the
2188
+ ≥10 requirement apply to the shipped set or the core tier? — is unresolved and
2189
+ belongs to the law's owner.
2190
+ - `CORE_CAP` stays at 65 and `MAX_SAMPLES_PER_RULE` stays at 20. Both are
2191
+ deliberate; the corpus sampler's in-source rationale (`scripts/corpus-sample.ts`)
2192
+ records why raising the cap produced 1,121 unadjudicated rows that the
2193
+ committed ceiling refused. The defect was never the constants — it was that
2194
+ the law measured a set of zero.
2195
+
2196
+ ### Single-site corrections, and one key that matched nothing
2197
+
2198
+ - **`FRAMEWORK_FAMILY.selenium` and `SLUG_TO_FAMILY.selenium` were `"QA-SEL"`.**
2199
+ No rule id carries that prefix — the real one is `QA-SE` — so both maps
2200
+ resolved to the empty list. The Selenium capability reported no evidence at
2201
+ all while its three rules (`QA-SE-001`–`003`) were credited to nothing.
2202
+ An unmatched key returns an empty result rather than an error, which is why
2203
+ nothing reported it. `QA-SE` was the only family in the registry that no
2204
+ capability could reach.
2205
+ - **`M5_FIELD_PROVEN` did not require `M4`.** `fieldProven` was the first test
2206
+ in `deriveMaturityFromEvidence`, so a capability carrying only that flag
2207
+ reached M5 — a level whose published meaning includes "declared" and
2208
+ "implemented" — without either. The ladder's own stated invariant ("each level
2209
+ requires every criterion below it") did not hold at the top, and the only route
2210
+ to M5 was to satisfy none of M4's criteria.
2211
+ - **`LOCAL_PROVEN` could cite an unmeasured rule.** `proofFor` picked the first
2212
+ non-_retired_ rule, so a capability whose first listed rule had no corpus
2213
+ evidence could be marked proven with a pointer into `docs/FP-AUDIT.md` for a
2214
+ rule the audit never measured. It now picks a rule from `facts.measured` —
2215
+ the same predicate the status itself reports on.
2216
+ - **A parity violation could name a field that cannot differ.** `checkParity`
2217
+ guarded on the fingerprints being different and then iterated
2218
+ `["intention", "fingerprint"]`, so the `fingerprint` arm was unreachable. Its
2219
+ sibling `extractBindings` already looped correctly over the same files for the
2220
+ same kind of marker.
2221
+ - **The claim linter reported the first claim on a line and dropped the rest.**
2222
+ `scanText` used a single `exec` per line, so a line making two claims of the
2223
+ same kind reported one — and the half it dropped was the half a maintainer has
2224
+ to go and fix. All matches are now reported, with a zero-length guard so a
2225
+ future pattern that can match empty cannot hang the linter.
2226
+ - **The forensics `successRate` was not a rate.** It could only be 0 or 1, and
2227
+ this object is only built when a test passed _on retry_ — so within
2228
+ `maskedFailures` it was invariably 1. A reader seeing "success rate: 1" would
2229
+ conclude the test passed on every attempt, which is the opposite of what the
2230
+ entry means. Renamed `finalPassBinary`. Its `attempts === 0` guard was
2231
+ unreachable: the enclosing filter requires `attempts > 1`.
2232
+ - **Two CI findings reported themselves under the wrong name.** An `exit 0` was
2233
+ typed `or-true` while its own description said it forced an exit code of 0, and
2234
+ the pattern `||\s*(?:true|:)` matched both `|| true` and `|| :` while the
2235
+ description said `|| true` unconditionally — naming a construct the script does
2236
+ not contain, on exactly the finding a reader is about to go looking for. They
2237
+ are now `or-true`, `or-colon` and `exit-zero`.
2238
+ - **A rule that sampled nothing no longer overwrites its review sheet.**
2239
+ `writeReviewSheets` wrote a sheet whose entire content was a heading, the
2240
+ verdict legend, and "Total sampled: 0" — and replaced any sheet from an earlier
2241
+ run that had samples, destroying pending classifications. A zero-sample rule now
2242
+ removes its sheet. This is distinct from a rule this run did not _visit_, whose
2243
+ sheet is owner work-in-progress and is still preserved.
2244
+
2245
+ ### The readiness table told maintainers to do the wrong work
2246
+
2247
+ `docs/CORE-READINESS.md` is the document a maintainer reads to decide what to
2248
+ do next, and two of its three actionable columns did not compute what they said.
2249
+
2250
+ - **`FP headroom` could never contain a positive number.** It asked how many
2251
+ _more_ false positives a rule could be **added** and still clear the ceiling —
2252
+ and adding false positives only widens the interval, so every rule that had
2253
+ already earned the tier read `0` and every other rule read `—`. The quantity
2254
+ that actually decides work is the opposite one: how much of the current
2255
+ evidence has to be **retracted**. Renamed `FPs to remove` and computed by
2256
+ searching downward from the observed count. It is now `—` only where
2257
+ retraction genuinely cannot reach the ceiling at that `n`, which is a fact
2258
+ about the sample rather than a missing value.
2259
+ - **`NEEDS-FP-REDUCTION` condemned detectors on thin evidence.** It fired on a
2260
+ point estimate above the ceiling, so a rule observed at 42.9% over `n=14` was
2261
+ labelled "the rule is wrong, not the corpus" by an interval that comfortably
2262
+ _included_ the ceiling. It now requires the Wilson interval to exclude the
2263
+ ceiling from below, so a rule the data cannot yet distinguish from a
2264
+ conforming one is told to gather more evidence first. **Nine rules moved from
2265
+ `NEEDS-FP-REDUCTION` to `NEEDS-SAMPLES`**, and the document's own counts
2266
+ change with them (28 → 19, 45 → 54).
2267
+ - **The same FP count was reconstructed two ways.** `measurementInterval` and
2268
+ `compareFpMeasurements` handed `wilsonInterval` an unrounded `fpRate * n`
2269
+ while this table rounded it — 10.5% of 19 is 1.995, and `wilsonInterval`
2270
+ rounds internally, so the ratchet and the readiness table derived two
2271
+ different `ciHigh` values for the same rule at the same n. Both round now. The
2272
+ interval math was always correct; the defect was in what reached it.
2273
+ - **`samplesForZeroFp`'s cross-check was wrong.** Its docstring quotes two
2274
+ `wilsonInterval(0, n)` values as the verification of the closed form. The
2275
+ `n=34` figure was quoted as 0.1012; the function returns 0.1015. A
2276
+ cross-check that fails when you run it reads as evidence and is not, so the
2277
+ numbers are now asserted in a test rather than only in prose.
2278
+
2279
+ ### Three places a claim was checked by something other than the thing it claimed
2280
+
2281
+ - **CONVERGENT did not mean convergence.** `groupByRootCause` fell back to
2282
+ `ruleId`, and nothing in the tree assigns `rootCauseId` — the machine
2283
+ contract reports it `null` on every finding — so the key for every group was
2284
+ the rule. N independent defects from one rule across N files rendered as
2285
+ "CONVERGENT — N findings share root cause QA-PW-002", which is the inverse of
2286
+ the claim: several findings being one thing versus several findings sharing a
2287
+ rule id. The fallback is now the finding fingerprint, `rootCauseId` still
2288
+ wins the moment anything populates it, and the listed identities are distinct
2289
+ — the old fallback made a three-finding group list one id three times, so a
2290
+ consumer could not tell the members apart, and the same-file AMPLIFIED
2291
+ conclusion it suppressed was being lost as collateral.
2292
+ - **Two glob compilers disagreed, and a gate used the wrong one.** `ignores.ts`
2293
+ and `scan-pipeline.ts` each compiled globs, and they differed on `?` (a
2294
+ metacharacter in one, a literal in the other) and on both forms of `**`. The
2295
+ mass-suppression integrity gate reached into `ignores.ts` for its compiler
2296
+ while the scan applied suppressions through `scan-pipeline.ts` — so it
2297
+ measured the suppressed set in a different dialect than the one that
2298
+ suppressed it, counting findings the scan never suppressed. Both now compile
2299
+ through `src/lib/glob.ts`, which is the only implementation of the dialect.
2300
+ One behaviour narrows: config `exclude` no longer reads `?` as a wildcard. No
2301
+ shipped default or repository config uses `?`, and `*`/`**` are unchanged.
2302
+ - **An unparseable expiry was perpetual.** `new Date("2026-13-45").getTime()`
2303
+ is `NaN`, and every comparison against `NaN` is false — so a suppression with
2304
+ a typo in its expiry date was never reported expired and suppressed findings
2305
+ forever. A date nobody can read now counts as expired; a suppression that
2306
+ genuinely never expires must say so by omitting `expires`, which is a
2307
+ decision someone made on purpose.
2308
+
2309
+ `detectMassSuppression`'s threshold boundary was already `>=`; what was missing
2310
+ is that the boundary was stated nowhere, so "50%" read as "above 50%".
2311
+
2312
+ ### Deletions, and one that was hiding another
2313
+
2314
+ - **`src/v6/tool-coverage.ts` deleted.** `ScanResult.toolCoverage` was declared
2315
+ and never assigned by anything — no assignment exists in `src/` or `scripts/`
2316
+ — so the block it assembled reached no surface, no report and no contract.
2317
+ Its header claimed "the census gap list shrinks by 15 real entries"; that
2318
+ reduction comes from `NOT_QA_TOOLING` in `ecosystem-census.ts`, which
2319
+ classifies names regardless of who calls it. The module contributed nothing to
2320
+ it. Recorded as a `DELETE` disposition in `docs/CARVE-MANIFEST.json` with that
2321
+ evidence, rather than dropped from the record.
2322
+ - **Deleting it exposed a second gap.** Its only importer was
2323
+ `src/v6/test-doubles.ts` — the false-proof detector, the module this product
2324
+ is named after. Nothing in `src/` or `scripts/` has called it since. That is
2325
+ now an explicit entry on the committed orphan list with the reason, rather
2326
+ than a fact hidden by a dead import edge. The `DOUBLE_ONLY`/`TRUTHY` fix
2327
+ landed on it in this release, so the logic is right and the wiring is still
2328
+ owed. It is listed, not deleted: a thesis module that is unwired is a
2329
+ product gap, and deleting it would make the gap invisible rather than gone.
2330
+ - **`src/scorer/scoring-validation.ts` deleted.** An orphan that documents four
2331
+ scoring invariants and implements two, one of them vacuous. It also carried a
2332
+ duplicate `SCORING_MODEL_VERSION` — `contract-versions.ts` owns that
2333
+ constant and `scan-pipeline.ts` reads it from there. Fixing and wiring it
2334
+ would have been strictly more code than deleting a module nothing runs.
2335
+ - **`--policy` no longer prints a scoring formula.** It read
2336
+ `score = 100 · (1 − deductions / (findings + NORMALIZATION_K))`, which
2337
+ contradicts `scorer.ts` on four counts: the denominator is test
2338
+ _declarations_, not findings; `SMOOTHING_C` is missing from it;
2339
+ `NORMALIZATION_K` multiplies the rate rather than sitting in the denominator;
2340
+ and the `min(100, …)` cap is absent, so the line described a score that can go
2341
+ negative. A table whose header says "every number here is imported, never
2342
+ written down" was carrying a formula written by hand, and it was wrong. It now
2343
+ names `docs/SCORING.md` and `scorer.ts` instead — a restated formula is the
2344
+ same defect one layer over, and it drifts the first time the scorer changes.
2345
+ - **Three empty imports removed from `src/commands/milestone.ts`** —
2346
+ `import {} from …` on three modules, which asserted a dependency the file
2347
+ does not have.
2348
+
2349
+ ### One path check, three callers: a claim that names a deleted file is now a failed check
2350
+
2351
+ Every artifact claim in this repository is a path inside a file — a
2352
+ `provisionalArtifacts` entry in `ROADMAP.yaml`, an `evidence` citation in the
2353
+ v6 inventory, a `regression_test`/`evidence_artifact` on an M26 ledger row.
2354
+ Three validators read those claims and all three asked the same weak question,
2355
+ "is this field a non-empty string?", so a citation to a module the v6 carve
2356
+ deleted validated clean. One check now serves all three, so a fourth caller
2357
+ cannot come back weaker.
2358
+
2359
+ - **`docs:roadmap:backlinks` is wired into the release and nightly tiers.**
2360
+ It was correct and red the whole time — reporting 19 missing provisional
2361
+ artifacts — and in no tier at all. It now runs through `gates:claim-integrity`,
2362
+ which is where a roadmap's artifact claims belong.
2363
+ - **The 19 dead `provisionalArtifacts` rows are deleted, not repointed.** M29,
2364
+ M31–M36, M38, M39, M41, M43–M50 each declared an artifact the carve
2365
+ removed. A successor is a different module answering a different question,
2366
+ so citing one would turn "this was never built" into a claim that something
2367
+ was.
2368
+ - **`docs/V6-CURRENT-STATE.md` and `docs/V6-GAP-MATRIX.md` are drift-locked.**
2369
+ Only the JSON halves of these generated artifacts were ever compared against
2370
+ a fresh render. The `.md` halves were hand-editable, which is how a
2371
+ hand-edited line came to contradict the `docs/v6-inventory.json` beside it.
2372
+ Both are now compared, and both sides are formatted in memory so the check
2373
+ still never writes to the tree it inspects.
2374
+ - **A generated artifact may no longer cite a file that does not exist.**
2375
+ Every repo-relative path in the rendered `.md` is now checked, which is what
2376
+ found the 11 dead citations the §100 classification was still carrying. The
2377
+ claims were corrected at source rather than in the rendered output: §59
2378
+ (dashboard) and §66 (quality debt) are now `OBSOLETE`, which is the state
2379
+ the enum had declared and nothing had ever used.
2380
+ - **M26 ledger rows are BLOCKED when a cited evidence path does not resolve.**
2381
+ `regression_test` and `evidence_artifact` are the only pointer a closure
2382
+ claim has to the thing that proves it. A `warning`, not an `error`: a row may
2383
+ legitimately cite a path this checkout does not carry, and "unverifiable
2384
+ here" is the honest status. The root is a parameter rather than a default,
2385
+ so a record's validity never depends on the working directory.
2386
+ - **Hand-written prose deleted from the generators.** GAP-V6-005's summary
2387
+ carried "14 of the 108" while the matrix beside it reported 18 — two
2388
+ representations of one number, one hand-maintained, where the renderer
2389
+ already derives the authoritative figure. The §38/§65 notes named
2390
+ `pw-report` and `trend`, which are commands, as modules of `src/forensics`.
2391
+ The `QA_DOMAIN_RECORDS` provenance column named a symbol that exists
2392
+ nowhere in `src/`.
2393
+ - **"156 cells" was never true.** The support matrix carries 136 cells and 6 on
2394
+ the language-framework axis. The figure was hand-written in source, so
2395
+ regenerating could not have fixed it; the language manifest now names the
2396
+ surface and points at the matrix that carries the count.
2397
+ - **`docs/ANTI-CREEP.md` pointed at `src/rules/tier-policy.ts`;** the module is
2398
+ at `src/engine/tier-policy.ts`.
2399
+ - **`docs/MANUAL-SCRIPTS.md` claimed `docs:roadmap:backlinks` was wired into
2400
+ the nightly tier.** It was not. It is now, so the row is gone — and the
2401
+ script-reachability gate enforces that a script cannot be both a gate and a
2402
+ manual tool.
2403
+
2404
+ A gate that has never fired is not a gate: the rendered-markdown comparison was
2405
+ verified to fail on a planted hand edit and pass after reverting it.
2406
+
2407
+ ### False greens: three places a scan reported a stronger result than it had
2408
+
2409
+ Every ordinary scan withheld 34 quarantined rules and reported nothing about
2410
+ it at three different surfaces. This release closes that gap. **These are
2411
+ user-visible status changes** — an `INCONCLUSIVE` finding is no longer
2412
+ `FIXED`, and SARIF `executionSuccessful` is now `false` on a scan that
2413
+ withheld rules. Existing baselines and saved reports will show the new,
2414
+ weaker claim; that is the point of the change.
2415
+
2416
+ - **A quarantined rule could prove a finding "fixed" on a scan that never ran
2417
+ it.** `analysisStatus` reported _how many_ rules were withheld but not
2418
+ _which_, and lifecycle resolution — which decides one finding at a time —
2419
+ had nothing to go on. A finding whose rule was filtered out disappeared
2420
+ from the scan and rendered `FIXED SINCE BASELINE`. New optional field
2421
+ `analysisStatus.withheldRuleIds` carries the set; `resolve()` returns
2422
+ `INCONCLUSIVE` with a new `rule-withheld` cause when the finding's rule was
2423
+ withheld. With `--strict` the rule runs and the fix resolves normally.
2424
+ `schemaVersion: 1` is unchanged — both new fields are additive
2425
+ (`docs/VERSIONING.md`).
2426
+ - **"FIXED SINCE BASELINE (verified by a complete same-revision scan)" was
2427
+ false for every withheld scan.** The sentence claimed the whole scan was
2428
+ complete; it now claims only what survived every guard — the rule ran, the
2429
+ file was in scope, it no longer fires.
2430
+ - **SARIF called a `PARTIAL`-coverage scan fully successful.**
2431
+ `executionSuccessful` was `!partial && rulesCrashed === 0`, and
2432
+ `coverageState` is deliberately orthogonal to `partial`, so a scan that read
2433
+ every file it was given and ran 45 of 79 detectors reported success to GitHub
2434
+ code scanning. `executionSuccessful` now also requires
2435
+ `coverageState !== "PARTIAL"`, with a `toolExecutionNotifications` entry
2436
+ naming the withheld count and pointing at `--strict`.
2437
+ - **`analysisStatus.reasons` now carries `coverage:quarantine:<n>`,** which
2438
+ `types.ts` already promised and nothing emitted. The flat reason set is what
2439
+ a machine consumer reads, and it was empty for the one gap that affects
2440
+ every non-`--strict` scan. `coverage:*` reasons are excluded from
2441
+ report-io's `partial` markers, so disclosing coverage does not make a saved
2442
+ report unloadable and does not turn ordinary scans into partial ones.
2443
+ - **A lone `expect(x).toBeTruthy()` was reported as a false proof.** The QA-IR
2444
+ gives `TRUTHY` an `"ANY"` expectation — an explicit, evidence-bearing shape
2445
+ — while the double-risk model did not list it as substantiating. The two
2446
+ modules disagreed, so the assertion counted as neither substantiating nor a
2447
+ double and fell into `DOUBLE_ONLY`: "every assertion is about a test double"
2448
+ about a test with no double assertions, at a `hollowRatio` of 0. `TRUTHY` is
2449
+ now substantiating, and `DOUBLE_ONLY` requires at least one double.
2450
+ - **The trust-trend report invented numbers.** `score` is `null` for a repo
2451
+ with no tests, and `(last ?? 0) - (first ?? 0)` reported a fabricated
2452
+ `+100` when a repo gained its first score. `scoreDelta` is now
2453
+ `number | null` and the rendering says "not comparable". `overallDirection`
2454
+ compared the _number_ of regression entries against improvements, so one
2455
+ 50-point drop lost to three 1-point gains and the trend read "improving" —
2456
+ it now sums magnitude. `frameworkCount` was `1` whenever detection was known
2457
+ (four detected frameworks and one rendered identically) and is now
2458
+ `frameworks.length`. The `warnings > errors * 2` ratio degenerated to
2459
+ `warnings > 0` at zero errors, reporting "weak assertions" for every repo
2460
+ with warnings and none; it now requires a denominator.
2461
+
2462
+ ### Names: one command, one name
2463
+
2464
+ `package.json` carried eight families of stragglers — two names for the same
2465
+ command, a name and its write arm under different spellings, and a chain whose
2466
+ members each had a name of their own. Nothing failed, because npm has no
2467
+ opinion and a second name is indistinguishable from a first one.
2468
+
2469
+ - **`build --determinism` and `unimported --check` had twins.** `unimported:check`
2470
+ and `check-unimported-modules` both ran `scripts/check-unimported-modules.mjs`;
2471
+ `build:determinism` and `verify-build-determinism` both ran
2472
+ `scripts/verify-build-determinism.mjs`; and so on for
2473
+ `fp-audit:generate`/`generate-fp-audit-table` and `docs:rules`/`generate-rule-docs`.
2474
+ A reader who typed the wrong one got the right answer and no signal that the
2475
+ name was wrong.
2476
+ - **`check-cli-contract` is the gate that made it visible.** It reads
2477
+ `docs/cli-contract.json`, which carries one disposition per verb in
2478
+ `src/engine/cli-command-names.ts`, and it fails when two npm names resolve to
2479
+ the same entry with the same argv, when a verb has no disposition, and when
2480
+ any `.md`, workflow, or Action names an `npm run` script that does not exist.
2481
+ A check arm and a write arm of one generator is **not** a duplicate — that is
2482
+ a declared convention in eleven places — so the rule is about the command,
2483
+ not the file.
2484
+ - **`corpus:regression`, `corpus:regression:update`, `corpus:resample` and
2485
+ `corpus:regression:refresh-provenance` are now `corpus:audit` with flags.**
2486
+ Same subject, four names. `scripts/resample-unmeasured.ts` became a guarded
2487
+ module the audit imports rather than a second entry point.
2488
+ - **`rules:quad:check`, `rules:quad:write` and `corpus:quad:verdicts` are now
2489
+ `check-fixture-quad` with flags.** The census's own report already told
2490
+ readers to run `npm run check-fixture-quad --verdicts`, which was not a name
2491
+ that existed.
2492
+ - **`version:check`, `version:surface:check`, `reporter:version-check`,
2493
+ `changelog:check` and `changelog:unreleased` are now `check-version`.** It is
2494
+ a file, not an npm chain, for a reason that bit during this change: `ci.yml`
2495
+ runs the unreleased-entry check with `--base=origin/<base>`, and npm appends a
2496
+ flag to the LAST command of a chain. A chain that is reordered by one line
2497
+ routes the flag to a check that ignores it, and the step keeps reporting PASS
2498
+ while comparing against an empty `git status` on a fresh CI checkout. The flag
2499
+ now has exactly one consumer, dispatched explicitly, and any other argument is
2500
+ a usage error rather than a silent no-op.
2501
+ - **`site:doctor` is now `doctor`**, and `doctor` runs the product's own
2502
+ self-audit before the site's, so the name a maintainer types is the name that
2503
+ diagnoses Mjölnir.
2504
+
2505
+ ### Carve 1.1 — twenty-two READMEs, and the gate that could only be met by deleting them
2506
+
2507
+ `README.<lang>.md` existed for 22 languages, machine-assisted, each about a
2508
+ dozen sections behind the English file. `docs/TRANSLATION-RATCHET.json` recorded
2509
+ `translationsNotFresh: 22` against a ceiling of 22 — so the ratchet could only
2510
+ ever be satisfied by deleting the thing it measured, and the maintainer had
2511
+ already documented that the failing branch was unreachable for the shape of
2512
+ regression it was written for. It was, in the end, a gate that measured nothing.
2513
+
2514
+ Deleted: the 22 files, the advisory report, the strict variant, the ratchet, the
2515
+ release-status sync, the shared language list, and the three specs that policed
2516
+ them. `README.md` is the only README. `docs/EXTERNAL-CONFIG.md` and the three
2517
+ gate rows in `gates/{pr,release,nightly}.json` were updated rather than left
2518
+ pointing at commands that no longer exist, and `tests/contract/gate-exit-codes.spec.ts`
2519
+ now asserts the machinery is _absent_ — a deleted gate is a hole with a comment
2520
+ on it, and the comment is the only thing that survives to be trusted.
2521
+
2522
+ The relic that went with them: `package/README.md`, a hand-maintained 778-line
2523
+ copy of the root README that nothing read and that had drifted 62 lines behind
2524
+ its own source. `pack-audit` matches tarball entries by name, so the fixture
2525
+ directory was never a real one.
2526
+
2527
+ ### Phase 2 — the matrix and the gaps: a claim, and a work queue
2528
+
2529
+ **`generate-certification-matrix`** writes `docs/CERTIFICATION-MATRIX.md` from
2530
+ three artifacts — the language manifest, the declared surface, and the cell
2531
+ records — and `--check` is a gate in all three tiers. The first output is
2532
+ **`0/100 cells certified`**, which is the honest number and not a failing state:
2533
+ a gate that blocks on progress reports nothing else.
2534
+
2535
+ The header says what the percentage means on every rendering, because a bare
2536
+ percentage beside a language name is exactly the misreading §5.4 warns about:
2537
+ _"the fraction of this ecosystem's required detection surface backed by
2538
+ certified evidence"_ — **not** how trustworthy a language is.
2539
+
2540
+ **`generate-certification-gaps`** turns each of those 100 open cells into an
2541
+ issue-ready entry: the ecosystem, the concept, the language and framework, the
2542
+ gate that fails, **the arithmetic of why**, and the floor that closes it. A gap
2543
+ entry that says "n too small" is a task description; one that says
2544
+ `precision n=0 is below 35` is a specification.
2545
+
2546
+ **Gate D was unfalsifiable, and the generated report is what showed it.** It
2547
+ failed any cell whose concept had more than one language binding — which means
2548
+ no cell of the three-language concepts could _ever_ pass, and 40 of the 100 open
2549
+ entries listed gate D. Language parity is a property of the SET, computed by
2550
+ the ecosystem roll-up in the matrix; a single cell cannot answer it. What a cell
2551
+ _can_ answer is whether it names a language its concept is bound to, which is
2552
+ what gate D says now.
2553
+
2554
+ **Seven missing manifest rows, added.** §5.1's table claims eleven ecosystems
2555
+ with none deleted, and seven had no row — so the matrix had nothing to print for
2556
+ GitHub Actions, Playwright, Cypress, Selenium, GitLab CI, Jenkins or Azure
2557
+ Pipelines. Every one is `DISCOVERED`, which is §5.1's own definition for "we
2558
+ have seen it exist"; `UNMEASURED` would claim a measurement nobody ran.
2559
+ `ECOSYSTEM_MANIFEST` and `ALL_ECOSYSTEMS` are the new names, with the old
2560
+ `LANGUAGE_MANIFEST` kept because four specs import it — renaming an export to
2561
+ fix a noun is a breaking change dressed as tidying.
2562
+
2563
+ **Three PENDING-CONSUMER rows deleted themselves**, which is the mechanism
2564
+ working rather than a nuisance. `validate.ts`, `record.schema.ts` and
2565
+ `language-manifest.ts` were allowlisted because nothing imported them; the
2566
+ moment the matrix generator imported them the orphan gate said _"an entry that
2567
+ no longer describes reality is a lie in a data file"_ and failed. An exemption
2568
+ that outlives its reason is how a gate starts protecting things nobody
2569
+ remembers.
2570
+
2571
+ ### Phase 2 — the §6 cell record: counts in, verdicts out, and what the schema refuses
2572
+
2573
+ **`src/certification/record.schema.ts`** — one record per
2574
+ `concept × language × framework`, as a JSON Schema plus a type-aware validator,
2575
+ following the repository's existing pattern (`src/config/config-schema.ts`)
2576
+ rather than buying a schema library for one file.
2577
+
2578
+ **Three things the schema refuses, and one it cannot.**
2579
+
2580
+ It refuses a stored Wilson bound in three places — the `not` clause, the
2581
+ `additionalProperties: false`, and the validator — because the plan's
2582
+ counter-example (`{fp: 5, n: 250, fpWilsonUpper: 0.02}`) reads as valid to
2583
+ anything that does not recompute it. It refuses one _nested_ inside the counts,
2584
+ which is where someone actually pastes a rate they just computed; a top-level
2585
+ check passes it. And it refuses `standard: "manifest-v5"` on a cell record.
2586
+
2587
+ It cannot express `n === tp + fp`, so the validator does. An `n` that disagrees
2588
+ with its own parts asserts something the evidence beside it does not support —
2589
+ the arithmetic twin of the stored-bound defect, and one nothing recomputes
2590
+ because an integer looks final.
2591
+
2592
+ **§5.2's two columns, spelled.** `CELL_STATES` is `v6-PENDING`,
2593
+ `v6-PARSEABLE`, `v6-CERTIFIED`, … and the validator rejects a bare
2594
+ `CERTIFIED`. TypeScript must read as `CERTIFIED (manifest-v5, n=240) · 0/9 cells
2595
+ (v6-abcdef, PENDING)` — the plan's own worked example, and the reason a cell
2596
+ state carries its standard in the name. A bare `PENDING` beside a `CERTIFIED` in
2597
+ the same row is a sentence two readers will contradict each other about.
2598
+
2599
+ **§6.4's re-attachment needs an arm.** `legacyVerdicts[].arm` exists because
2600
+ multi-arm rules are real here: `TQUAL-001` carries a no-assertion arm and a
2601
+ mock-only arm, `PW-124` a project-split arm and a config-gap arm. Keying on
2602
+ `(oldRuleId)` alone would attach half a rule's verdicts to the wrong arm.
2603
+
2604
+ ### Phase 2 — `EXPECTED_CERTIFICATION_SURFACE`: the denominator, declared
2605
+
2606
+ **`src/certification/surface-manifest.ts`** — 107 cells across all eleven §5.1
2607
+ ecosystems, each `REQUIRED` or `NOT_APPLICABLE` **with a reason**, plus
2608
+ `check-certification-surface` and its committed baseline.
2609
+
2610
+ Without a committed denominator, "60% → 100%" is achievable by deleting the
2611
+ five cells that were not certified. That is the one move that improves every
2612
+ number in the report without anybody writing a fixture, and no metrics-only
2613
+ report can tell it from progress.
2614
+
2615
+ Four properties, each with a test:
2616
+
2617
+ - **The surface cannot shrink silently.** The gate compares the manifest
2618
+ against a committed baseline, so a removal committed last month is still
2619
+ visible today. A gate that compared the manifest against itself could not
2620
+ see it at all — the change would already have passed.
2621
+ - **`REQUIRED` may grow freely.** Raising the bar is never gated; only
2622
+ shrinking is, and only with `--acknowledge-surface-change=<why>`, which moves
2623
+ the baseline and records the reason beside the new list.
2624
+ - **Every exclusion NAMES AN ALTERNATIVE.** `NOT_APPLICABLE` is a first-class
2625
+ state and it is printed, but a reason that says only why _not here_ is a
2626
+ deletion wearing a label. The gate rejects it; the plan's own example — a CI
2627
+ concept on a Python ecosystem — shows the real shape.
2628
+ - **No cell names a concept the vocabulary does not have.** This caught three on
2629
+ the first run: `frameloctor-…`, `ignored-exit-code-or-true` and
2630
+ `trial-click-…` were ids I guessed rather than read, and a `REQUIRED` cell for
2631
+ an undefined concept is a denominator entry nobody can ever close.
2632
+
2633
+ **The three wave-C ecosystems with zero corpus repositories STAY.** Deleting
2634
+ GitLab CI, Jenkins and Azure Pipelines would improve every percentage in the
2635
+ report by removing the ecosystems nobody has measured. §5.1 retains all
2636
+ eleven; a wave is a ranking, not a deletion.
2637
+
2638
+ **Two defects the spec found in the gate it was written for.**
2639
+
2640
+ The gate computed `removed` and `added` with the _same_ filter over the current
2641
+ keys — so `removed` was really the added set, and a genuine shrink was
2642
+ invisible. The gate that exists to catch a shrinking denominator did not
2643
+ detect one. The fixture shrank the surface by a cell and the gate said PASS,
2644
+ which is how it was found.
2645
+
2646
+ And the gate **swallowed a failure to read its own manifest**: with no
2647
+ `node_modules` in the fixture tree the `tsx` spawn failed, the error was
2648
+ ignored, and it printed `PASS — 107 cells`. A gate that reports a pass because
2649
+ it could not read its input is the loudest possible wrong answer, so an
2650
+ unreadable manifest is now exit 10 and says so. The fixture needed a
2651
+ `node_modules` junction for a different reason: it copied the gate and then
2652
+ invoked the _real_ one, which computes its root from its own location — so the
2653
+ mutation was applied to a copy nobody read, for three runs.
2654
+
2655
+ ### Phase 2 — the concept vocabulary, and the §6 contract that judges a cell
2656
+
2657
+ **`src/certification/concepts.ts`** — 60 concepts covering all 79 rules,
2658
+ **generated** from the registry by `scripts/generate-concepts.ts`, **committed**
2659
+ for review, and **verified** by `tests/contract/certification-concepts.spec.ts`.
2660
+ The registry is the authority; the table is a claim about it a reader can check.
2661
+
2662
+ A concept is a failure MODE and a rule is that mode in one language. The
2663
+ derivation is mechanical because the titles in this tree already assert the
2664
+ grouping: `QA-TEST-004` ("Hard sleep in test", TypeScript) and `QA-PY-005`
2665
+ ("time.sleep() in test", Python) are different rules for the same concept, and
2666
+ §6 certifies `concept × language × framework` — so a concept that split them
2667
+ would certify one detector twice and report two cells where there is one.
2668
+
2669
+ It yields 60 concepts where the plan says 32, because the plan's concepts are
2670
+ COARSER: it calls `test-cannot-fail` one concept, the tree calls it five ("Test
2671
+ without assertions", "Empty test body", "Skipped test", "Focused test",
2672
+ "Disabled test"). Both are defensible and they measure different things, so
2673
+ the fine cut is committed and the coarse groupings are expressible as a VIEW
2674
+ over it — additive and reviewable, where merging rows is not. The count is
2675
+ reported, not gated, which is the plan's own rule for counts.
2676
+
2677
+ **`src/certification/validate.ts`** — §6 as code: six independent gates, counts
2678
+ in and verdicts out.
2679
+
2680
+ **Counts are stored; intervals never are.** `CellEvidence` has no
2681
+ `fpWilsonUpper` field and `assertCountsOnly` rejects a JSON record that has
2682
+ grown one — walking the TREE, because the first version checked only the top
2683
+ level and `fpRate` nested inside `precision` is exactly where someone would
2684
+ paste it. The plan's own counter-example is `{fp: 5, n: 250}` with a stored
2685
+ 0.02 upper bound: it looks valid to anything that does not recompute it.
2686
+
2687
+ The thresholds are pinned by tests, because they are not round numbers:
2688
+ `n ≥ 35` is where a PERFECT 35-of-35 clears both a `≤ 0.10` FP upper bound and a
2689
+ `≥ 0.90` recall lower bound — `wilson(0,35) = 0.0989` and `wilson(35,35) =
2690
+ 0.9011`. That coincidence is the entire justification for the concept bar, and
2691
+ `n ≥ 34` failing is asserted beside it, because a threshold nobody fails is not
2692
+ a threshold.
2693
+
2694
+ **Three wrong readings of the Wilson bound, and the tests caught all three.**
2695
+ `wilsonInterval(x, n)` is a proportion of SUCCESSES. For precision the successes
2696
+ are the FALSE POSITIVES; for recall they are the TRUE POSITIVES. Reading `fn`,
2697
+ or reading the complement `n - tp`, gives a lower bound of exactly **0** on a
2698
+ perfect cell — the Wilson bound at p = 0 is 0 — so every cell in the repository
2699
+ fails gate B. The plan's own numbers settle it: `wilson(35,35) = 0.9011` is a
2700
+ lower bound on a cell with no failures.
2701
+
2702
+ The tiering is asserted as a RELATIONSHIP rather than a comparison: the same
2703
+ 24-of-24 evidence clears the cell bar (0.862 ≥ 0.80) and fails the concept bar
2704
+ (0.862 < 0.90, and `n = 24 < 35`). That is the reason §5.5b has two tiers, and
2705
+ an assertion comparing a bound to a count would have passed for no reason at
2706
+ all — which is what the first version of that test did.
2707
+
2708
+ **One commit-time self-caught defect.** Registering the two new npm scripts, the
2709
+ first draft was `check-concepts: "… generate-concepts.ts"` plus
2710
+ `generate-concepts: "… generate-concepts.ts --write"` — two names, one file,
2711
+ one command. That is the exact straggler pattern Phase 0 existed to remove,
2712
+ committed by the person removing it, and caught by the gate built for the
2713
+ purpose.
2714
+
2715
+ `validate.ts` is on the orphan list as `PENDING-CONSUMER`, with the reason that
2716
+ its first production caller is `generate-certification-matrix.ts`. It is filed
2717
+ as pending rather than as an exemption on purpose: this gate fails an allowlist
2718
+ row that something now IMPORTS, so the row disappears the moment the matrix
2719
+ generator lands.
2720
+
2721
+ ### Carve 1.6.1 — the order-dependent pair, and why they are still reachable
2722
+
2723
+ The plan marks `historical-trust.ts` and `machine-contract-verification.ts` as
2724
+ deletable **only** after `commands/milestone.ts` stops needing them, and says to
2725
+ do it here rather than in 1.5 for exactly that reason. Every refactor that
2726
+ removed a verb left `milestone.ts` as the home of the survivors, so two modules
2727
+ on the delete list stayed reachable through a filename that no longer described
2728
+ what the file was.
2729
+
2730
+ `commands/milestone.ts` went from 1,049 lines to 463, split along the seams it
2731
+ already had:
2732
+
2733
+ - **`milestone-args.ts`** — the arg parser, the target validator and the
2734
+ workflow-file check. Seven commands used them and they are **shared**, not
2735
+ copied seven times: a flag rule that exists twice can disagree with itself,
2736
+ and the copy a pipeline runs is the one that has to be right.
2737
+ - **`contract-verify.ts`** — the ~280 lines of nested type guards that decide
2738
+ whether a file on disk is the document the tool claims it wrote, plus the
2739
+ command.
2740
+ - **`release-trend.ts`** — the trust-snapshot history and `ci release-trend`.
2741
+
2742
+ **The two engine modules stay, and `docs/cli-contract.json` records why with
2743
+ their importer named.** `contract-verify` is a live verb (the plan's merge into
2744
+ `ci verify` is a behaviour change to a live command, recorded as
2745
+ `plannedTarget` rather than done), and `ci release-trend` is a live subcommand.
2746
+ The delete list was written when those verbs were going away; the capability
2747
+ moved rather than vanished. "On a delete list" and "reachable from three
2748
+ places" are both true, and only one of them is a reason to delete.
2749
+
2750
+ A range extraction cannot see what a slice depended on, and the compiler listed
2751
+ all 300 resulting errors. Four of them are worth recording because each is a way
2752
+ this goes wrong quietly:
2753
+
2754
+ - `ContractDocument` is `ScanResult & { contract }`, not a wrapper with a `scan`
2755
+ field. I guessed the wrapper. A persisted contract artifact is the scan with
2756
+ its own verdict attached, and a reader that has to know which of the two
2757
+ shapes it holds is a reader that will hold it wrong.
2758
+ - `MilestoneArgs` was declared _above_ the extracted range, so the module that
2759
+ PARSES it no longer contained it. A type with three homes is a type with
2760
+ none; it belongs where the parsing is.
2761
+ - `STRICT_SUPPRESSION_POLICY` sat immediately above the range and stayed behind
2762
+ with its only reader gone — moved down to it.
2763
+ - `ContractDocument` ended up declared in two of the new files, because a
2764
+ declaration and its first use can straddle a boundary. Two identical copies
2765
+ are invisible to the compiler, which is the reason to look for them by hand.
2766
+
2767
+ ### Carve 1.6 (MOVE arm, group 3) — three internal verbs folded, two arrows that do not connect
2768
+
2769
+ `pw-report`, `framework-maturity` and `doctor:playwright` all fold into flags —
2770
+ `explain --playwright`, `doctor --frameworks`, `doctor --frameworks` — which is
2771
+ what the plan calls INTERNAL for all three: none is a capability a reader is
2772
+ meant to reach for. `doctor --frameworks` is a FLAG and not a second
2773
+ `argv[0] === "doctor"` branch, because `tests/contract/readme-commands.spec.ts`
2774
+ reads those literals as the known subcommand set and one verb with two entries
2775
+ in it is a set that no longer means what it says.
2776
+
2777
+ **Three of the plan's arrows do not connect, and the contract says so.**
2778
+
2779
+ - `contract-verify` → `ci verify`. `ci verify` is the BLOCKING CHECK and does
2780
+ not run the machine-contract or exit-code validators. Folding it is a
2781
+ behaviour change to a live command, not a rename, so the verb, the
2782
+ implementation and its eleven tests stay and the row reads
2783
+ `plannedTarget: ci verify` with the reason.
2784
+ - `analyze` → `explain`. `analyze` is CROSS-FILE analysis — shared imports,
2785
+ duplicated blocks, circular dependencies — and its findings reference
2786
+ multiple files. `explain` answers "what does this finding mean"; making it
2787
+ answer "which files import each other" would give a verb a capability it
2788
+ does not have.
2789
+ - `handoff` → `install agent-rules`. `install` writes agent INSTRUCTION
2790
+ surfaces; `handoff` renders a deterministic remediation plan from a saved
2791
+ report. One configures an environment, the other renders a document an agent
2792
+ can execute.
2793
+
2794
+ **Two routing bugs the collapse introduced and this step fixed.**
2795
+
2796
+ - `mjolnar ci install --help` started running the installer. The `--help` rule
2797
+ was positional in its first form and had a second special case for the
2798
+ three-token case; the collapse generalised the first and deleted the second,
2799
+ taking the `ci install --help` path with it. The rule is now "a `--help`
2800
+ anywhere in the invocation asks for help", which is the rule that was always
2801
+ true.
2802
+ - `mjolnar ci verify --help` rendered **`ci install`'s** page, because the
2803
+ dispatcher built the subject as `ci:install` and the catalogue keys it
2804
+ `"ci install"` — a space, because no verb name contains one. A `:` was
2805
+ chosen to avoid colliding with `doctor:playwright`, which read as clever
2806
+ right up to the moment it sent a reader to a page that does not exist.
2807
+
2808
+ The root help also no longer prints a section whose verbs are all retired: the
2809
+ collapse emptied two groups and left the headings, and a table of contents that
2810
+ promises a section it does not have is the same class of claim as a command
2811
+ that does not exist. The `GROUPS` list itself was rewritten to name the fourteen
2812
+ entries the catalogue actually has — it used to list twenty-nine, of which the
2813
+ renderer silently skipped twenty, so the file read as a graveyard.
2814
+
2815
+ `tests/cli/help.spec.ts` gained a sweep that renders every catalogue entry and
2816
+ both composite pages and asserts none names a retired verb, reading the
2817
+ retirements out of `docs/cli-contract.json` rather than listing them. The
2818
+ pattern covers slash-separated tool lists as well as invocations, because the
2819
+ `mcp` line carried three retired names in that shape and the narrower sweep was
2820
+ blind to it.
2821
+
2822
+ ### Carve 1.6 (MOVE arm, group 2) — five capabilities became `explain` arms
2823
+
2824
+ `explain` is the question verb. Five capabilities were top-level verbs that all
2825
+ answer a variation of that question, and each is a flag on it:
2826
+
2827
+ | Was | Now | |
2828
+ | ------------ | --------------------- | ----------------------------------------------------- |
2829
+ | `rules` | `explain --list` | the rule catalogue, with each rule's measured FP rate |
2830
+ | `why` | `explain <file:line>` | already an arm — see below |
2831
+ | `cross-file` | `explain --callers` | who calls this symbol |
2832
+ | `handover` | `explain --plan` | who owns what, and what to do next |
2833
+ | `triage` | `explain --evidence` | runtime evidence from a real run |
2834
+
2835
+ **`why` never moved.** `explain` has delegated the `file:line` subject form to
2836
+ that command since before the collapse, so retiring the verb removed a second
2837
+ name for a capability `explain` already had. The cheapest MOVE in the set and
2838
+ the one most likely to be got wrong: `mjolnir why file.ts:12` is now a usage
2839
+ error, and the fix is a different word in the same position.
2840
+
2841
+ `--playwright` is the internal arm (the plan says so), and it is marked as such
2842
+ in the table. `pw-report` summarised a Playwright run for a person reading CI
2843
+ output; the same information arrives through `--evidence` once that arm reads a
2844
+ report rather than one runner's format, and one flag that means "the run
2845
+ summary" beats two that mean it for one of them.
2846
+
2847
+ The arms table has the same shape as `CI_SUBCOMMANDS` and for the same reason:
2848
+ it is the single source the dispatcher reads **and** the help renders from.
2849
+ Arms are dispatched **before** the subject is read, because `--list` and
2850
+ `--plan` take no subject and the subject check is what `mjolnar explain --plan`
2851
+ would otherwise have hit.
2852
+
2853
+ **One residue worth naming.** `cli-handlers.ts` carried
2854
+ `import {} from "./forensics/triage.js";` — an empty import left behind when the
2855
+ `triage` VERB was retired, because every specifier in that statement became
2856
+ unused and the statement survived. Legal TypeScript, imports nothing, and it
2857
+ points at the file a maintainer would go looking in for where triage lives.
2858
+
2859
+ The generator of the 101 rule-doc footers was fixed before the files were, so
2860
+ `mjolnir rules --md` did not come back on the next `npm run generate-rule-docs`.
2861
+ The 22 PRESERVED retired-rule pages still name it, correctly: they record what
2862
+ a rule said while it existed, and the gate now skips a rule doc whose ID is not
2863
+ in the live registry — a fact read from the registry, not a pattern exemption.
2864
+
2865
+ ### Carve 1.6 (MOVE arm, group 1) — five verbs became `ci <subcommand>`
2866
+
2867
+ `ci` is the right home for all five, and the word is not incidental: each one is
2868
+ a check a pipeline runs, and `ci` is already the verb whose meaning is "the
2869
+ thing a pipeline does".
2870
+
2871
+ | Was | Now | |
2872
+ | ----------------- | -------------------- | ------------------------------------------------------- |
2873
+ | `ci-adapter` | `ci adapters` | which ecosystems can be analysed, and how well |
2874
+ | `ci-integrity` | `ci integrity` | the generated workflow is itself correct |
2875
+ | `verify` | `ci verify` | run the blocking check and the suppression policy |
2876
+ | `release-trust` | `ci release-trust` | the signed measurement release's trust record |
2877
+ | `trust-trend` | `ci release-trend` | how that record has moved across releases |
2878
+ | `contract-verify` | `ci verify` (merged) | its two checks are the judgement `verify` already makes |
2879
+
2880
+ `integrity` and `verify` are the sharpest case in the whole collapse: as
2881
+ top-level verbs, `mjolnir integrity` and `mjolnir verify` read as two
2882
+ products that happened to share a surface nobody could see from the command
2883
+ list. `contract-verify` is recorded as a **merge with its destination named**,
2884
+ not a rename — "the checks went somewhere" has to be checkable.
2885
+
2886
+ **The table is the claim.** `CI_SUBCOMMANDS` in `src/cli.ts` is one object, the
2887
+ dispatcher reads it by name, and `mjolnir ci --help` renders from it — so a
2888
+ subcommand that exists is documented and one that is documented exists. A
2889
+ dispatcher with five inline `if` arms plus a separate help string is how the two
2890
+ drift apart, and this repository has that shape somewhere in every other
2891
+ subsystem. An unknown subcommand names the known ones and exits 10.
2892
+
2893
+ Two routing bugs the collapse fixed on the way: `ci --help` rendered the ROOT
2894
+ help, and `ci verify --help` rendered **`ci install`'s** help, because the
2895
+ `--help` rule hardcoded `["ci", "install"]`. Both are now derived from the
2896
+ request rather than from a constant.
2897
+
2898
+ ### Carve 1.6 (REPLACE arm) — four verbs became flags, and two could not
2899
+
2900
+ `scan` gained three `--format` values and three modes:
2901
+
2902
+ | Retired verb | Replacement | What the flag is |
2903
+ | ------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------- |
2904
+ | `trust-report` | `scan --format trust-report` | the terminal render, spelled out |
2905
+ | `pr-comment` | `scan --format pr-comment` | the same renderer plus the stored-baseline diff, so a comment can still say RESOLVED |
2906
+ | `summary` | `scan --format github-summary` | named after its CONSUMER, because `summary` was ambiguous with the terminal's own summary band |
2907
+ | `suppressions` | `scan --suppressions` | the ledger, now reading the TARGET rather than the cwd |
2908
+ | `suppression-gate` | `scan --suppression-gate` | **delegates** to the command; the flag lands first |
2909
+ | `policy` | `scan --policy` | the SCORING table, printed from the constants |
2910
+
2911
+ `scan --policy` is the new file here: `src/scorer/scoring-policy.ts` imports
2912
+ `NORMALIZATION_K`, `SMOOTHING_C`, both ceilings and the deduction-mass bands
2913
+ and renders them, so the table cannot drift from the code that scored the run.
2914
+ The `NORMALIZATION_K` line says **declared, not fitted** in the table itself,
2915
+ because printing a bare `5` among measured numbers reads as a measurement, and
2916
+ it is not one.
2917
+
2918
+ **Two of the plan's REPLACE rows were not honest replacements, and are recorded
2919
+ as pending rather than done.** `stats → scan --json` would replace a milestone
2920
+ ledger with a scan result — a different document wearing the same name. `rules →
2921
+ explain --list` and `why → explain <file:line>` name arms that do not exist
2922
+ yet. And `policy` keeps its verb, because `mjolnir policy init` and
2923
+ `validate` manage `mjolnir.policy.json`, a file that gates CI; folding a
2924
+ subcommand tree into a flag would lose both. Each row in
2925
+ `docs/cli-contract.json` now says what is missing, because "the plan says so" is
2926
+ not a disposition.
2927
+
2928
+ `--suppression-gate` delegates rather than reimplementing: a governance rule
2929
+ that exists twice can disagree with itself, and the flag and the command are
2930
+ the same judgement, so the flag synthesises argv and calls it.
2931
+
2932
+ ### Carve 1.6 slice 2 — `mutation`, `forensics` and `triage`, and a directory that had to stay
2933
+
2934
+ The `mutation` verb is gone with `src/mutation/` (501 lines): reading Stryker
2935
+ and Mutmut reports is reading another tool's opinion of the code, and the
2936
+ directory had no importer but the verb's own runner. Two fuzz harnesses are
2937
+ narrowed rather than deleted, and the loss of mutation-format fuzz coverage is
2938
+ recorded here rather than left in a file that was about to be deleted.
2939
+
2940
+ **`src/forensics/` stays.** The plan makes `forensics` a DELETE, and the verb
2941
+ is deleted — but the import graph says the directory is not dead:
2942
+ `evidence-hygiene.ts` is imported by `commands/trust-report.ts` and
2943
+ `integrations/github/evidence-sanitization.ts`, the path the GitHub Action's PR
2944
+ comment goes through, and `triage.ts` is imported by `mcp/server.ts` under a
2945
+ parity contract. Deleting 2,600 lines by name would have broken the PR commenter
2946
+ and the MCP server, and the diff would have been green. That is the plan's own
2947
+ "names are not evidence" applied to a directory the plan did not enumerate.
2948
+
2949
+ `triage` is a MOVE to `explain --evidence` and the flag lands with the next
2950
+ slice, so the contract records `targetStatus: PENDING` rather than claiming the
2951
+ capability has arrived. The verb goes now so nothing can depend on it; the
2952
+ capability arrives when the flag does.
2953
+
2954
+ **The generated output was naming the dead tool at users.** `FLAKY.md` and
2955
+ `TRIAGE.md` stamp themselves "Generated by `mjolnir triage`" — a string
2956
+ written into a file a user commits to their repository, asserting a command
2957
+ ran. Both now name Mjölnir and the data source instead, which is both true and
2958
+ stable across the verb's next move.
2959
+
2960
+ One source-layer miss worth recording: the rule `fix` strings that told users
2961
+ to run the retired verbs live in `src/rules/`, and I edited the _generated_
2962
+ `docs/rules/*.md` first. `npm run generate-rule-docs` would have put them
2963
+ straight back — the same class of bug as the hardcoded `@v3` in the
2964
+ translation sync, one level up. The rules are the source; the docs, the site
2965
+ pages and the demo report are all downstream of them, and changing a generated
2966
+ file without changing its generator is a decision that survives exactly until
2967
+ the next regeneration.
2968
+
2969
+ Two artifacts turned out to be hand-maintained with no writer:
2970
+ `assets/readme/demo-report.json` (four readers, no generator) and
2971
+ `packages/playwright-reporter/` (unpublished, 0.1.0, its whole description
2972
+ about the three retired verbs). Both are noted rather than quietly patched; the
2973
+ reporter is a Phase 7 decision, since its output format is still what the
2974
+ evidence path will read.
2975
+
2976
+ ### Carve 1.6 (DELETE arm) — ten verbs removed, and every place that named one
2977
+
2978
+ `badge` · `debt` · `impact` · `trend` · `exec-report` · `dashboard` ·
2979
+ `baseline` · `diff` · `create-rule` · `init` are gone: the command module, the
2980
+ `cli.ts` `VERBS` row, the `argv[0] === "…"` literal that
2981
+ `tests/contract/readme-commands.spec.ts` reads, the import and re-export lists,
2982
+ the `cli-handlers.ts` runner, the help-catalogue entry, and 117 tests across 27
2983
+ spec files — four of which were deleted outright because the removed verb was
2984
+ their entire subject.
2985
+
2986
+ **A verb's disposition is about the verb, not the module.** `baseline` is the
2987
+ case that would have shipped a green build and a broken product: the module is
2988
+ imported by `stats.ts`, `verify.ts`, `mcp/server.ts` and
2989
+ `reporter/pr-report-shared.ts`, so deleting it with the verb would have broken
2990
+ the MCP server, the PR reporter and `verify`. The module survives as a shared
2991
+ reader with a header that says so; the verb does not, because a stored
2992
+ "yesterday was fine" is the same trust-the-green-check failure the product
2993
+ exists to catch. That is the plan's own instruction — _names are not evidence_ —
2994
+ applied to an object it did not name.
2995
+
2996
+ **The suggestions the product printed were the bug nobody filed.** The
2997
+ reporter's `nextStep` strings are not documentation; they are the next thing
2998
+ printed into the terminal. `nextStep("mjolnir diff")` in a product whose `diff`
2999
+ was retired in the same commit is a command that does not exist, in the one
3000
+ place a user is guaranteed to be looking. `check-cli-contract` treats `src/`
3001
+ as a live surface for exactly this reason, and it is why `src/` was swept too
3002
+ rather than left to a later documentation pass.
3003
+
3004
+ **The contract is now a record of removals, not only of presences.**
3005
+ `docs/cli-contract.json` gained `retiredVerbs`, and the gate learned to check
3006
+ it: a `DELETE` row for a verb that has left `cli-command-names.ts` must appear
3007
+ in `retiredVerbs` with the commit that made it, and a `retiredVerbs` entry for
3008
+ a verb that is still registered fails too. Ten verbs retired; the gate now
3009
+ fails any README, site page, workflow, Action or source string that names one.
3010
+
3011
+ Follow-through, all of it found by gates rather than by looking: three coverage
3012
+ exemptions whose subject is gone, three `vitest.config.ts` coverage exclusions,
3013
+ two degradation exemptions, the dashboard-template assertion (which asserted
3014
+ "exactly one generator" and became vacuous when the generator was deleted, so
3015
+ it now asserts the invariant that still has teeth — that two renderers cannot
3016
+ both claim a band), and the demo asset, regenerated.
3017
+
3018
+ `forensics` stays. The plan makes it a DELETE too, and it will be — its
3019
+ subsystem and the `triage` relocation into `explain --evidence` are the next
3020
+ slice. Its row says KEEP until they land, because a contract that asserts a
3021
+ deletion the binary does not honour is worse than one that is merely out of
3022
+ date.
3023
+
3024
+ ### Carve 1.5 — 14,986 lines of unwired modules, and one the plan got wrong
3025
+
3026
+ Nineteen modules with zero importers, all nineteen already on the committed
3027
+ orphan list, all nineteen with a dedicated spec file that imported nothing but
3028
+ them. A module with a test and no caller is the shape that makes dead code look
3029
+ alive: `tests/` counted as coverage of a file the product never reached.
3030
+
3031
+ Deleted: `evidence-artifacts` · `finalize-scan-result` · `pipeline-stages` ·
3032
+ `semantic-model-api` · `runtime-evidence-graph` (1,857 lines) ·
3033
+ `runtime-static-correlation` · `coverage-ingestion` · `evidence-enforcement` ·
3034
+ `reporter/sarif-compliance` · `m38-challenge-contract` ·
3035
+ `m43-system-of-systems` · `m44-historical-intelligence` (2,247) ·
3036
+ `m49-experience-parity-contract` · `bench/m48-scale-operating-model` ·
3037
+ `governance/m33-m34-contract` (2,745) · `qa/domain-model` ·
3038
+ `frameworks/universal-pack-contract` · `plugins/sdk-contract` ·
3039
+ `mutation/failure-sensitivity` — and their twenty specs. Nineteen stale
3040
+ allowlist rows went with them, because a row describing a file that no longer
3041
+ exists is the same lie as a row describing a file something now imports.
3042
+
3043
+ **The plan listed a twentieth as a true orphan, and it is not one.**
3044
+ `src/engine/evidence-graph.ts` has two importers and is shipped-reachable —
3045
+ `src/commands/milestone.ts` is one of them, which is the command that serves
3046
+ the `evidence-graph` verb. Deleting it would have removed the only way to reach
3047
+ a query surface the §3 table does not even mention. It stays, and
3048
+ `docs/cli-contract.json` now records the import-graph evidence rather than the
3049
+ plan's assertion.
3050
+
3051
+ The check that caught it is worth naming, because the first version of it was
3052
+ wrong: it resolved the plan's bare filenames (`evidence-graph.ts`) against
3053
+ `src/`, where they do not live, so the import graph returned an empty importer
3054
+ list for all twenty and every one of them looked like a safe deletion. The
3055
+ files are under `src/engine/`, `src/bench/`, `src/governance/`, `src/qa/`,
3056
+ `src/frameworks/`, `src/plugins/` and `src/mutation/`. A graph lookup that
3057
+ answers "no importers" for a path that does not exist is not evidence of
3058
+ anything, and the plan's own instruction — _names are not evidence_ — applies
3059
+ to the plan's file list exactly as it applies to the tree.
3060
+
3061
+ ### Carve 1.3 — the brand self-audit survives, and its header stopped lying
3062
+
3063
+ `npm run brand:doctor` is a nine-rule self-audit over a six-node graph, and
3064
+ it still passes 9 of 9 with an empty `KNOWN_OPEN` ratchet. The tokens stay:
3065
+ `src/brand/tokens.ts` is load-bearing for the reporter, the site theme, the
3066
+ README generators and the mark hashes, and `assets/brand/tokens.json` is
3067
+ byte-locked against it.
3068
+
3069
+ What changed is the gate's own description of the tree. It said the badge
3070
+ drift appeared "in all 23 READMEs" and referred to a `site-doctor` check by a
3071
+ name that no longer exists. "Every README" is now one file plus five generated
3072
+ bands, and the rule did not weaken to reach that number — the twenty-two
3073
+ translations were never a distinct edge, they were copies of the same badges,
3074
+ and the copying is what made the drift expensive.
3075
+
3076
+ Site gate after the step: `gen`, `test`, `doctor` (0 of 8 failing), `build`,
3077
+ with no generated drift.
3078
+
3079
+ ### Carve 1.2 — the 86-second tour drew a command the product will not have
3080
+
3081
+ `assets/video/script.tour.json` rendered `mjolnir forensics
3082
+ ./test-results/`, and `forensics` is a DELETE verb in the v6 CLI collapse
3083
+ (plan §3). A committed render script is evidence: it is what the renderer
3084
+ is allowed to draw, and it was drawing a command that will not exist.
3085
+
3086
+ Removed: `captureTourScript()`, the `tour` script id, `script.tour.json`,
3087
+ the `tour` arm in three contract specs, the `both|demotour` choice on the
3088
+ `demo-video` workflow, and the tour rows in the video README. Order mattered —
3089
+ this runs before the CLI collapse so the three specs that re-run the capture
3090
+ and fail on drift stay green instead of going red on a verb the carve was
3091
+ about to remove.
3092
+
3093
+ The hero stays. Its story — a false-green CI gate found, fixed, re-proved —
3094
+ is the whole positioning, and it uses only verbs the collapse keeps.
3095
+
3096
+ One behaviour change while the file was open: rendering no longer overwrites
3097
+ the committed MP4. `publishDemo()` used to run unconditionally after any
3098
+ render, so a manual workflow dispatch replaced two binaries the README links
3099
+ to without asking. Publishing is now an explicit `--publish`, which
3100
+ `npm run docs:video:render` passes, so regenerating the shipped asset is
3101
+ unchanged for a human and safe by default for a workflow.
3102
+
3103
+ ### The Action major tag every reader was told to pin did not exist
3104
+
3105
+ `action.yml` documented `uses: Sergey-Bar/Mjolnir@v3` while the published stable
3106
+ was **5.0.0** — and `scripts/readme-release-status.mjs` did not merely inherit
3107
+ that, it re-wrote it. Every translation sync ran
3108
+
3109
+ ```js
3110
+ replaceAll("Sergey-Bar/Mjolnir@v1", "Sergey-Bar/Mjolnir@v3");
3111
+ ```
3112
+
3113
+ so twenty-two translated READMEs instructed every non-English reader to pin a
3114
+ tag the repository stopped moving at 3.x, and the next sync put it back. A
3115
+ hardcoded major is a syntactically valid ref; it fails for the reader as a 404
3116
+ and for nobody in this repository.
3117
+
3118
+ The sync now derives the major from the released version, and
3119
+ `tests/contract/census-drift.spec.ts` sweeps every live surface for a
3120
+ `Sergey-Bar/Mjolnir@vN` that is not the published major, with the same
3121
+ dormant-shape and match-count discipline the census sweep already uses.
3122
+
3123
+ ### The carve manifest: 28 files, and the one that mattered
3124
+
3125
+ Phase 1 removes ~10,000 lines across twenty areas. The cheapest way for that to
3126
+ go wrong is for a reviewer to trust a folder name, and the plan's own evidence
3127
+ says not to: `src/ledger/` reads like bookkeeping and is 2,856 lines of M26
3128
+ validators that four scripts import. So `docs/CARVE-MANIFEST.json` records, per
3129
+ file, who imports it, whether it ships, which specs name it, and how big it is —
3130
+ generated facts, hand-written dispositions that survive regeneration, and a gate
3131
+ (`npm run check-carve-manifest`) that fails when the two disagree.
3132
+
3133
+ All 28 files start at KEEP. Nine have no importer; every one of them was already
3134
+ on the disclosed orphan list, so the manifest found no new dead code — it found
3135
+ the size of the code that a directory name would have hidden.
3136
+
3137
+ The gate also fails on the case a directory-based review cannot see: a file that
3138
+ left the tree without a DELETE disposition. That is the difference between a
3139
+ reviewed removal and an accident, and it is one field.
3140
+
3141
+ ### Claim integrity — the gates that could not fail
3142
+
3143
+ Every item here is a check that was running, reporting, and could not have
3144
+ failed. The pattern is one defect wearing a dozen hats: a value that looked
3145
+ load-bearing and held nothing.
3146
+
3147
+ - **`coverage:ratchet` measured a truncated run.** It read
3148
+ `summary.total` and never counted files, so a two-file run of a 292-file
3149
+ tree passed at 97.92% — and a partial run reports _higher_ percentages than
3150
+ a complete one, because the files that survive are the easy ones. It now
3151
+ asserts a file-count ratio and a non-trivial denominator, both fail-closed.
3152
+ - **`nForZeroFp` said 1.** The expression `z²·p / (p·(1−p)) − z²` simplifies to
3153
+ `z²·p/(1−p)` ≈ 0.427, and `Math.ceil` turned that into 1 — so every
3154
+ straddling rule read "About 1 clean sample would settle it". The correct
3155
+ value at the 10% ceiling is **35**.
3156
+ - **`docs/rules/` and the registry both claimed the core tier, differently.**
3157
+ `effectiveTier` now resolves it everywhere, including dedup survivor
3158
+ resolution, where an undeclared rule defaulted to `core` and outranked every
3159
+ rule that had declared `extended`.
3160
+ - **The capability registry resolved evidence once for the whole registry.**
3161
+ One measured rule anywhere made `hasMeasurement` true for every capability,
3162
+ and `ci.provider.azure-pipelines` was advertised on an unrelated Java rule's
3163
+ measurement. Evidence is resolved per entry now.
3164
+ - **`OVER_CLAIMED_MATURITY` compared a value with itself.** `proven` and
3165
+ `maturity` were assigned from one call, so the gate could not fire. They now
3166
+ come from two resolvers — the census declares, the registry proves — and a
3167
+ provider whose evidence beats the ledger is visible as such.
3168
+ - **`STALE` was unreachable.** The FP-audit generator filtered stale
3169
+ measurements out of `MEASURED_FP`, so `hasStaleMeasurement` was
3170
+ structurally always `false` and `QA-PY-004` (42 hand-classified verdicts,
3171
+ one detector revision behind) reported as never sampled. `MEASURED_FP_RAW`
3172
+ keeps them and both consumers read it.
3173
+ - **The claim registry had no recency budget.** `observedAt: null` satisfied
3174
+ its presence check, so a 2026-06 observation read like yesterday's.
3175
+ Per-type budgets now apply, the digest check runs against a real status
3176
+ instead of a value absent from the enum, and every `BLOCKED` claim carries a
3177
+ `blockedReason` and a review date.
3178
+ - **The tier ratchet was vacuous on an empty tier.** `MAX_UNMEASURED_CORE = 0`
3179
+ was satisfied by a set with no members, so the first core promotion would
3180
+ have arrived with the check already green. A rule is now accounted for by ≥10
3181
+ classified verdicts **or** by an unexpired `corePromotion` naming an owner.
3182
+ - **The anti-creep law had two escapes.** The exception marker was searched
3183
+ for across the whole append-only changelog, so one marker in any past
3184
+ release disabled the ratchet for good — and with an empty tier and a zero
3185
+ baseline, the first promotion would have switched the law off permanently.
3186
+ The comparison is also now against `previousBaselineCore`, so lowering the
3187
+ baseline to match a grown tier no longer hides the growth.
3188
+ - **The census could call an ecosystem `SUPPORTED` with a fixture
3189
+ directory as its only evidence.** `SUPPORTED` now requires an adapter **and**
3190
+ a verified four-leg quad, which demoted 33 entries — the honest result, and
3191
+ the reason 19 rules were demoted in 6.0 for the same reason.
3192
+ - **Two hardcoded-`false` quad resolvers, in two files.** They could not fail
3193
+ and the census's directory-size proxy answered a different question from the
3194
+ registry's `false`. Both now read one filesystem probe.
3195
+ - **Seven gates ran only locally.** `qa-ir:parity` and `check-unimported-modules` were
3196
+ on a nightly cron, so a PR that broke either passed CI and failed on the
3197
+ maintainer's machine. The other seven are new here. All nine are in
3198
+ `gates:claim-integrity`, which `ci.yml` runs as one step — named per gate
3199
+ inside the chain so a failure identifies itself — and declared in all three
3200
+ `gates/*.json` tiers.
3201
+
3202
+ ### New gates
3203
+
3204
+ `npm run gates:claim-integrity` chains the nine claim-integrity checks so a
3205
+ failure names its gate instead of a step number. Also new: `workflow:scripts`,
3206
+ `config:consumers`, `scripts:reachable`, `translations:ratchet`,
3207
+ `docs:provenance-drift`, `check-fixture-quad`, `rules:promotion:check`,
3208
+ `check-changelog`.
3209
+
3210
+ ### Deliberate omissions
3211
+
3212
+ - **The 4-leg fixture backfill (148 legs) is not in this change.** The plan
3213
+ requires the quad to be _executed_, not merely present; writing 148 legs
3214
+ no detector had run against would have made `check-fixture-quad` green by
3215
+ construction. `npm run check-fixture-quad` prints the work list — 53 rules
3216
+ have `MUST-FIRE`, 40 `MUST-NOT-FIRE`, 75 `RECALL`, **0 `PRECISION`**, and 0
3217
+ of 79 have all four — so the backfill is driven by data rather than memory.
3218
+ `PRECISION` is zero because it demands a classified `TN` on a negative
3219
+ fixture, and no fixture has been classified as silent.
3220
+ - **`gitlab-ci` is written but not registered.** The adapter exists and three
3221
+ detection classes are already enumerated in `src/adapters/gitlab-ci.ts`, but
3222
+ it has no rules, so registering it would trade one false claim for another.
3223
+ Admitting it means writing the three rules with fixtures, and it is gated by
3224
+ the same quad as everything else.
3225
+
3226
+ ### PR reports say what to do next (#698)
3227
+
3228
+ `src/reporter/pr-report-actions.ts` turns a PR comment from a finding list into
3229
+ a handover: each band names the action it implies, so a reader who disagrees
3230
+ with a verdict can see which command contradicts it without reading the source.
3231
+ Six score badges (`assets/brand/pr-status/*.svg`) are generated from the brand
3232
+ tokens by `npm run brand:pr-status` and drift-locked by
3233
+ `tests/reporters/pr-report-actions.spec.ts`, which fails if a badge's stroke
3234
+ stops matching the token it came from. `brand:pr-status` is a deliberate brand
3235
+ change, so it is declared manual in `docs/MANUAL-SCRIPTS.md` rather than wired
3236
+ into a gate.
3237
+
3238
+ ### The website reading stack, and the palette it arrived with (#696)
3239
+
3240
+ A quieter, cooler set of greys and a teal→violet accent rule for the landing and
3241
+ guide surfaces, plus `docs/AI-DISCOVERY.md`, `site/public/llms.txt`, the guided
3242
+ proof walkthrough, and vendored Nordic and Cascadia fonts.
3243
+
3244
+ The palette arrived as raw hex inside the theme stylesheets, which is exactly
3245
+ what `brand-doctor` rule 6 exists to catch: a second palette no gate could see.
3246
+ It is now nine tokens in `BRAND` — `readingAccent`, `readingAccentAlt`,
3247
+ `readingFocus`, `readingAccentBright`, `readingAccentInk`, `readingUnworthy`,
3248
+ `readingMuted`, `readingDim`, `readingLede` — emitted as `--mj-reading-*` and
3249
+ referenced by the stylesheets that use them. Putting them in `BRAND` rather than
3250
+ a group of their own is deliberate: rule 8 computes WCAG AA for every foreground
3251
+ in `BRAND` against every surface, so the nine are held to the same floor as the
3252
+ terminal palette rather than escaping it. The weakest pairing is
3253
+ `readingAccentAlt` on `soft` at 5.75:1.
3254
+
3255
+ These are scoped to the reading surfaces. `steelDim` and `heroMuted` keep their
3256
+ values for the terminal, the README artwork and every other surface — the POC
3257
+ never claimed otherwise.
3258
+
3259
+ ### Known open, deliberately
3260
+
3261
+ **Publishing a package is not certifying it.** 5.1.0 is on npm; that is a
3262
+ statement about distribution, not about evidence. The candidate manifest remains
3263
+ `WORKING_CANDIDATE` / `NOT_CERTIFIED` / `NOT_AUTHORIZED`, and nothing in this
3264
+ release moves those states.
3265
+
3266
+ Four gates report `BLOCKED` on `npm run release:verify` and did not gate this
3267
+ publish:
3268
+
3269
+ - **`m26:audit`** — no consented external execution and no holdout proof.
3270
+ - **`candidate:readiness`** — no real-world, platform-matrix or
3271
+ consumer-install evidence.
3272
+ - **`candidate:decision:release`** — the same, adjudicated as a release-stage
3273
+ decision rather than an engineering one.
3274
+ - **`docs:roadmap:check`** — 69 support-matrix cells are explicitly `BLOCKED`
3275
+ and `docs/M26-EXTERNAL-VALIDATION.json` records external validation as
3276
+ `BLOCKED`, not `COMPLETE`. This is the same missing evidence read through
3277
+ the roadmap ledger, and it is a reporting gate: `stable-release.yml` does not
3278
+ run it. It has read this way since the v6 carve — no commit since then has
3279
+ touched `docs/M26-SUPPORT-MATRIX.json`, `docs/M26-EXTERNAL-VALIDATION.json`
3280
+ or `docs/M26-GAP-LEDGER.jsonl`.
3281
+
3282
+ The first three still run and still print on every publish; they stopped blocking
3283
+ only because a person recorded `vars.STABLE_RELEASE_AUTHORIZED=true`
3284
+ (`stable-release.yml`, `Candidate evidence`). `npm run ci-local` passes, because
3285
+ `candidate:decision:engineering` clears every engineering blocker.
3286
+
3287
+ This is the designed state for 5.x, not a workaround. The alternative — editing
3288
+ the manifest to claim `REMOTE_PROVEN`, `CERTIFIED` or `AUTHORIZED` — is the
3289
+ false-green this project exists to prevent.
3290
+
14
3291
  ## [5.0.0] — 2026-09-27
15
3292
 
16
3293
  ### Breaking changes
@@ -695,9 +3972,9 @@ This release is software-complete and locally certified for the declared enginee
695
3972
 
696
3973
  - Added seven milestone commands to the public CLI and root help:
697
3974
  - `mjolnir ci-integrity` validates GitHub/GitLab/Jenkins scan gates and reports non-blocking or disabled candidates.
698
- - `mjolnir framework-maturity` reports bounded F0–F5 maturity while preserving human calibration authority.
3975
+ - `mjolnir doctor --frameworks` reports bounded F0–F5 maturity while preserving human calibration authority.
699
3976
  - `mjolnir suppression-gate` evaluates reasons, expiry, allowlists, total counts, and matched-finding mass suppression.
700
- - `mjolnir cross-file` reports duplicate test names, shared imports, circular dependencies, and amplified findings.
3977
+ - `mjolnir explain --callers` reports duplicate test names, shared imports, circular dependencies, and amplified findings.
701
3978
  - `mjolnir contract-verify` validates machine-contract fields and binds persisted contracts to a fresh scan.
702
3979
  - `mjolnir trust-trend` persists, deduplicates, and compares trust snapshots.
703
3980
  - `mjolnir evidence-graph` builds provenance-bearing evidence graphs and supports file/rule queries.
@@ -1658,7 +4935,7 @@ Azure DevOps support: guarded azure-pipelines.yml parsing, the QA-CI Azure arms,
1658
4935
  preserved byte-identically (differential preservation suite).
1659
4936
  - **Explain v2 (WI-7).** `mjolnir explain` gains verdict mode
1660
4937
  (`mjolnir explain verdict --json <mjolnir.json>`) and finding mode
1661
- (file:line delegates to `mjolnir why`); every mode now answers the §8
4938
+ (file:line delegates to `mjolnir explain <file:line>`); every mode now answers the §8
1662
4939
  checklist including WHAT WOULD CHANGE THE VERDICT and NEXT ACTION.
1663
4940
  - **Triage v2 (WI-8).** `mjolnir triage` now runs the §9 guided workflow —
1664
4941
  CLASSIFY → EVIDENCE → TRUST VERDICT → NEXT ACTION per row, every row
@@ -1859,7 +5136,7 @@ Azure DevOps support: guarded azure-pipelines.yml parsing, the QA-CI Azure arms,
1859
5136
 
1860
5137
  ### Added
1861
5138
 
1862
- - **`mjolnir why <file>:<line>`** — occurrence-level evidence query
5139
+ - **`mjolnir explain <file:line> <file>:<line>`** — occurrence-level evidence query
1863
5140
  (informational, NOT a gate): exact file+line match, severity icon,
1864
5141
  message/why/fix, evidence level, trust level, measured FP rate
1865
5142
  (or the honest "ships on assumption"), runtime corroboration when
@@ -2269,7 +5546,7 @@ rules… → Scoring…`) fed by the new additive `ScanHooks.onProgress`.
2269
5546
  unified filter excludes them from non-strict scans exactly like core
2270
5547
  (post-scan cap still observable under `--strict`; the cli-scan-arms
2271
5548
  test updated to cover both sides).
2272
- - **Drift-checked:** `mjolnir rules --md --external` renders the
5549
+ - **Drift-checked:** `mjolnir explain --list --md --external` renders the
2273
5550
  catalog from the LOADED external rules with a provenance column
2274
5551
  (`core`/`external`) — an on-disk edit changes the next render; the
2275
5552
  catalog can never drift from what actually ships.
@@ -2828,7 +6105,7 @@ SEMANTIC | FRAMEWORK | RUNTIME`) instead of free text: `src/rules/rule.ts`
2828
6105
 
2829
6106
  ### Added — UNSURE adjudication gate + QA-PW-101 measured (plan §11.5)
2830
6107
 
2831
- - **UNSURE ceiling ratchet:** `npm run fp-audit:generate` now fails when the
6108
+ - **UNSURE ceiling ratchet:** `npm run generate-fp-audit-table` now fails when the
2832
6109
  UNSURE backlog grows beyond the committed
2833
6110
  `tests/corpus/verdicts/unsure-ceiling.json` (the §11.5 mechanism: UNSURE
2834
6111
  never counts into `n` but always triggers review). The ceiling only moves
@@ -2897,7 +6174,7 @@ detectorRevision, FP ≤ 10%, n ≥ 10`), on any detectorRevision mismatch
2897
6174
  - **`mjolnir doctor`**: `MAX_UNMEASURED_CORE` lowered 40 → **0** (Phase 1
2898
6175
  exit gate: 0 unmeasured in effective core, now enforced); tier checks
2899
6176
  consume `effectiveTier` + stale-measurement logic. Display surfaces
2900
- (`mjolnir explain`, `mjolnir rules`, generated rule docs, capability
6177
+ (`mjolnir explain`, `mjolnir explain --list`, generated rule docs, capability
2901
6178
  matrix) render the PROVISIONAL status honestly.
2902
6179
 
2903
6180
  ### Verification Trust Evolution, Phase 0 + Phase 1 prep
@@ -2927,7 +6204,7 @@ detectorRevision, FP ≤ 10%, n ≥ 10`), on any detectorRevision mismatch
2927
6204
  - **`tests/corpus/detector-revisions.json` sidecar**: hand-maintained,
2928
6205
  one entry per measured rule (all at revision 1 today), diffable.
2929
6206
  `MEASURED_FP` entries now carry `detectorRevision` stamped from the
2930
- sidecar by `fp-audit:generate`; `docs/FP-AUDIT.md` gains a
6207
+ sidecar by `generate-fp-audit-table`; `docs/FP-AUDIT.md` gains a
2931
6208
  `detectorRev` column. Measurement inheritance law (§07): a measurement
2932
6209
  belongs to a specific detector implementation, not merely to a rule
2933
6210
  ID. Drift lock extended in `tests/measured-fp-generated.spec.ts`
@@ -3156,21 +6433,21 @@ detectorRevision, FP ≤ 10%, n ≥ 10`), on any detectorRevision mismatch
3156
6433
  code; that fact previously lived only in `docs/FP-AUDIT.md` and
3157
6434
  `mjolnir doctor`. Now surfaced everywhere a user looks:
3158
6435
  - The scan footer reports how many of the rules that _fired_ are measured.
3159
- - `mjolnir rules --unmeasured` / `--measured` filter the catalog; a new
6436
+ - `mjolnir explain --list --unmeasured` / `--measured` filter the catalog; a new
3160
6437
  "FP (measured)" column in `rules --md`; a "Measured FP rate" row on every
3161
6438
  `docs/rules/` page and in `mjolnir explain`.
3162
6439
  - JSON findings carry `measuredFpRate` and `measuredFpN` (additive —
3163
6440
  `schemaVersion` is still 1).
3164
6441
  - `src/rules/measured-fp.generated.ts` bakes the rates into the shipped
3165
6442
  package (the raw verdicts are not packed); regenerated by
3166
- `npm run fp-audit:generate`, drift-locked by a test, and now the single
6443
+ `npm run generate-fp-audit-table`, drift-locked by a test, and now the single
3167
6444
  source `mjolnir doctor` reads.
3168
6445
  - Scoring is unchanged — this is visibility only.
3169
6446
  - **Corpus expanded 6 → 13 repos** so the previously-silent rule families
3170
6447
  (QA-TEST, QA-TQUAL, most QA-PW, QA-CI-001) fire on real consumer code:
3171
6448
  added `next-auth`, `vite`, `sveltekit`, `astro`, `TanStack/query`,
3172
6449
  `eslint-plugin-playwright`, `playwright-pytest`. `corpus:sample` and
3173
- `corpus:regression` now scan with `--strict` (quarantine rules were
6450
+ `corpus:audit` now scan with `--strict` (quarantine rules were
3174
6451
  invisible to both before). `docs/FP-AUDIT.md` is 15/91 (down from 19 —
3175
6452
  see the dispatch fix below, which retired the leaked cross-language
3176
6453
  verdicts for QA-PW-101/112 and QA-TEST-004/QA-ENV-001 on Java/Python
@@ -3371,7 +6648,7 @@ by a `must-not-fire` fixture so the class cannot return silently.
3371
6648
  - Upgrade-Plan-v3 Phase 0.1: expanded the false-positive corpus with two
3372
6649
  additional Python repos (`pytest-dev/pytest`, `psf/requests`) so all
3373
6650
  QA-PY-001..012 rules are exercised against real code via
3374
- `npm run corpus:regression`.
6651
+ `npm run corpus:audit`.
3375
6652
  - Added `CHANGELOG.md` (this file) per Upgrade-Plan-v3 critical item #3:
3376
6653
  user-visible rule behavior changes get a first-class entry from now on.
3377
6654
  - Upgrade-Plan-v3 Phase 1: five new TypeScript/Playwright rules —
@@ -3416,17 +6693,17 @@ by a `must-not-fire` fixture so the class cannot return silently.
3416
6693
  Promoted to core: QA-CS-101 (0% FP, n=20), QA-JV-105 (10% FP, n=20).
3417
6694
  Quarantined rules still ship and are still documented — they are opt-in via
3418
6695
  `--strict` rather than shaping the default report.
3419
- - `mjolnir rules` (`--json` and `--md`) now exposes each rule's `tier`, and
6696
+ - `mjolnir explain --list` (`--json` and `--md`) now exposes each rule's `tier`, and
3420
6697
  every generated page under `docs/rules/` shows it in the metadata table.
3421
6698
 
3422
6699
  ### Fixed — documentation claims a `grep` disproved
3423
6700
 
3424
6701
  - Every generated rule page told the reader to reproduce corpus counts with
3425
- a `corpus:audit` script. That script had been renamed to `corpus:regression` in
6702
+ a `corpus:audit` script. That script had been renamed to `corpus:audit` in
3426
6703
  the Tempering plan and the generator string was never updated — the command
3427
6704
  printed on 91 published pages did not exist. Same dead name in
3428
6705
  `docs/PUBLISHING.md`
3429
- (`corpus:audit:update` → `corpus:regression:update`).
6706
+ (`corpus:audit:update` → `corpus:audit --update`).
3430
6707
  - **`docs/FP-AUDIT.md` under-reported the rule base as 84 rules when the
3431
6708
  registry holds 91.** The coverage denominator was built by grepping source
3432
6709
  for `id: "QA-…"`, which silently missed the seven rules that the Phase 6