@am_shork/attest 0.7.4 → 0.9.0

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
@@ -13,6 +13,873 @@ input, removes/renames a public API or `--json` field, or changes a default
13
13
  runtime behavior an existing invocation relies on — diagnostic message text is
14
14
  not API.
15
15
 
16
+ ## [0.9.0] - 2026-08-12
17
+
18
+ ### Added
19
+
20
+ - **`--apply` writes MODIFIED requirements back, at the granularity of a value
21
+ rather than an entry.** `merge.ts` refused any delta carrying `modified` for
22
+ five releases, and what that refusal cost is on the record rather than
23
+ estimated: in one adopting project **5 of 19 changes carried `modified`**, each
24
+ merged by hand with a script the author rewrote five times in a session, which
25
+ located ` 'AUTH-7': { … },` in the registry **by regex**. `[0.6.0]` cites
26
+ eight hand-written Python scripts — one of them that same regex locator — as
27
+ the evidence that this step must not be left to the adopter, so the refusal had
28
+ reproduced the tool it was meant to retire, inside a project that had `--apply`
29
+ available.
30
+ **The argument the refusal rested on was subtly wrong, and correcting it is
31
+ what made this buildable.** `--apply` is allowed to edit a hand-written
32
+ registry because "the result is checkable by re-reading it", and the entry
33
+ proposing this narrowed on the wrong axis in consequence — accept an entry only
34
+ when its whole span is comment-free, which lowers the *rate* of destruction and
35
+ not its *size*. A re-read compares values and is blind to what was **lost**: a
36
+ replacement that ate a comment passes one cleanly. So the unit of the edit is
37
+ the value, not the entry. `statement` and `rationale` replace a string
38
+ literal's span, where a comment cannot be present by construction; a `params`
39
+ key replaces that key's value span and a new key is a pure insertion;
40
+ `outOfScope` replaces the array literal. Everything else in the entry — the
41
+ keys, the commas, the layout, every comment on a field that did not move — is
42
+ bytes the edit never addresses.
43
+ **Refused, still whole, when a span that would be overwritten carries a
44
+ comment**, as `apply-unsupported-delta` naming the requirement and the field.
45
+ Measured over this repository's own registry before it was built, on
46
+ 2026-08-11: **75 entries, 3 carrying a comment anywhere inside them, all three
47
+ inside a `params` value**, and none at all inside a statement or a rationale.
48
+ So the refusal is the rare case rather than the categorical one, and every
49
+ refusal is still a merge nobody has to trust.
50
+ **RENAMED and REMOVED are deliberately still refused, and the granularity is
51
+ the reason they are now separable.** "REMOVED cannot say which comments
52
+ belonged to the entry it deletes" is the sentence the two shared, and it
53
+ survives untouched: deleting an entry has no smaller span to fall back to,
54
+ while the same question one level in is answered by not replacing that level.
55
+ **Two things this brought with it.** The re-read is now performed rather than
56
+ argued — after writing, the registry is read back and every id the edit was
57
+ answerable for is compared against what the gate proved, ADDED included, since
58
+ a check covering only the newer half would leave the older claim exactly as it
59
+ was. And every registry edit is computed before any file is written, so
60
+ "refused whole" is true of the write-back and not only of the delta's shape;
61
+ before this, the one thing that could fail during text generation — a
62
+ `__proto__` param key — failed one file at a time.
63
+ Held as `ATX-76`, whose scenarios read the registry as **text**: a merge that
64
+ regenerated the entry would produce a file reading back as exactly the same
65
+ requirement, so nothing about the parsed end state can tell a value-sized edit
66
+ from a rewritten one. `ATX-54` keeps the refusal half over what is left.
67
+ *One limit, recorded so nobody assumes otherwise:* a patch cannot express
68
+ **deleting** a `params` key — `applyDelta` merges keys and never drops one — so
69
+ write-back does not cover that, and no amount of span work would.
70
+
71
+ - **The workflow document now describes an inner loop for stage 2.** Its only
72
+ instruction there was `attest archive`, which applies the delta and runs the
73
+ base suite, so an agent iterating toward green paid the full gate after every
74
+ edit — and `status` cannot help, its two obligations both being settled at the
75
+ end of stage 1. The addition needs no engine change: run the proposed spec file
76
+ directly while you are still making it pass, keep the gate for the verdict.
77
+ Measured before it was written — a `*.proposed.spec.ts` runs under a plain
78
+ Vitest invocation, delta import included — and it carries the two things that
79
+ make it safe and the one that makes it not a verdict: nothing but the gate ever
80
+ writes `first-run.json`, `status` still answers the readable half, and a green
81
+ file does not predict the gate, because the direct run gets the project's
82
+ environment and the gate's child run is isolated. That last point is the
83
+ `tests-red` section's existing subject, so it is pointed at rather than
84
+ restated.
85
+
86
+ - **`status` applies the change's delta and refuses the report when it does not
87
+ fit, which no static command checked at all.** `applyDelta` produces six
88
+ diagnostics — a rename whose target exists or whose source does not, a
89
+ modification of an absent id or one leaving the requirement invalid, an
90
+ addition that is invalid or conflicts — and nothing reached them without
91
+ running the suite first. `check` reads deltas, but only to attribute proposed
92
+ specs; it never applies one. So a delta wrong in any of those six ways was
93
+ first heard from `archive`, in the workflow position where the specs and the
94
+ implementation are already written, about a fact that was available the moment
95
+ the delta was saved.
96
+ Refusal rather than a row, because there is no row that could be right: the
97
+ states this command reports are progress toward an *applied* registry, and a
98
+ delta that cannot be applied has none to be progress toward — an `unproven`
99
+ against it is a true-looking sentence about a change that cannot exist.
100
+ Held as `ATX-78` rather than under `ATX-33`, which the cheap implementation
101
+ makes clear: `ATX-33` constrains what an ERROR from this command *means*, and
102
+ an implementation that never detects an unapplicable delta satisfies it
103
+ completely, vacuously, having no ERROR to constrain. This is the detection —
104
+ the same split `ATX-69` records against `ATX-65`.
105
+ **Behaviour change, and the reader it costs is not the one being fixed.**
106
+ Applying a delta needs the registry, which this command did not read before, so
107
+ a project whose registry is not a literal now gets `registry-not-static` and no
108
+ report where it got a report, and gets it back only under `--eval`. That is the
109
+ trade `ATX-16` already makes for `check`, `cover` and `render`, arriving at the
110
+ fourth static command. The registry is read through whichever reader the
111
+ command was asked for, so `--eval` moves both halves together rather than
112
+ quietly evaluating one to make the other work.
113
+
114
+ ### Fixed
115
+
116
+ - **`status` told the reader to write a scenario that was already on disk.**
117
+ `changeMergedPlan` returns the specs neither parse could read, and says in
118
+ place why they block: an unreadable proposed spec is a scenario the gate would
119
+ otherwise report as absent. `archive` acts on that list before it runs
120
+ anything, on the argument that a plan known to be short cannot grade a change
121
+ (ATX-65). `status` took the plan from the same call and destructured only
122
+ `merged`, dropping the issues — and every state it prints is computed *from*
123
+ that plan, so a claimed `*.proposed.spec.ts` that failed to parse left its
124
+ requirement with no scenarios in the plan and the row came back
125
+ `no-scenario`, with `issues` empty and `ok` true.
126
+ **Not an under-report but the inverse of one**, which is why it is a fix rather
127
+ than a widening: the command whose stated contract is to be a projection of the
128
+ gate answered the opposite of the gate about the same change, and the advice it
129
+ produced — write the scenario — is work the reader has already done. The one
130
+ thing that makes it recoverable is that `status` writes nothing.
131
+ **Behaviour change, and it moves an exit code.** A change in that state now
132
+ fails with `unreadable-file` and exits 1 where it printed a table and exited 0.
133
+ Anything wrapping `status` in a loop sees red on input that used to pass — but
134
+ the input is a spec file that does not parse, and the table it used to print
135
+ was wrong about it.
136
+ Held under `ATX-33` rather than as a new requirement: the rule it is an
137
+ exception to is untouched — an unmet obligation is still a row, never a verdict
138
+ — and what moved is which input that exception covers.
139
+ **Stating it exposed that the exception could not be enumerated, so it is now a
140
+ class and the code list is gone rather than extended.** `ATX-33` had named the
141
+ one code that could make `status` fail, and adding a second is what showed the
142
+ shape: the roster it can *actually* fail with was already longer than the list
143
+ on the day the list was written — a rejected change name and an unusable
144
+ compiler were never in it — and it grows with every input the command learns to
145
+ read. A param extended per failure path is a name for the implementation rather
146
+ than a contract, and it fails in the direction that hides, since a code missing
147
+ from it makes the requirement quietly false rather than red. What the statement
148
+ carries instead is the property worth preserving: **an ERROR from `status`
149
+ always means there is no report**, so a reader who sees one never has to ask
150
+ whether progress was being scored. That is **the first `params` key ever
151
+ removed from this registry**, against 57 added and one value changed; the
152
+ removal is the safe direction for the same reason those additions were, since
153
+ what the scenarios assert is broader than what the list named, not narrower.
154
+ **The measurement suite priced it in two places and one of them was a
155
+ finding.** The restated sentence is universally quantified, so
156
+ `tests/intent-rule-candidates.spec.ts` demanded a `QUANTIFIED` row and got the
157
+ `sites` shape — the members are early returns in one function, so nothing
158
+ enumerates them and no scenario can iterate them, which is the `ATX-37`
159
+ position that table exists to record. Two sites were unreached; one is closed
160
+ here by a scenario, and **the compiler path stays open as a measured gap,
161
+ taking that table from one to two**. It was not created here: `status` could
162
+ always fail that way with nothing attesting it, and naming the failing half as
163
+ a class is what made the site visible at all.
164
+ No `SCHEMA_VERSION` bump: `issues[]` already carries this code and the envelope
165
+ is unchanged. `unreadable-file` is not a new code either — what is new is a
166
+ fourth command that can raise it, which is why the troubleshooting section now
167
+ says which commands stop on it and which keep going.
168
+
169
+ - **The workflow document said a proposed spec stays out of a plain `vitest run`,
170
+ and it does not.** `*.proposed.spec.ts` ends in `.spec.ts`, so an ordinary
171
+ `**/*.spec.ts` include — Vitest's own default among them — matches it;
172
+ confirmed by running one. Only *Attest's* scope excludes it. The claim was
173
+ wrong in the direction that surprises: during stage 1 those scenarios are red
174
+ by construction, so an adopter following this document is told their own
175
+ `npm test` will be unaffected and then watches it go red, at the exact moment
176
+ the framework is asking them to trust a workflow they have just met. The
177
+ sentence now claims only what is true, and the optional `exclude` is shown —
178
+ as the reader's decision about their own suite, which is not one Attest makes
179
+ for them.
180
+ *That sample spreads `configDefaults.exclude`, and the reason is worth the
181
+ line it costs.* Vitest's `exclude` **replaces** its defaults rather than
182
+ extending them, so the obvious one-liner silently un-excludes
183
+ `**/node_modules/**` — measured, not inferred: a bare
184
+ `exclude: ['**/*.proposed.spec.ts']` collects and runs a `*.spec.ts` planted
185
+ inside `node_modules`. This repository's own `vitest.config.ts` has always
186
+ re-added those globs by hand, so the trap was already known here and would
187
+ still have shipped in advice written for someone else. A sample in a document
188
+ is executed by whoever copies it, and this one was two lines from turning a
189
+ cosmetic complaint into a broken suite.
190
+
191
+ - **The workflow document contradicted itself about `apply-unsupported-delta`,
192
+ and the half an agent branches on was the stale half.** The commit that taught
193
+ `--apply` to write MODIFIED back edited the prose in `skill.ts` and left the
194
+ code table eleven lines above it reading `--apply` writes back ADDED only, and
195
+ this delta carries more. So an agent meeting that code looked it up, concluded
196
+ the delta carried something unsupported, and would have gone off splitting
197
+ MODIFIED out of it — when the actual cause is now most often the other one the
198
+ same code covers, a comment sitting inside the span a MODIFIED value would be
199
+ written over. **A wrong cause costs more than a missing one**, because it sends
200
+ the reader to a repair that cannot work; the row now names both.
201
+ *What this says about the gate that exists.* `ATX-57` holds that every code the
202
+ workflow names is one the engine can emit, and its rationale records the
203
+ one-directionality as deliberate — the document has no business naming every
204
+ code, so the obligation is only that what it names exists. That is untouched
205
+ here and still right: the code existed, and what moved was its *meaning*.
206
+ `skill.ts`'s own note said "read this file when a release adds a diagnostic",
207
+ which is the rule that would not have caught this. It now says *or changes what
208
+ one means*, and to grep the body for the code rather than trusting that the
209
+ section just edited was its only mention.
210
+ *No `ATX-n`.* The obligation this would state — every description in the
211
+ workflow matches what the engine does — is the one nothing can check, which is
212
+ why `ATX-57` stops where it does. Adding a requirement that no scenario can
213
+ falsify would be a green tick over an unguarded property.
214
+
215
+ - **`attest status`'s documented surface was wider than the command, in three
216
+ places at once.** All three say some version of "`status` reports what the gate
217
+ still wants, and the one thing it cannot see is whether the tests pass". It is
218
+ a strict subset of that: `statusRows` projects the *uncovered* half of gate
219
+ check 1 and the whole of check 4, both restricted to the ids a change ADDs.
220
+ Check 3 reads `runtimeCoverage`, which only the suite produces — so a scenario
221
+ left `skip`ped blocks `archive` with the suite fully green and `status` fully
222
+ clean, which is exactly the conclusion the sentence invites a reader to rule
223
+ out. Orphans and unbound params are not projected either. Corrected in design
224
+ §8 (both languages), in the workflow document, in that document's command
225
+ table, and in the README's command list — four copies of one gloss, which is
226
+ the count worth recording: the phrase was not repeated because anyone reasoned
227
+ it four times, and correcting three of them would have left the repo saying two
228
+ different things about one command.
229
+ **Design §8's sentence was wrong on the day it was written, not stale, and the
230
+ difference was worth checking rather than assuming.** The tidy explanation was
231
+ that `declared-not-run` had been inserted as check 3 and renumbered the list
232
+ under a sentence nobody re-read — this file's own recurring lesson, and it fits
233
+ so well it was nearly written down. `git log -S` says otherwise: the numbering
234
+ has had check 3 reading `runtimeCoverage` since 2026-07-21, and the sentence
235
+ claiming check 3 is decidable without running anything arrived six days later.
236
+ So the mechanism here is not drift at all. **A sentence that enumerates a list
237
+ written a few lines above it is not checked against that list by anyone,
238
+ including its author**, and no gate reads prose.
239
+ *Separately, `status`'s History table was missing `0.8.0`.* Moving the
240
+ first-run record to `version: 2` — filed against `archive`, where the change
241
+ was made — also moved `status`'s output, because the two read that file with
242
+ the same predicate. Measured: the same change reports `✓ AUTH-7 2 scenarios,
243
+ seen red / 1 ready to archive` on a version-2 record and `● AUTH-7 2 never
244
+ run / 0 ready to archive` on the version-1 record a 0.7.x change carries. The
245
+ row is owed by the rule that a History table is the only thing that can tell a
246
+ reader on an older build which half of the page applies to them, and it is the
247
+ shape that hides: `status` exits 0 either way, so nothing anywhere turned red.
248
+
249
+ - **The workflow document never said a delta must be a literal.** It states the
250
+ rule for `*.reqs.ts` and stops there, while `check` and `status` read
251
+ `requirements.delta.ts` through the same static reader — so an agent lifting a
252
+ `const` out of a delta, which is the ordinary thing to do when two params share
253
+ a value, meets `registry-not-static` pointing at a file the rule it was given
254
+ never mentioned. Confirmed by running it rather than by reading the reader:
255
+ `attest status` on a delta whose param is an identifier reports
256
+ `registry-not-static` at that line. `changes/` is also the more exposed of the
257
+ two files, being by definition the one still under review, so it is the worse
258
+ of the two to leave undocumented.
259
+ *The same roster was short in `CLAUDE.md`*, which named three commands that
260
+ execute nothing where `ATX-16.staticCommands` names four — `status` being the
261
+ one missing, for the same reason: what it reads statically is a delta rather
262
+ than a registry, so it falls outside a sentence scoped to `*.reqs.ts` while
263
+ being squarely inside the security property that sentence exists to teach.
264
+
265
+ - **`check` had no `empty-spec` guard for four releases, and the reference said
266
+ it did.** A root with zero requirements answered `✓ No issues.` and exit 0,
267
+ while `verify` reported it as an ERROR. Reported by an adoption on 0.8.0,
268
+ which reached it the way it will usually be reached: `check` is the cheap CI
269
+ pre-check, so it is the command most likely to be pointed at a directory a
270
+ moved registry or a wrong `dir` argument has emptied — and the expensive gate
271
+ that would have caught it runs minutes later, or on a matrix leg that does not
272
+ run at all. What makes it a defect rather than a proposal is that the contract
273
+ was already written down and the implementation was what disagreed with it:
274
+ `cli-reference.md` lists `empty-spec` among the codes **`check`** reports, and
275
+ its own History row credits `0.2.0` with adding it, so the `0.2.0` change
276
+ landed on one of the two commands the reference describes it on and nothing
277
+ compared them since. `troubleshooting.md` closes the loop by telling a reader
278
+ who meets this to run `attest check .` — advice to a command that could not
279
+ say it.
280
+ **The fix is a layer move, not a branch added to a second command**, and that
281
+ is what settles the ambiguity the entry was filed with. The rule needs nothing
282
+ executed to decide it — the registry alone answers it — so it belongs to
283
+ structural validation, which is where `validateStructure` already lives and
284
+ which both commands already call with the same three arguments. Both design
285
+ documents said this twice and did not agree: the sentence stating the rule sat
286
+ in **§5.4**, under "what a run runs, and what `passed` may not mean", while
287
+ the account of what `init` writes cited **§5.3** for the same code in a
288
+ context where nothing has run at all. §5.3 is now the one place it is stated,
289
+ which makes that citation correct for the first time.
290
+ **Re-measured before a line was written, and the re-measure found a case the
291
+ entry had not counted** — the section's own discipline, and the third time it
292
+ has paid on the *reachability* question rather than on a number. The reported
293
+ half reproduced exactly. What the walk added is that `verify` fires
294
+ `empty-spec` **beside** `registry-invalid` when the registry was found and
295
+ failed to load, and its message tells the reader to point Attest at the
296
+ directory holding the `*.reqs.ts` files — a file that is sitting right there,
297
+ where the fix is the load error already in the report and following the hint
298
+ would move a path that was correct. So the obvious implementation, "make
299
+ `check` agree with `verify`", would have imported a latent misdiagnosis into
300
+ the command adopters run first. `empty-spec` is now withdrawn when a registry
301
+ file failed to load, on both commands: the same withdrawal `validateStructure`
302
+ already makes for `orphan-test`, against the same input, for the same reason.
303
+ A registry that could not be read is not an absent one, and the two take
304
+ opposite repairs.
305
+ **Stated beside the findings it explains, not instead of them.** The louder
306
+ symptom in the report was measured at 135 `orphan-test` ERRORs, one distinct
307
+ code, and the actual diagnosis — there is no registry under this root — not
308
+ among them. Each orphan is true of the root as given, so the alternative is
309
+ rejected explicitly rather than by omission: what was missing is the single
310
+ line saying why they are all true at once. `verify` already had it this way,
311
+ which answered the question by measurement rather than by argument.
312
+ Held as `ATX-18`, widened from `verify` to the layer, and `ATX-77` for the
313
+ withdrawal — kept apart because the two are falsifiable in opposite
314
+ directions, and both were observed doing exactly that before the branch was
315
+ written: reporting nothing satisfies `ATX-77` and fails `ATX-18`'s four
316
+ scenarios, reporting unconditionally satisfies `ATX-18` and fails `ATX-77`'s
317
+ two.
318
+ *A root that reported success now reports an error, which this file's
319
+ Versioning section makes a **minor** bump.*
320
+
321
+ - **`--apply` skipped the delta import it is supposed to repoint whenever the
322
+ specifier carried no file extension, and reported success.** `[0.6.0]` records
323
+ this exact failure being found and fixed — a merged spec left importing a path
324
+ step 3 has just moved to `archive/`, `check` silent, only the merged suite
325
+ showing it — and the fix covered the two spellings *this* repository writes.
326
+ `resolvesTo` (`core/splice.ts`) accepted the delta's own path or the NodeNext
327
+ `.js`-for-`.ts` spelling of it, and nothing else. A project on bundler
328
+ resolution writes neither: every import in it is extensionless, including the
329
+ one this is looking for. Reported by an adoption that is extensionless
330
+ throughout — application modules and all 35 spec files — so it hit this on
331
+ **every** applied change, nine in a row, and after the first one pre-empted it
332
+ by hand. That is the shape worth recording rather than the bug: the workaround
333
+ is cheap enough to become habit, so the defect stops producing reports while
334
+ continuing to happen.
335
+ **Re-measured before a line was written, and the entry's own account of the
336
+ repair was wrong.** The reading half reproduced exactly as filed, against the
337
+ current source rather than the `dist/` the report used. The writing half was
338
+ filed as needing nothing — "extensionless in, extensionless out falls out of
339
+ the empty case" — and the empty case sat behind `if (ext)`, so it left the
340
+ target's `.ts` in place. That guard was correct only while the empty case was
341
+ unreachable, which is precisely what widening the match changes. Fixing the
342
+ reported half alone would have written an extension into a project that writes
343
+ none: the reading half repaired, the writing half newly broken, and the
344
+ uniformity the whole fix exists to respect broken by the fix for it. This is
345
+ the second time an entry that named its own fix had the fix turn out to be the
346
+ wrong half, after the C1 stripping in `[0.8.0]`, and it is the case least
347
+ likely to be re-examined for exactly that reason.
348
+ Held as `ATX-75` with a scenario per spelling rather than by widening
349
+ `ATX-52`, which attests that the merged project verifies clean: a merged
350
+ project whose spec never imported the delta verifies clean either way. The
351
+ assertions are on the specifier the command wrote, not on the health of what
352
+ surrounds it, which is what makes the two separately falsifiable in the
353
+ direction that matters.
354
+
355
+ - **`ATX-72`'s second scenario could not run on Windows at all, and the
356
+ requirement's own rationale said it could.** The injection needs the registry
357
+ file's name to carry the *specifier's own* quote — that is what makes the two
358
+ quote styles two scenarios rather than a loop — and the rationale argued the
359
+ name was "legal on Windows too since neither is among the characters it
360
+ reserves". True of the apostrophe, and false of the double quote, which is in
361
+ the set that same sentence enumerates. So `writeFile` threw before the emitter
362
+ was reached and `verify self` was red on win32, from `[0.8.0]`.
363
+ **What hid it is worth more than the fix.** CI is Linux, where both names are
364
+ legal, so no pipeline could have reported it — `CLAUDE.md`'s two-platform rule
365
+ arriving on a requirement that had already written the platform argument down
366
+ and got it half right. The argument was written while this requirement covered
367
+ one quote; widening it to two left the reasoning for the first standing over
368
+ both, unre-examined. A rationale is not re-read when the thing it justifies
369
+ grows.
370
+ The obligation is unchanged and holds on both platforms, because it is about
371
+ what the emitter writes. What is platform-limited is the end-to-end route to
372
+ it, so the win32 run now reaches the same assertion through `repointImport`
373
+ directly rather than skipping — a skipped scenario is `declared-not-run`
374
+ (`ATX-71`), which would trade a red suite for a red gate. The honest reading,
375
+ recorded in the requirement: a repository holding that name cannot be checked
376
+ out on Windows at all, so the exposure the end-to-end case stands for is
377
+ POSIX-only.
378
+
379
+ ### Changed
380
+
381
+ - **`status`'s closing line names `check` as well as `archive`.** It said what
382
+ this report is short of on one side — the verdict — and nothing about the
383
+ other: a proposed spec no delta claims, or one whose merged name is already
384
+ taken, is a static fact about this change that `check` already reports. Naming
385
+ it is deliberately the whole of the fix. Reporting those here too would put a
386
+ second answer to one question in the tree, which is the arrangement this
387
+ project takes apart everywhere else, and the reader who wanted one command was
388
+ going to run `archive` anyway.
389
+
390
+ - **`divergent-param` re-keyed a fourth time, and this one carries something the
391
+ first three did not.** `ATX-77` writes `ATX-18` out of the `code` group by
392
+ carrying the same `empty-spec`, on the rule's usual arithmetic — one id per
393
+ distinct value, later writer wins. The churn itself is now unremarkable and
394
+ that is the point of counting it. What is new is *which* pair it hid: `ATX-18`
395
+ and `ATX-77` were split deliberately, because each is falsifiable in a
396
+ direction the other is blind to, which makes them the most tightly related
397
+ pair in this registry — and the group can show only one of them. That is the
398
+ second time the one real relationship in this group has been the invisible
399
+ one, after `ATX-66`/`ATX-73`, and twice makes the mechanism legible rather
400
+ than the coincidence: two ids are related when they name the **same**
401
+ constant, which is exactly the case the deduplication discards. The rule is
402
+ not blind to relatedness by omission — its key is built by throwing the
403
+ evidence of it away.
404
+
405
+ - **`divergent-param`'s finding for `flag` had to be re-keyed a second time**, on
406
+ a requirement carrying the same `--apply` as the member it displaced. The
407
+ divergence itself did not change — the same three unrelated constants under
408
+ the same param name — but the grouping keeps one id per distinct *value* and
409
+ the later writer wins, so the row's identity moved and a human had to re-judge
410
+ a finding about which nothing was new. Once was a curiosity worth a line; twice
411
+ makes it the candidate rule's behaviour, and names a maintenance cost paid per
412
+ addition on top of the zero precision already recorded. Note which additions
413
+ trigger it: the ones that **agree** with an existing member.
414
+
415
+ - **`findFiles` opened one directory per directory in the tree; it now opens
416
+ 32.** The recursion was `Promise.all(subdirs.map(walk))`, so every sibling at a
417
+ level was in flight at once and each of their children after them. The walk is
418
+ now a bounded pool a level at a time, which makes the peak a constant — the
419
+ same figure and the same shape as `parseSpecs`, whose bound `[0.7.0]` added.
420
+ *The entry this closes was half stale, and that is the part worth recording.*
421
+ `[0.7.0]` fixed the `parseSpecs` half and wrote down, in this file, that the
422
+ `findFiles` half was deliberately left; the `Under consideration` entry was
423
+ never narrowed to match, so it went on describing `parseSpecs` as reading every
424
+ source into memory for four releases after that stopped being true. An entry
425
+ whose other half ships is not re-read either — the same shape as a rationale
426
+ not re-read when the thing it justifies grows (`ATX-72`, above), arriving on
427
+ the section whose own discipline is to re-measure before building. Re-measuring
428
+ is what found it: the memory arithmetic the entry rested on was the half
429
+ already fixed.
430
+ *What retires the blocker is that it named the wrong failure.* `[0.7.0]` held
431
+ this back on "no report, and no demonstrated failure anywhere", where the
432
+ failure it meant was descriptor exhaustion — unreachable on either development
433
+ platform, and on Windows not even governed by `ulimit -n`, since Node uses
434
+ Win32 handles there. That is still true and still unmeasured. But it is not the
435
+ only cost: the peak itself is portable arithmetic, which is the standard the
436
+ `parseSpecs` half was accepted on. Measured in `tests/locate-fanout.spec.ts`,
437
+ which now counts in-flight `readdir` as well as in-flight `readFile`, on a
438
+ 64-wide tree two levels deep: **64 before, 32 after** — and 200 before on a
439
+ 200-wide one, which is the point. Unbounded, the figure is the width of the
440
+ level; the tree is the attacker's to choose, and `check` is the command this
441
+ project tells people to run first on an untrusted fork MR.
442
+ *The 200-wide tree is not what ships, and why is worth a line.* Building it
443
+ timed this file's `beforeAll` out on win32 under the parallel suite,
444
+ intermittently — two runs in three — and the failure arrived in the shape
445
+ `CLAUDE.md` records for `tests/consumer.spec.ts`: a throw in a hook reports
446
+ every test in the file as **skipped**, so the count moves from 385 passed to
447
+ 383 passed and 2 skipped, and nothing says the property went unchecked. The
448
+ tree is now 64 wide, built concurrently, with an explicit hook timeout. A test
449
+ that is sometimes not run is worth less than a smaller one that always is.
450
+ *Both fan-outs now go through one `forEachBounded`*, extracted at the second
451
+ call site rather than the first: `parseSpecs` had the pool written inline, and
452
+ a shape written twice is the one a later fix reaches only one copy of — which
453
+ is, in miniature, what this entry is about. `parseSpecs` keeps its own
454
+ constant rather than sharing one, because the two bound different resources
455
+ and a shared figure could not be tuned for either. Its peak is unchanged at 32,
456
+ re-measured through the helper rather than assumed.
457
+ *A level at a time rather than one pool over a growing queue*, because a queue
458
+ fed by its own workers has to keep them alive while it is momentarily empty and
459
+ another worker may still push — a termination condition worth not owning for a
460
+ barrier paid in tree *depth*, which the filesystem bounds, against a fan-out
461
+ paid in tree *width*, which nothing does.
462
+ No behaviour change and no `ATX-n`, on the precedent `[0.7.0]` set for the
463
+ other half: the returned list is byte-identical, sorted the same way, with
464
+ `SKIP_DIRS` excluded the same way — all three asserted beside the peak — so
465
+ there is no contract here a consumer could branch on.
466
+
467
+ ## [0.8.0] - 2026-08-09
468
+
469
+ ### Security
470
+
471
+ - **The `--json` document escaped every C0 control and no C1, while every other
472
+ surface stripped both.** `control()` (`core/terminal.ts`) covers
473
+ `\x00-\x1f`, DEL and `\x80-\x9f`, the last of those held deliberately for
474
+ 8-bit CSI; the machine path rested on `JSON.stringify`, and the claim written
475
+ above it — that serialising to JSON escapes the same characters — is true of
476
+ C0 and of nothing else. Re-measured before a line was written, as this file's
477
+ discipline asks: `U+009B` and `U+007F` both leave `renderJson` verbatim.
478
+ Reachable from every `--json` command including `check --json`, which executes
479
+ nothing and still quotes scenario names, file paths and reqIds out of the
480
+ repository being checked; under `verify` and `archive` a thrown message joins
481
+ them.
482
+ **Filed at the strength the evidence carries, and that has not changed.**
483
+ Exploiting it needs a terminal that treats UTF-8-decoded C1 as control —
484
+ believed to split VTE from xterm's default, still **not measured on either** —
485
+ and a reader looking at the JSON rather than parsing it. What makes it worth
486
+ closing is not the size of that window but the asymmetry: design §9.1 is
487
+ stated as a property of every artifact Attest writes, and one emitter opting
488
+ out through a true-but-narrower guarantee is exactly how two `catch` blocks
489
+ came to print a raw stack beside sanitised diagnostics once already.
490
+ **Re-measuring moved where the fix goes.** The entry proposed a map over
491
+ `issues`, on the `withDocs` model. Walking the report showed that is the wrong
492
+ half: `change`, `outFile`, `outFiles` and — two levels down — a status row's
493
+ scenario name and file all carry text from the project under test, and none of
494
+ them passes through the envelope. Enumerating them is the arrangement §9.1
495
+ already names as the cause of the defect, so the rule sits at serialisation
496
+ instead, which is the *document* arrangement the section describes — one point
497
+ of assembly, before any of it is seen, covering a field nobody has added yet.
498
+ `outFiles` and `docsUrl` were both added after the envelope and would each
499
+ have needed remembering. `reqId` needed none of this and is worth recording as
500
+ the case that is safe by construction: `RegistryIdSchema` holds every id to
501
+ `^[A-Z]+-\d+$` on both readers, which is the same argument `render` uses for
502
+ not sanitising ids.
503
+ No `SCHEMA_VERSION` bump: the shape is unchanged, and the field values a
504
+ consumer parses differ only where they previously carried a control character.
505
+ Attested as `ATX-74` rather than by widening `ATX-37`, which claimed this
506
+ surface was exempt — the two are separately falsifiable in the direction that
507
+ matters, since `ATX-37` holds at every print site today while this one failed.
508
+ That rationale, and the same claim in `core/terminal.ts`, are corrected in
509
+ place.
510
+
511
+ - **`render --out` wrote through a committed symlink, because the per-segment
512
+ resolution `init` performs had exactly one caller.** `resolveDest` resolves
513
+ every segment of an `init` destination and refuses with `unsafe-target-path`;
514
+ the `--out` path was `resolve(cwd, opts.out)` and went straight to
515
+ `writeAtomic`. Measured before a line was written, as this file's discipline
516
+ asks, and the filed claim held: with `docs` committed as a symlink to a
517
+ sibling directory, `attest render <dir> --out <dir>/docs/SPEC.md` reported
518
+ `✓ Wrote …` and the document appeared outside the root. The victim's `--out`
519
+ is the one their own README or CI documents, and the content redirected is the
520
+ rendered document — the statements and rationales their repository wrote.
521
+ **The leaf was already safe, which is why the gap was exactly the directory
522
+ segments.** `writeAtomic` lands by `rename`, which replaces a symlink instead
523
+ of following it — measured: a `SPEC.md` symlinked at a file outside the root
524
+ left that file's bytes untouched. `wx` does not reach this either and was
525
+ never scoped to: it guards the temporary path, and the temporary path is
526
+ `dirname(dest)`, i.e. already inside whatever directory the link chose. So
527
+ both held perfectly while the whole write happened somewhere it should not.
528
+ **The scope is the part that is not inherited from `init`.** `init` owns its
529
+ destinations, so containment there is unconditional; `--out` is an argument,
530
+ and `--out ../site/SPEC.md` is a destination the caller can see they chose.
531
+ Refusing it would be refusing the flag. The check therefore applies exactly
532
+ where the argument stops being evidence of where the file goes: a path that
533
+ reads as staying inside the project. It is asked against the root in both
534
+ spellings, lexical and resolved, because they differ whenever the project
535
+ itself sits under a link — testing only the resolved one would skip the check
536
+ for every such checkout, failing open on precisely the layout that has links
537
+ in it.
538
+ **`--check` goes through the same resolution**, which the filed entry did not
539
+ cover. It reads the destination rather than writing it, and a freshness gate
540
+ comparing a fresh document against a file outside the project answers about
541
+ the wrong document — quietly, since both verdicts it can reach are ones it
542
+ reports normally.
543
+ So this is `resolveDest` gaining its second caller rather than a new
544
+ indirection; by this repository's own rule it was a hypothetical seam with one
545
+ adapter and is now a real one. `changes/<name>/` remains the candidate third
546
+ and stays unmeasured.
547
+ **The repair moved the write, not just the check, and the reason is the shape
548
+ of the defect rather than tidiness.** `render`'s `writeAtomic` was the only
549
+ call to it outside `core/` — `runInit` writes in the pipeline, `merge.ts`
550
+ writes in the pipeline, and this one write sat in the CLI, which is exactly
551
+ how it reached the filesystem without the resolution `runInit` had performed
552
+ since `[0.7.3]`. Guarding it in the shell would have left that intact: the
553
+ obligation to call the check would still belong to a caller who can forget it,
554
+ which is the situation `pipeline.spec.ts`'s compiler-guard gate already exists
555
+ to catch elsewhere ("delete the line and every scenario stays green"). So
556
+ `runRender` takes the destination and owns the write, `runRenderCheck`
557
+ resolves the path it reads, and the resolution is **private to the module** —
558
+ there is no longer an entry point that writes a document without resolving
559
+ where it goes. The CLI lost its `writeAtomic` import, and with it the last
560
+ policy decision it was making; `hasError` before the write is now what stops a
561
+ broken registry overwriting a good committed document, stated where the write
562
+ is. No existing test changed: `runRenderCheck` keeps its signature, because
563
+ `target` already carried the spelling a diagnostic echoes and asking a caller
564
+ for it twice is a clump waiting to disagree with itself.
565
+ Two things the move surfaced that a guard bolted onto the shell would have
566
+ hidden. **The compiler guard has to stay the first statement**, which
567
+ delegation gave `runRenderCheck` for free and a resolution placed above it
568
+ quietly took away — an unsupported TypeScript would have come back as a path
569
+ refusal. Both entry points call `compilerIssue` first now, and
570
+ `runRenderCheck` leaves the exemption table in `tests/pipeline.spec.ts`, which
571
+ is what that table is for: a list of exemptions exists to be decided about
572
+ rather than to drift. And **"never overwrite a good document with the output
573
+ of a broken registry" was a comment with nothing behind it** — the write lived
574
+ in the CLI, where no test could reach it. It is asserted now, where the write
575
+ is, and is red without the guard: a half-loaded registry renders a document
576
+ that silently omits requirements, so overwriting the committed one destroys
577
+ the only correct copy and the next `--check` passes against the damage. The ATX-73 scenarios drive
578
+ the binary, so they are what proves the move changed no behaviour, and three
579
+ mutants confirm they still bite: writing without resolving, refusing every
580
+ path that leaves the root, and `--check` skipping the resolution are each red.
581
+ Stated as `ATX-73` with three scenarios: the write path and the `--check` path
582
+ both driven through the real CLI against a project carrying a planted link —
583
+ spawned rather than called, because the defect was never in the resolution but
584
+ in the write not going through one, so a scenario calling `resolveOutFile`
585
+ directly would have passed against the broken build — and a third holding the
586
+ scope, that a destination spelled outside the project is still written where
587
+ it says. All three are red against the previous build.
588
+ One further behaviour change falls out of resolving rather than checking, and
589
+ it is a repair rather than a cost: a link *inside* the project is now
590
+ **followed** instead of replaced. `rename` used to overwrite `docs/SPEC.md ->
591
+ build/SPEC.md` with a regular file while `--check` read straight through it,
592
+ so the two halves of the gate disagreed about which file the document was;
593
+ measured, the link now survives and `build/SPEC.md` receives the bytes.
594
+ **This turns a passing run red for anyone whose `--out` currently resolves
595
+ through a link out of the project**, which is the behaviour change that
596
+ matters and is the point of it. No
597
+ `SCHEMA_VERSION` bump: `unsafe-target-path` is an existing code reported by an
598
+ additional command, `src/cli/json.ts` is untouched, and no field moves.
599
+
600
+ - **`--apply` wrote the registry's own file name into a spec's import as text
601
+ rather than as a string, so a registry whose name carried a quote closed the
602
+ literal and everything after it became code.** `splice.ts` states that every
603
+ generated byte is escaped — `tsString` for statement, rationale and
604
+ `outOfScope`, `keySource` for param keys, a `throw` rather than a guess for
605
+ `__proto__` — and `repointImport` was the one site that interpolated instead.
606
+ What it interpolated is the value the *checked repository* names: `to` is the
607
+ registry file `--apply` decided the ids belong in, and `isReqsFile` tests only
608
+ the suffix.
609
+ **The deletion test says what this was worth, and it is not execution.**
610
+ `archive --apply` runs the suite in the same invocation, so the attacker
611
+ already executes there. What the injection added is **persistence**: the
612
+ payload lands in a `*.spec.ts` that the merge then commits, and runs on every
613
+ later clone and CI job, inside a diff `--apply` advertises as a rename plus one
614
+ specifier. **Not platform-scoped** — Windows rejects `\ / : * ? " < > |` in a
615
+ file name and neither `'` nor `;` is among them.
616
+ Re-measured before a line was written, as this file's discipline asks, and the
617
+ filed claim held with one correction it did not have: the payload must carry
618
+ **the file's own quote character**, so the double-quoted spec survives a `'`
619
+ and vice versa. That is why the repair is not "escape `'`". `tsString` splits
620
+ into a `tsStringBody(value, quote)` that is *told* which quote it writes
621
+ between, and the specifier edit passes the quote already in the file — the
622
+ file's quote style stays exactly as it was, which is the property that ruled
623
+ out `JSON.stringify` here in the first place. No refusal path was needed:
624
+ escaping has an answer for every name, since a specifier is a string and
625
+ `tsStringBody` can spell any string as one, so `repointImport` still cannot
626
+ throw and `merge.ts` keeps the one `UnwritableValue` site it had.
627
+ The regression is asserted as a **round-trip** rather than against escaped
628
+ bytes — the file still parses, carries its original four statements, and the
629
+ specifier reads back as the path — under both quote styles, because escaping
630
+ the wrong quote is as wrong as escaping neither. Both cases are red against
631
+ the previous emitter.
632
+ Stated as `ATX-72` with a scenario per quote style, driving the real
633
+ `--apply` over a project whose registry file is named with the payload. Its
634
+ first draft is worth recording because a gate caught it and not a reviewer:
635
+ written as a quantifier over every value the emitter writes, the `sites` table
636
+ in `tests/intent-rule-candidates.spec.ts` demanded a scenario per site and
637
+ found three the sentence claimed which no `self/` scenario reaches. The hole
638
+ was in the *statement*, not the coverage — the other three sites are attested
639
+ in `tests/splice.spec.ts` and were never part of this change — so the repair
640
+ was to narrow the sentence to the one value the checked repository names.
641
+ §7 gains the general form, since it already carried the emitter's rule ("it
642
+ may emit no text whose evaluation differs from the value it was handed") and
643
+ carried it as a rule about the *registry*: `--apply` writes a second file, and
644
+ a file name is not something a tool is in a position to restrict, so it is
645
+ something the writer is obliged to escape.
646
+ No `SCHEMA_VERSION` bump: `src/cli/json.ts` is untouched, no code or field
647
+ moves, and no valid input changes behaviour — for any registry not named to
648
+ attack, the bytes `--apply` writes are the bytes it wrote before.
649
+
650
+ - **A registry file that swaps its prototype was read as the requirements the
651
+ prototype carried, under the reader that evaluates.** `{ '__proto__': { … } }`
652
+ written as a literal is a swap, not a key: the object has **no own keys at
653
+ all**, so what the loader reported never depended on the swap but on what
654
+ happened to be behind it. The static reader refuses such a source outright as
655
+ `registry-not-static` and always did — the two readers agreeing is a security
656
+ property, not a convenience — so this was the two of them disagreeing about
657
+ whether a file is a registry at all.
658
+ **A fixture for exactly this existed and was passing for the wrong reason.**
659
+ `tests/static-registry.spec.ts` put *malformed* ids on the prototype, and zod
660
+ v3's record enumerates with `for…in`, which walks the chain — so those
661
+ inherited keys were tested against the id grammar and failed it. The red was
662
+ incidental to the swap, and the whole guarantee rested on it.
663
+ Put a **well-formed** id on the prototype instead and the same shape is
664
+ **accepted**, with the entire registry built out of a prototype the source
665
+ does not declare. That is measured on the zod this release ships with, not
666
+ inferred: the new differential case is red without the guard and the old
667
+ fixture stays green beside it, which is the defect and its camouflage in one
668
+ run. Found while evaluating the move to zod v4 — which enumerates own keys
669
+ only, so the swapped object is simply empty and even the malformed fixture
670
+ goes green — but the acceptance is reachable today and is fixed here, ahead of
671
+ that port and independently of it.
672
+ Refused now by the `isPlainObject` guard `params` has carried since a param
673
+ became any JSON value, applied one level up to the registry itself — **stated
674
+ over the shape rather than over the name `__proto__`**, because a blocklist of
675
+ names is a second answer to "is this data" and the guard is already the first.
676
+ A class instance and a `Date` fail it for the same reason. `registry-invalid`
677
+ rather than a code of its own: the obligation is identical to every other way
678
+ the schema refuses a registry, and so is the reader's situation.
679
+ Stated as `ATX-70` with three scenarios, and the fixture the suite was missing
680
+ — the well-formed-id swap — is now the one the differential suite runs.
681
+
682
+ ### Changed
683
+
684
+ - **The guidance now says which quantifier to write, where before it only said
685
+ what a scenario must read.** `[0.7.0]` added the `QUANTIFIED` table after
686
+ `ATX-37` — "**everything** the CLI writes to a terminal MUST replace every
687
+ control character" — was found to have scenarios for the two formatters that
688
+ existed the day it was written, while `render` built a document by
689
+ concatenation that never went past the sanitiser. What the table taught was
690
+ never written down anywhere an author would meet it, and it is the more useful
691
+ half of that work: **which surface a quantifier ranges over decides whether
692
+ scenarios can keep up with it**, and there are three, not the two the original
693
+ proposal assumed. Over *values* one site processes, representative inputs
694
+ settle it. Over a *roster* the code enumerates, a scenario looping that roster
695
+ covers a new member by construction. Over *sites* in the implementation, each
696
+ needing a hand-written scenario, a new site is a new obligation and nothing
697
+ enumerates them — the only one of the three that can silently stop being true,
698
+ and what `ATX-37` was. So the rule is *prefer a quantifier a scenario can
699
+ iterate*.
700
+ *Where it landed.* Design §11 as a residual weakness, beside the two `params`
701
+ bullets it completes — those say what an assertion must do with a value it
702
+ read; this says what the statement above them should quantify over in the first
703
+ place. The `init` skill gets the adopter-facing form in the same model section
704
+ as those two rules, so a project adopting Attest is told before it writes the
705
+ statement rather than after the gap is found by reading. Both languages, one
706
+ commit, per the reason `[0.5.0]` records for doing this shape of change in one
707
+ pass.
708
+ *No engine change, and the entry stays open for the one that is still not
709
+ proposed.* Which surface a sentence names is prose judgement — the ground truth
710
+ §0 refuses to compute for someone else's requirements — and the detector that
711
+ exists flags the quantifier only, missing the article form ("A registry that
712
+ cannot be read MUST …"). What ships here is counter-pressure, which is what §11
713
+ now says: a hand-judged table for this registry, plus a rule an author can
714
+ follow. The carve-out is carried with it, because it is the failure mode of any
715
+ such table: rewriting a statement to name its surfaces takes it back out of the
716
+ flagged set, so the table admits rows for ids nothing flags.
717
+
718
+ - **Zod moves from v3 to v4 (`zod@^3.23.0` → `^4.4.3`).** The reason is supply,
719
+ not speed: v3 is in maintenance, so fixes land on v4. The port of the schema as
720
+ it stood was the **one API change** the plan predicted — `errorMap` → `error` —
721
+ with `z.custom().pipe()`, `z.record`, `z.lazy` and the recursive `paramValue`
722
+ all carrying over unedited, and `red-record.ts`'s `z.ZodType` untouched. A
723
+ `as z.ZodType<ParamValue>` on `paramValue` became unnecessary and is gone. The
724
+ count is one *for the schema the plan measured*: the prototype guard added in
725
+ the commit before this one brought a second options bag with it, so the port
726
+ actually touched two — which is the ordinary way a plan's cost estimate goes
727
+ stale, by the code moving under it rather than by the estimate being wrong.
728
+ **The plan's "no `package.json` change" framing was about the evaluation, and
729
+ taking it as the destination would have shipped a broken package.** It rested
730
+ on `zod@3.25.76` exporting a `./v4` subpath — true of the *installed* version
731
+ and not of the *declared range*: `zod@3.23.8` is inside `^3.23.0` and exports
732
+ no `./v4` at all, so importing `zod/v4` while keeping that range yields a
733
+ package that fails to resolve on a legitimate install of its own dependency.
734
+ It is a real bump or it is nothing.
735
+ *Re-measured here, and the entry's central type claim does not survive.* The
736
+ plan recorded instantiations as **unchanged** (27,681 → 27,671). On this
737
+ codebase they go **36,211 → 89,831**, about 2.5×, with build check time
738
+ 0.58 s → 0.71 s. Controlled for the prototype guard, which accounts for 292 of
739
+ that — the growth is the port. The consumer-side `.d.ts` footprint is worse
740
+ than predicted too: **14 files / 68 KiB → 80 files / 217 KiB** (the plan said
741
+ 66 / 181 KiB), and a consumer's own instantiations 71,935 → 131,044. Runtime
742
+ goes the other way and by less than advertised: parsing this registry is
743
+ **0.873 ms → 0.143 ms**, 6.1× rather than 13×, and worth ~0.7 ms against the
744
+ hundreds `attest check self` takes. None of it blocks a migration whose reason
745
+ is supply, and all of it is recorded because "v4 is also faster to typecheck"
746
+ is the sentence that would otherwise reach the commit message — and on this
747
+ schema it is false in a way the plan did not have: the plan called the type
748
+ side neutral, and it is a cost.
749
+ *One diagnosis regression, found and repaired.* A v4 record reports a bad key
750
+ as "Invalid key in record" and does not surface the key schema's own message,
751
+ so a mistyped requirement id — the commonest way to fail this schema — stopped
752
+ being told the grammar it had missed. The record now carries an explicit
753
+ `error`, which restores it.
754
+
755
+ - **`RegistryValidationError.issues` has a shape of this project's own, and no
756
+ longer exposes `z.ZodIssue`.** Zod was otherwise fully absorbed — `Requirement`
757
+ and `Registry` are `z.infer` aliases, so a consumer resolves them to plain
758
+ object types and never names the library. This field was the single exception,
759
+ and it was the load-bearing one: it is reached through the `./define` subpath
760
+ that `defineRequirements` is imported from, and `ZodIssue` is a discriminated
761
+ union zod reshaped between v3 and v4. So a dependency bump this project should
762
+ have been free to make was a **breaking change for anyone who had destructured
763
+ the field**. The element type is now `RegistryValidationIssue` — `path:
764
+ PropertyKey[]` and `message: string`, exported from the same subpath — which is
765
+ the same boundary `Issue.message` already holds, with the difference that
766
+ `Issue` was designed with the boundary and this was not.
767
+ **Breaking, for the narrow surface that read past those two fields**, which is
768
+ why it is a minor under 0.x. Nothing in this repository was in that surface:
769
+ re-measured before a line was written, the only two readers are `formatIssues`
770
+ in `registry.ts` and `firstMessage` in `apply.ts`, and **nothing anywhere reads
771
+ `code`, `expected`, `received` or `fatal`** — which is what made this a rename
772
+ rather than a redesign. A consumer that only formats what it is given, as both
773
+ of those do, sees no change at all.
774
+ *`path` is `PropertyKey[]` rather than `(string | number)[]` deliberately.* It
775
+ is the wider of the two spellings zod has used, so a validator failure assigns
776
+ to it under either — which is the whole point, since the release that moves
777
+ that type is the one this change exists for.
778
+ *One thing the plan glossed, found by doing it.* The two formatters were said
779
+ to read the same two fields, and they did — but not the same way: `apply.ts`
780
+ spelled the path `.map(String).join('.')` and `registry.ts` spelled it
781
+ `.join('.')`. Under `(string | number)[]` those are identical; under
782
+ `PropertyKey[]` the second **throws** on a symbol key, because `join` cannot
783
+ convert one. The shared formatter uses the safe spelling. `ATX-44` is what made
784
+ the rest checkable rather than hopeful: the `a.b.c` path spelling is a stated
785
+ requirement with a scenario on it, so the property that had to survive already
786
+ had something asserting it.
787
+
788
+ - **`changes/<name>/first-run.json` moves to `version: 2`, and a version-1 file
789
+ is discarded whole.** The record now nests the scenario name under the spec
790
+ file, so the old shape cannot be read as the new one. Discarding is the safe
791
+ direction — an empty record makes every obligation "never observed", which the
792
+ gate blocks on — and the cost is one stage-1 run to re-observe, for a change
793
+ in flight across this upgrade only. The version is *checked* rather than
794
+ inferred from the old shape failing the schema: it would fail it anyway, but
795
+ "discarded because the format moved" and "discarded because the file is
796
+ corrupt" are different facts, and reading one as the other is how a format
797
+ change happens without anyone deciding to make it. Unrelated to the report
798
+ `schemaVersion`, which does not move — `src/cli/json.ts` is untouched, and no
799
+ envelope, field or `code` changed.
800
+
801
+ ### Fixed
802
+
803
+ - **A scenario that never ran was reported as attested, when another spec file
804
+ declared a scenario of the same name under the same `requirement()`.** Runtime
805
+ coverage was keyed `(reqId, scenario name)` with no file, so two spec files
806
+ declaring `scenario('x')` under `requirement('APP-1')` were one entry, and
807
+ whichever of them executed vouched for both. `declared-not-run` — the check
808
+ whose whole job is to catch a declared scenario that did not execute — was
809
+ answering about the wrong scenario, and answering "fine".
810
+ Reproduced on 2026-08-09 with no flag, no `--eval` and default configuration:
811
+ a `real.spec.ts` whose `scenario('x')` runs beside a `ghost.spec.ts` whose
812
+ `scenario('x')` sits in a branch that never registers gives
813
+ `✓ No issues. — 1 requirement / 2 scenarios`. Renaming the ghost to `'y'`
814
+ produces the `declared-not-run` naming it, so the collision was the whole of
815
+ the difference — the check was present and was looking at the wrong key.
816
+ **This is the failure this tool exists to refuse**, which is what put it ahead
817
+ of everything else in flight: a report that says a scenario attested a
818
+ requirement when the scenario did not execute.
819
+ **The same key reached the first-run record, and there it is worse.** The
820
+ record is monotonic toward `fail`, so two scenarios of one added requirement
821
+ sharing a name meant one going red satisfied the other's `never-red`
822
+ obligation — mechanism 2 certifying a discrimination that never happened.
823
+ That half was inferred from the shared key when this was filed and is measured
824
+ here: both halves go red independently under control, which is why `ATX-71` is
825
+ two obligations rather than one.
826
+ Fixed by carrying the file into the key rather than by adding a check. `walk`
827
+ already descends from the file task, which is the only level holding a path,
828
+ and `ParsedScenario` has carried `file` since it existed — so both sides of
829
+ design §5.4's one cross-process channel had the field and only the maps
830
+ forgot it. It goes through `relativePath` for the reason `unloadedFiles` does:
831
+ the key is compared against a plan built on POSIX paths, and a native
832
+ separator on Windows would turn this false green into a false red.
833
+ Nested (`reqId -> file -> name`) rather than a composite key string, at both
834
+ ends: no separator has to be chosen that a path or a scenario name could
835
+ contain, and `first-run.json` stays a file a reviewer reads.
836
+ **What the fix cost, and the alternative that was rejected.** Keyed by the
837
+ name on disk, the record broke `--apply`'s resume: step 2 renames
838
+ `x.proposed.spec.ts` to `x.spec.ts`, so a resume after that rename looked up a
839
+ key written under the proposed name, found nothing, and blocked a change whose
840
+ scenarios *had* been observed red — the gate refusing its own evidence, caught
841
+ by `ATX-53`'s two resume scenarios. Rewriting the record beside the rename was
842
+ the obvious repair and is worse: two files that must move together, in a
843
+ design whose entire resume story is that each step is separately idempotent.
844
+ So the record keys by the **merged** spelling from the start, which the rename
845
+ cannot move. It still separates two spec files, which is the collapse the key
846
+ exists to prevent; what it deliberately does not separate is a file from its
847
+ own proposed name, because those are the same file.
848
+ Not claimed: that a scenario name is unique within one file. It need not be,
849
+ and this key does not make two same-named scenarios in one file
850
+ distinguishable. What it closes is the cross-*file* case, which is the one
851
+ reachable by ordinary means — two authors adding a scenario to the same
852
+ requirement from two files is how a requirement grows, while a duplicate name
853
+ inside one file is visible in the file being read.
854
+ Measured on Linux; **Windows is unmeasured**, and the path-shaped half of the
855
+ fix is exactly what differs there.
856
+
857
+ - **An error class was being used as a formatter, at two sites that never threw
858
+ it.** `locate.ts` and `static-registry.ts` each constructed a
859
+ `RegistryValidationError` purely to borrow its `.message`, because both report
860
+ the same `registry-invalid` as the throwing path and the message has to be
861
+ identical rather than merely similar. The formatter is what those sites wanted
862
+ and it is now the thing they can ask for — `registryValidationMessage` in the
863
+ new `core/registry-issues.ts`, which all three sites share. That sharing is
864
+ what makes the messages structurally identical instead of a coincidence
865
+ maintained by hand.
866
+ The module is separate from `registry.ts` rather than added to it because
867
+ `./define` maps to the whole of `registry.ts`, so everything that file exports
868
+ is a public contract. The issue *type* has to be public; the formatter does
869
+ not, and both of its callers are internal.
870
+
871
+ ### Added
872
+
873
+ - **`tests/registry-error-types.spec-d.ts` pins the shape that was just
874
+ narrowed.** The property is entirely type-level — it has no runtime footprint,
875
+ and the engine reads registries from the AST — so a `.spec-d.ts` is the only
876
+ thing that can catch a regression in it, and a green suite would otherwise
877
+ carry one through to users untouched. It pins both halves: that the element
878
+ type is exactly the two fields (`toEqualTypeOf`, so a future edit widening it
879
+ back to a union fails), and that a real parse failure still assigns to it with
880
+ no cast. Confirmed to go red on the exact regression it exists to catch by
881
+ reverting the field to `z.ZodIssue[]` and re-running it.
882
+
16
883
  ## [0.7.4] - 2026-08-08
17
884
 
18
885
  ### Added
@@ -3875,15 +4742,139 @@ checked.** The discipline aims at keeping the *argument*, not at re-testing the
3875
4742
  evidence under it, so the first act of building one is re-measuring what it
3876
4743
  claims rather than implementing what it proposes. That has paid three times:
3877
4744
  once killing an entry outright, when the spans it rested on turned out not to be
3878
- spans; twice correcting an entry's own numbers before a line of it was written —
3879
- a call-site count that a regex had inflated, and a cost filed as "a fixture per
3880
- diagnostic" that the corpus put at one fixture. **What a release costs its adopters is not known on the day it is cut**
4745
+ spans; four times correcting an entry's own numbers or scope before a line of it
4746
+ was written — a call-site count that a regex had inflated, a cost filed as "a
4747
+ fixture per diagnostic" that the corpus put at one fixture, an injection whose
4748
+ payload turned out to need the target file's *own* quote character, which is the
4749
+ difference between escaping one quote and escaping the right one, and a fix
4750
+ filed as a map over `issues` that the report's own shape moved to the
4751
+ serialiser, four fields and one nesting level further out. **What a release costs its adopters is not known on the day it is cut**
3881
4752
  — several entries arrived from one adoption report, about shapes the tool made
3882
4753
  someone work around without ever erroring. And **the fixes with no observable
3883
4754
  symptom are worth batching**: individually none justifies a commit, together they
3884
4755
  cost about an hour, and they are the group no gate could have found, since having
3885
4756
  no symptom is the definition of what a gate cannot see.
3886
4757
 
4758
+ *The five entries this section carried before the ones below* were built and are
4759
+ recorded under `[0.8.0]`. The second is where this preamble's own
4760
+ discipline paid again: it went in on an entry whose headline type
4761
+ measurement — instantiations unchanged — was false on re-measure, and the
4762
+ re-measure is the only reason the release notes do not repeat it. The fourth is
4763
+ the other outcome the discipline has, and worth a line because the first three
4764
+ were all corrections: the `render --out` symlink measurement reproduced exactly
4765
+ as filed, down to the leaf being safe for the reason given. What re-measuring
4766
+ added was not a correction but a **surface the entry had not counted** —
4767
+ `--check` reads the same destination, so the same link makes a freshness gate
4768
+ date a document outside the project. Re-measuring an entry is therefore not only
4769
+ a test of the number it quotes; walking the code the claim is about is what
4770
+ shows the claim's own edges. The fifth is that lesson arriving where an entry
4771
+ had already named its own fix: the C1 measurement reproduced exactly, and the
4772
+ repair it proposed — a map over `issues` — was still the wrong half, because
4773
+ four more fields and a nested scenario name carry the same untrusted text and
4774
+ never pass the envelope. An entry that ends with a fix is the one least likely
4775
+ to have its fix re-examined, which is the case worth naming.
4776
+
4777
+ *That has now happened twice, so it is a pattern rather than an incident.* The
4778
+ extensionless-import entry likewise reproduced exactly on its reported half and
4779
+ was wrong about its own repair — it recorded that the writing half needed
4780
+ nothing, and the writing half was guarded by a condition that was correct only
4781
+ while the reading half stayed broken. Both times the error had the same shape: an
4782
+ entry reasons about the code path it can see failing, and the repair makes a
4783
+ second path reachable that nobody has looked at because nothing could reach it.
4784
+ So the re-measure that matters is not "does the reported symptom reproduce" —
4785
+ both did, exactly — but **what does the proposed fix make reachable for the first
4786
+ time**.
4787
+
4788
+ *The first time that question was asked in advance, it paid, and how it paid is
4789
+ worth a line because it is not what the two cases above would predict.* The
4790
+ `check` `empty-spec` entry — `[Unreleased]`, the third entry to reproduce exactly
4791
+ on its reported half — was not wrong about its own repair. Asking the question
4792
+ anyway found the defect one step to the side: the fix it proposed was correct,
4793
+ and the *obvious implementation of it* — make `check` agree with `verify` —
4794
+ would have copied a misdiagnosis `verify` had been emitting unnoticed, because
4795
+ the reference implementation an entry points at is not itself re-read. So the
4796
+ question generalises past the shape it was learned from. It is not only about a
4797
+ path the repair makes reachable; it is about **everything the repair would make
4798
+ `check` agree with**, and an entry that names the right fix can still be
4799
+ implemented wrongly by copying the half nobody has audited.
4800
+
4801
+ - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
4802
+ The provenance work in `[0.7.2]` signs the release but does not change
4803
+ what authorises it: a long-lived automation token, masked and protected, that
4804
+ publishes as us to anyone holding it. npm's trusted publishing exchanges the
4805
+ same OIDC identity the attestation already uses for a short-lived credential,
4806
+ which would remove the stored secret entirely and grant provenance as a
4807
+ property of the flow rather than a flag on the command. The deletion test
4808
+ points the right way for once: what it removes is the only long-lived
4809
+ credential this repository has.
4810
+ **The blocker this entry was filed with is retired, and it was the wrong
4811
+ question** — checked against npm's documentation on 2026-08-08. It asked
4812
+ whether npm's trusted publishers accept a **gitlab.com-hosted** project on the
4813
+ plan this package publishes under, and both halves come back clean: GitLab
4814
+ CI/CD is a first-class provider alongside GitHub Actions, and no plan
4815
+ requirement is stated anywhere in the documentation. The four fields it wants
4816
+ are `Pseudorca`, `attest`, the top-level CI file path (`.gitlab-ci.yml`, and it
4817
+ must carry the extension) and the allowed actions; environment name is optional
4818
+ and this project uses no GitLab environments. Shared runners are the only ones
4819
+ supported and the publish job carries no `tags:`, so it is already on them. The
4820
+ `repository.url` cross-check needs no separate verification — provenance
4821
+ already passes it with the same identity.
4822
+ Two things the entry did not anticipate, both of which change the shape of the
4823
+ change rather than blocking it. **`SIGSTORE_ID_TOKEN` is not replaced**: the
4824
+ documented GitLab snippet declares `NPM_ID_TOKEN` with
4825
+ `aud: "npm:registry.npmjs.org"` *beside* it, so this is an addition to the
4826
+ `id_tokens` block, not a swap. And **the YAML is not what deletes the
4827
+ credential** — enabling a trusted publisher leaves the automation token just as
4828
+ able to publish; what retires it is `Require two-factor authentication and
4829
+ disallow tokens` on the package, which npm states does not affect trusted
4830
+ publishers. That setting is the one that closes the loop this job's own comment
4831
+ opens, where an automation token is used *because* a granular token fails
4832
+ against 2FA on writes: trusted publishing does not bypass the prompt, it stops
4833
+ being a token.
4834
+ *What blocks the decision now is `pnpm`, and it is not a version to wait for.*
4835
+ **pnpm does not implement the OIDC exchange at all.** `pnpm/pnpm#11526` — the
4836
+ fix for the pnpm 11 regression that made this look like a supported path —
4837
+ shows what its "OIDC support" is: `actions/setup-node` writes
4838
+ `_authToken=${NODE_AUTH_TOKEN}` into `.npmrc`, pnpm used to pass the unresolved
4839
+ placeholder through verbatim, and the fix treats it as empty so that **npm's**
4840
+ credential exchange can be the sole auth source. pnpm gets out of the way; npm
4841
+ does the work. That matches npm's own wording, which requires publishing
4842
+ through npm directly or indirectly, and it matches the reports: every account
4843
+ of pnpm publishing under OIDC is GitHub Actions, and there is **no record of
4844
+ pnpm + GitLab + OIDC succeeding**. Stated at the strength the evidence carries:
4845
+ this is a strong negative inference, not a documented refusal.
4846
+ So the real cost is a tooling swap — `pnpm publish` back to `npm publish` in
4847
+ this one job — and it is a swap this file has already priced once in the other
4848
+ direction. The comment above records that moving to pnpm took
4849
+ `COREPACK_ENABLE_STRICT` with it, that override having existed only so a bare
4850
+ `npm` could run under a `packageManager` pinning pnpm; going back re-adds it.
4851
+ It also needs npm ≥ 11.5.1 where `node:22-slim` ships 10.x, so the job gains an
4852
+ npm upgrade step. None of that is prohibitive. It is simply a different
4853
+ decision from the one this entry was filed as, which is why it stays here.
4854
+ *One correction to the entry's own risk framing.* "It cannot be tested except
4855
+ by publishing" is half wrong: registering a trusted publisher on npmjs.com is
4856
+ free and reversible, and `NPM_TOKEN` can stay in place while it is tried. What
4857
+ decides whether this can be rolled out incrementally is whether a failed OIDC
4858
+ exchange falls back to the `_authToken` already in `.npmrc` or fails the
4859
+ publish outright — unmeasured, and the thing to establish before touching the
4860
+ job.
4861
+ *Method note, because it is the reusable part.* The pinned pnpm bundle was the
4862
+ obvious place to look and was the wrong one — `pnpm publish --help` on 10.28.0
4863
+ omits the flags it accepts, exactly as this job's comment already records for
4864
+ `--provenance`, so neither the help text nor the shipped bundle would have
4865
+ answered it. The answer was in the upstream pull request.
4866
+ **Promoted from `Under consideration` on 2026-08-08**, and what moved it is
4867
+ that the question it was filed as is answered. It asked whether npm's trusted
4868
+ publishers would accept this project at all; they will. What is left is a
4869
+ known tooling swap with a priced cost, which is a decision to build rather
4870
+ than a question — the preamble's own line, *a decision with an argument
4871
+ attached*. One thing stays unmeasured and is the first act of building it, per
4872
+ the discipline three paragraphs up: whether a failed OIDC exchange falls back
4873
+ to the `_authToken` already in `.npmrc` or fails the publish outright. That
4874
+ decides whether this rolls out incrementally beside the existing token or in
4875
+ one cut, and it is cheap to establish because registering a trusted publisher
4876
+ is free and reversible.
4877
+
3887
4878
  ## Under consideration
3888
4879
 
3889
4880
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3896,51 +4887,6 @@ deletion. What each entry has to carry is the evidence currently blocking the
3896
4887
  decision, because that is what a re-proposal a year from now would otherwise
3897
4888
  have to gather again.
3898
4889
 
3899
- - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
3900
- The provenance work in `[0.7.2]` signs the release but does not change
3901
- what authorises it: a long-lived automation token, masked and protected, that
3902
- publishes as us to anyone holding it. npm's trusted publishing exchanges the
3903
- same OIDC identity the attestation already uses for a short-lived credential,
3904
- which would remove the stored secret entirely and grant provenance as a
3905
- property of the flow rather than a flag on the command. The deletion test
3906
- points the right way for once: what it removes is the only long-lived
3907
- credential this repository has.
3908
- *What blocks the decision, and it is evidence rather than principle.* Whether
3909
- npm's trusted publishers accept a **gitlab.com-hosted** project on the plan
3910
- this package publishes under has not been verified against this project — it
3911
- was not checked when the provenance job was written, and an entry claiming it
3912
- works would be exactly the unverified confidence the rest of this file avoids.
3913
- It also cannot be tested except by publishing: as with provenance, there is no
3914
- dry run, so a failed experiment is a release. The cheap next step is reading
3915
- npm's current trusted-publisher documentation for GitLab support before
3916
- writing any YAML.
3917
-
3918
- - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
3919
- project with no concurrency limit: `findFiles` recurses through
3920
- `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
3921
- once, and `parseSpecs` does `Promise.all(files.map(readFile))`, which reads
3922
- every spec file into memory before parsing any of them. Neither is bounded by
3923
- anything, and `check` is the command this repository tells people to run first
3924
- in a pipeline on an untrusted fork MR — so the input size is not ours to
3925
- assume.
3926
- *The evidence, and why it is not enough to act on.* Measured on a synthetic
3927
- tree of 6000 spec files: the fan-out is real and unbounded — peak in-flight
3928
- `readFile` calls came back as exactly 6000 — and `attest check` finished in
3929
- 1.99 s without erroring. It could not be made to fail. Windows is why: Node
3930
- uses Win32 handles there rather than POSIX descriptors, so the `ulimit -n` of
3931
- 3200 that the shell reports does not govern the process, and the one platform
3932
- where descriptor exhaustion is plausible is the one CI runs and this
3933
- measurement could not reach (`CLAUDE.md`'s two-platform rule, pointing the
3934
- unusual way round). What *is* portable is the memory: `parseSpecs` holds every
3935
- source at once, which at a realistic 8 KiB per spec file is ~47 MiB at this
3936
- count — noticeable, not fatal.
3937
- So the fix is known and cheap — a concurrency limit of ~32 at both sites, and
3938
- parsing each source as it arrives rather than after all of them, which makes
3939
- the peak constant — and it is held back by this file's own standard: no report,
3940
- and no demonstrated failure on any platform. Recorded here so that the day
3941
- someone hits an `EMFILE` from `attest check`, the diagnosis is already written
3942
- down rather than rediscovered.
3943
-
3944
4890
  - **An assertion that recomputes its expectation from the param the code under
3945
4891
  test just read is a tautology, and nothing says so.** Reported by
3946
4892
  `mine-capablanca`, which probed it by mutating the registry and running the
@@ -4116,115 +5062,6 @@ have to gather again.
4116
5062
  out below* — the entry is kept whole here because the two were measured in one
4117
5063
  pass and separating them is the finding.
4118
5064
 
4119
- - **Whether `check` and `verify` should refuse a half-loaded registry the way
4120
- `cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
4121
- and the argument is recorded at both call sites: a report computed from a
4122
- half-loaded registry is a lie, and a lie that reads as a pass is the worst
4123
- shape it can take — `cover` in particular used to answer `ok: true` on a repo
4124
- whose intent layer had stopped being readable, because an empty registry is
4125
- fully covered. `check` and `verify` carry on regardless.
4126
- *What that produces, measured* on a project with one unreadable `*.reqs.ts`:
4127
- a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
4128
- that file's requirements, plus `empty-spec`, `tests-red` and a
4129
- `declared-not-run` per scenario — six derived findings from one cause. Worse,
4130
- `orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
4131
- for an id already sitting in the registry that failed to load, so the loudest
4132
- advice in the report points at work that must not be done. In a multi-file
4133
- registry it scales with the broken file's scenario count, and the one true
4134
- finding is at the top where a wall of red pushes it out of view.
4135
- *Why it is not simply the same call as `cover` and `render`.* Each of those
4136
- produces exactly **one** artifact, and refusing to produce it is the whole of
4137
- the decision. `check` is the command a pipeline runs first and its contract is
4138
- breadth — returning three findings instead of nine because one of the nine was
4139
- a load failure makes it quieter about problems it can still see perfectly well,
4140
- which is the opposite of what it is for. `verify` has the same shape with a run
4141
- attached.
4142
- *What blocks the decision.* The narrower move — keep reporting everything
4143
- except the findings **derived from** the missing registry, i.e. suppress
4144
- `orphan-test` for ids whose prefix belongs to a file that failed to load —
4145
- needs a prefix→file mapping for a file that never parsed, and that is exactly
4146
- the file `loadRegistry` has no ids from. Recovering the mapping means reading
4147
- the prefix out of something other than the registry's own keys (the file name,
4148
- or a partial AST read that survives the failure), and whether either is
4149
- trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
4150
- weaker signal "some registry file failed to load, so suppress all
4151
- `orphan-test`" is the other candidate and is a strictly worse `check` on a
4152
- multi-file registry, where the other files are fine and their orphans are real.
4153
- **The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
4154
- fixed under `[Unreleased]`, and the blocker above was the wrong question.**
4155
- The mapping this entry called for could not be recovered, and did not have to
4156
- be: what a suppression needs is not the broken file's *prefix* but the ids it
4157
- declares, and those are written in its source. Reading them back is a source
4158
- read that returns ids and builds nothing, so the failure modes that matter —
4159
- a truncated file, a missing default export, a non-literal value, a
4160
- schema-invalid entry, a module that throws at import — all give them up. The
4161
- six derived findings from one cause are now two ERRORs and one WARNING, and
4162
- the collapse is per id rather than per prefix, which is strictly more precise
4163
- than what was blocked: a genuine typo sharing a prefix with the broken file
4164
- stays reported. **The lesson is the entry, not the fix.** It sat here for two
4165
- releases behind a blocker stated as "the mapping is unavailable", which was
4166
- true and was about a mapping nothing needed; the evidence it carried was about
4167
- the right problem and the mechanism it proposed was not, and nothing rereads a
4168
- blocker that has already been written down. That is the same failure the
4169
- section's own preamble records about a figure nobody re-checks, one level up:
4170
- the *shape* of the proposal goes stale as quietly as its numbers do.
4171
- *What is left of this entry is only the original question* — whether `check`
4172
- and `verify` should refuse a half-loaded registry outright, the way `cover`
4173
- and `render` do — **and the evidence that motivated it has been spent.** The
4174
- argument for refusing was never the load failure itself; it was the wall of
4175
- derived findings burying it, and that wall is gone. What remains is the
4176
- breadth argument recorded above, which points the other way for both commands.
4177
- It stays here rather than moving down because "the reason to do it was
4178
- removed" is not the same as "it was decided against", and the case would come
4179
- back the moment another finding is found to scale with a broken registry
4180
- rather than with the number of problems.
4181
-
4182
- - **A statement that quantifies over a surface, with scenarios covering only the
4183
- part that existed when it was written.** Found by reading, and it had already
4184
- cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
4185
- a report, and the stack of a crash — MUST replace every control character",
4186
- and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
4187
- the two functions that existed when it was written. `render` builds a document
4188
- by concatenation that never went past the sanitiser, and a registry could drive
4189
- the reviewer's terminal from `attest render` with no flag at all — shipped
4190
- under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
4191
- specific to that requirement.
4192
- The shape: a universal quantifier in the statement, a finite set of scenarios,
4193
- and nothing anywhere that holds the two together. It reads as covered — the id
4194
- has scenarios, `cover` is green, `check` is green — and the quantifier is the
4195
- part no gate is looking at. Every one of the three mechanisms this project
4196
- sells operates *below* it: coverage asks whether an id has a scenario,
4197
- never-red asks whether that scenario discriminated, drift asks whether it reads
4198
- the params. None asks whether the scenarios span what the sentence claims.
4199
- *What blocks the decision.* Detecting it means knowing which surface a
4200
- statement names, which is prose judgement — the same judgement
4201
- `tests/intent-rule-candidates.spec.ts` records as the reason obligation
4202
- counting can never be an engine rule ("asserting a ground truth Attest cannot
4203
- compute for someone else's requirements, which is exactly what §0 refuses").
4204
- So the shippable form may not exist.
4205
- **The repo half has shipped, and this entry is now only about the engine
4206
- half.** `[0.7.0]` adds the `QUANTIFIED` table it described — hand-judged,
4207
- a row per flagged statement, every named scenario asserted to exist — and it
4208
- closed the way an entry built from a strict read is supposed to: it found a
4209
- live hole on `ATX-62`, the one requirement written specifically to avoid this
4210
- shape, where the archive gate path was claimed by the statement and asserted by
4211
- nothing. Two things came back that the entry did not predict. The useful one is
4212
- that "names a surface" splits three ways rather than two — `values`, `roster`,
4213
- `sites` — and only `sites` carries the risk, because a `roster` scenario
4214
- iterates the set the code owns and grows with it. That is a sharper statement
4215
- of the defect than "the scenarios do not span the sentence", and it is a rule
4216
- an author can act on: **prefer a quantifier a scenario can iterate.** The other
4217
- is that the remedy — rewriting the statement to name its surfaces, as `ATX-62`
4218
- did — takes it out of the flagged set, so the table has to allow rows for ids
4219
- the detector does not flag or it loses the binding exactly when the author does
4220
- the right thing.
4221
- *What stays open is the engine half, and nothing above touches it.* The three
4222
- kinds are prose judgement, the detector misses the article form ("A registry
4223
- that cannot be read MUST …") which is a universal too, and no rule can tell
4224
- which surface a sentence claims without being told. The repo half is a
4225
- counter-pressure, not a detector, and it holds for one registry whose authors
4226
- agreed to maintain it.
4227
-
4228
5065
  - **Whether a requirement can ever be retired, and what it costs that none can.**
4229
5066
  The registry is at **69 requirements and 192 scenarios, 2.78 each**, and
4230
5067
  every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
@@ -4272,6 +5109,138 @@ have to gather again.
4272
5109
  falling, which is the premise the whole entry rests on, and it is the first
4273
5110
  time this file has had two measurements of it to compare.
4274
5111
 
5112
+ - **Running `verify` over a subset of the scenarios, and what a partial run may
5113
+ be allowed to say.** `verify` is all-or-nothing and `check` executes nothing,
5114
+ so there is no middle. Reported by an adoption where `verify` takes **7m33s**
5115
+ wall clock — three requirements are strength gates that play twelve long games
5116
+ each — with the consequence stated as a change in *when* the tool gets used:
5117
+ `verify` became a run-at-the-end command, and `archive` became the everyday
5118
+ one, paying the full suite on every invocation because its verdict was the one
5119
+ needed. The ask is a selector — `--only`/`--skip` over ids or tags, or simply
5120
+ honouring a Vitest name filter — with the partial scope reported loudly enough
5121
+ that a subset pass cannot be read as a pass.
5122
+ *The cost is not Attest's, and that is the first thing the decision turns on.*
5123
+ Every expensive scenario in that report is behavioural: the engine's own
5124
+ overhead is not what makes 7m33s. So this is a selector over someone else's
5125
+ suite, and Vitest already has one — the question is whether Attest reporting
5126
+ *over* a filtered run is worth owning, not whether the run can be filtered.
5127
+ *What blocks it — three, and they are about the report rather than the run.*
5128
+ **(1)** Coverage is computed against the whole registry: under a filter every
5129
+ unselected requirement has no scenario that ran, which is `uncovered-requirement`
5130
+ and `declared-not-run` firing on requirements that are fine. Either those codes
5131
+ gain a meaning that depends on which flags the run carried — and a `code` is
5132
+ the one thing in a report consumers are told to branch on — or the report has to carry its scope
5133
+ explicitly and the codes have to be computed within it. **(2)** `archive`'s
5134
+ gate cannot accept a partial run at all: "done" having a hard definition is the
5135
+ claim the tool is for. So the flag has to be refused there, which means two
5136
+ shapes of `verify` and a rule for which commands may carry it. **(3)** `--json`
5137
+ is where a partial run does its damage — a consumer branching on `ok` must not
5138
+ read a subset pass as a pass, so this needs a field naming the scope and a
5139
+ decision about `SCHEMA_VERSION`, and that is the cheapest part to price first
5140
+ because it decides whether the feature is additive at all.
5141
+ *What would move it.* A second report of the same shape, ideally one where the
5142
+ slow half is smaller, so the question is a selector's ergonomics rather than
5143
+ one project's gates; and a scored answer to whether a scope-carrying report can
5144
+ be stated in a way that a `--json` consumer written before it cannot misread.
5145
+ **Measured on 2026-08-12, before building anything, per this section's
5146
+ discipline. The second of those two is now answered, and the answer is a
5147
+ refusal; the first blocker was wrong; and the whole feature turns out to be a
5148
+ different, much cheaper one.**
5149
+ *The cost claim reproduces on a second corpus.* `verify self`: the static half
5150
+ — scan, registry load, `parseSpecs` — is **79 ms of 30,539 ms, 0.26%**. So a
5151
+ selector cannot save engine work anywhere; it can only save scenarios, and the
5152
+ entry was right that this is a selector over someone else's suite.
5153
+ *Blocker **(1)** conflates two codes with different inputs, and no arrangement
5154
+ fires both.* `uncovered-requirement` reads registry × plan and is purely
5155
+ static; `declared-not-run` reads plan × runtime coverage. Measured over 77
5156
+ requirements and 214 scenarios, selecting one requirement: filtering the **run
5157
+ scope** while leaving the plan whole gives 209–213 spurious `declared-not-run`
5158
+ and **zero** `uncovered-requirement`; filtering the **plan** gives 76
5159
+ `uncovered-requirement` and **zero** `declared-not-run`. The choice of where
5160
+ the filter goes decides which one misfires, and the entry priced a case that
5161
+ cannot occur.
5162
+ *And on the run-scope arrangement it does not misfire at all, which is the
5163
+ finding that collapses the rest.* A run that did not execute a declared
5164
+ scenario really has not attested it, so `declared-not-run` is **true** under a
5165
+ filter — only its message guesses the wrong cause ("skipped, or excluded by an
5166
+ `.only`?"). The premise under "the codes have to be computed within [the
5167
+ scope]" is therefore false: the report is already honest, and a subset run is
5168
+ already loudly not a pass. 209 ERRORs on a one-requirement filter is the
5169
+ loudness the entry asked to build.
5170
+ *Blocker **(3)**, scored, comes back **impossible rather than expensive**.* The
5171
+ contract `cli-reference.md` publishes to consumers is that "a consumer can
5172
+ always parse stdout and branch on `ok`"; the next paragraph describes
5173
+ `schemaVersion` as a producer-side promise — what the emitter bumps — and
5174
+ checked across the whole tree rather than that page, every other mention of it
5175
+ is producer-side too, `ATX-5` included, which obliges the report to *declare*
5176
+ it. Nothing tells a consumer to gate on it before trusting `ok`. A consumer
5177
+ written before a `scope` field does not read new fields — that is what makes it
5178
+ a consumer written before them — so no
5179
+ additive shape can protect it, and a `SCHEMA_VERSION` bump is a signal to a
5180
+ reader who is not looking. Exactly one design is safe: a subset run never
5181
+ reports `ok: true`. That is what already happens, for free, by the paragraph
5182
+ above. So the correct answer to "does this need a scope field and a schema
5183
+ decision" is **neither**.
5184
+ *Blocker **(2)** dissolves into that.* `archive` refusing a partial run stops
5185
+ being a rule to design and becomes a consequence: a partial run cannot be
5186
+ `ok`, and the gate already refuses what is not.
5187
+ *What the measurement also shows is that the feature as asked would not have
5188
+ helped the reporter.* Their report says `archive` became the everyday command
5189
+ **because its verdict was the one needed**. A subset run cannot carry a
5190
+ verdict — that is the whole of blocker (3) — so a selector would have given
5191
+ them a fast non-verdict, which they already have by running Vitest directly.
5192
+ *The argument that survives is one the entry does not make, and it is the
5193
+ strongest available.* The child run is `config: false` — no aliases, no DOM, no
5194
+ plugins — so `npx vitest run one.spec.ts` executes in a **different
5195
+ environment** from the one the verdict comes from, and a green there does not
5196
+ predict `verify`. Owning a selector is the only way to run a subset in Attest's
5197
+ environment. That moves the justification off speed, where Vitest is the
5198
+ incumbent and wins, and onto parity between the fast loop and the verdict,
5199
+ where nothing else can serve.
5200
+ *So the thing to build, if a second report arrives, is not what this entry
5201
+ proposed.* Not a scoped verdict but a **development-loop command**: runs a
5202
+ subset in Attest's environment, reports what did not run as `declared-not-run`
5203
+ because that is true, and exits non-zero always. It claims no pass, so it never
5204
+ touches the impossibility above; it needs no field, no `SCHEMA_VERSION`
5205
+ decision, and no code whose meaning depends on a flag.
5206
+ *A second argument arrived on 2026-08-12, from the author's reason for building
5207
+ `status` rather than from an adoption, and it says a hole exists at all — which
5208
+ is the half the parity argument above does not supply.* `status` was added so
5209
+ that when collaborating with an agent there would be a fast way to know where a
5210
+ change stands without paying `verify` every turn. It delivers that for **stage
5211
+ 1 only**, and structurally rather than by omission: its two obligations — has
5212
+ each ADDed id a scenario, has each been recorded failing — are both settled by
5213
+ the end of stage 1 and never move again, so it reads all-`proven` for the whole
5214
+ of stage 2. Stage 2 is the long half, and the workflow document tells the agent
5215
+ to run `attest archive` there every iteration, correctly, because
5216
+ `*.proposed.spec.ts` is deliberately outside `verify`'s scope: the gate is the
5217
+ only Attest command that runs a change's own scenarios at all. So the everyday
5218
+ full-gate run in that adoption report was not a habit — it is what this
5219
+ project's own shipped instructions require.
5220
+ **The cost curve is per-turn, not per-CI-run**, which is what makes this
5221
+ different from the report the entry was filed on. A saving that looks small
5222
+ against one pipeline run is multiplied by every iteration of a conversation, and
5223
+ the agent is the consumer this framework writes a document *for*.
5224
+ *Labelled for what it is:* not the second adoption report this entry asks for,
5225
+ but a structural argument from this repository's own workflow, which is a
5226
+ different kind of evidence — no user was surveyed, and nothing here measures how
5227
+ much stage 2 actually costs anyone. What it does is narrow the thing to build
5228
+ one more notch. The development-loop command above is a **stage-2** tool, and
5229
+ its user is mostly an agent.
5230
+ *The cheap interim was written into the workflow document rather than left for
5231
+ this entry to deliver*, since it needs no engine change: run the proposed spec
5232
+ file directly for the inner loop, keep the gate for the verdict. Measured
5233
+ first — a `*.proposed.spec.ts` runs under a plain Vitest invocation, delta
5234
+ import and all — and it is safe in the one way that matters, because only the
5235
+ gate writes `first-run.json`. It is *not* a substitute, for the parity reason
5236
+ above, and that is why the entry stays open: the interim buys speed by leaving
5237
+ Attest's environment, which is precisely what a real selector would not do.
5238
+ *Still unmeasured, and both need the reporter's repo rather than this one:*
5239
+ whether their fast loop and their verdict actually diverge under `config:
5240
+ false`, which is what decides whether the surviving argument is real for them;
5241
+ and the second report of the same shape, which this measurement does not
5242
+ supply and does not replace.
5243
+
4275
5244
  ## Considered and rejected
4276
5245
 
4277
5246
  Decisions **not** to build something, kept where they can be found before the
@@ -4282,6 +5251,327 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
4282
5251
  sinking one version deeper each time a release was cut above it, and a rejection
4283
5252
  filed under a version reads as belonging to it.
4284
5253
 
5254
+ ### Widening `status` to the rest of the gate's static half
5255
+
5256
+ Scoped and decided on 2026-08-12, in the session that gave `status` its two
5257
+ refusals (an input it could not read, and a delta that will not apply). The
5258
+ question behind both was whether `status` should report *everything* about a
5259
+ change that is decidable without running anything — the gate has a static half,
5260
+ and `status` was projecting two of its checks. Four candidates were priced. Two
5261
+ shipped; these two did not, for opposite reasons, and the reasons are worth
5262
+ keeping because the shape of the proposal will recur.
5263
+
5264
+ *`validateStructure`'s ERRORs over the merged plan — already decided, and not
5265
+ here.* The proposal was to project the gate's first check whole: `orphan-test`,
5266
+ duplicate ids, and `uncovered-requirement` for every id rather than only the
5267
+ ADDed ones. Design §8 had settled it before the proposal was written — `status`
5268
+ projects "the *uncovered* half of check 1 and the whole of check 4, both
5269
+ restricted to the ids the change ADDs", and orphans and unbound params are named
5270
+ as **not** projected, "being facts about the whole applied registry rather than
5271
+ progress on an added id". So this was not a new decision to make but an old one
5272
+ the proposal had not read, and the finding is procedural rather than technical:
5273
+ the design document answers scoping questions about `status` that a reading of
5274
+ `status.ts` alone will not. Rebuilding it means changing §8 first and arguing
5275
+ against that sentence, which nothing here does.
5276
+
5277
+ *The proposed-spec findings — the only genuinely new shape, rejected on
5278
+ duplication.* `proposed-spec-unclaimed`, `proposed-spec-name-taken` and
5279
+ `spec-in-change-dir` are static facts about *this change*, and unlike the
5280
+ refusals that shipped they leave the report perfectly computable — every row
5281
+ stays correct. So they are neither progress nor a failure to compute, which is
5282
+ the one case the existing `issues` contract has no room for, and pricing that
5283
+ room was expected to be the expensive part. It never had to be priced: **`check`
5284
+ already reports all three**, and already reads each change's delta to attribute
5285
+ proposed specs, so `status` reporting them would put a second answer to one
5286
+ question in the tree — the arrangement this project takes apart everywhere else,
5287
+ and the reason `declaredNotRunIssues` and `hasError` are each one function. What
5288
+ was built instead is one line: `status`'s closing line now names `check`
5289
+ alongside `archive`. The cost is real and is not hidden — an agent in stage 2
5290
+ runs two commands where it wanted one — and naming it is the whole of the
5291
+ compensation.
5292
+
5293
+ *The consequence worth recording is that the `--json` question dissolved.* The
5294
+ open design decision that had been sequenced ahead of all four candidates was
5295
+ what shape new `status` findings take in the report: whether `issues` widens
5296
+ from "the report could not be computed" to "here are findings", or a second
5297
+ field arrives, and whether either moves `SCHEMA_VERSION`. After these two
5298
+ rejections there is nothing left to decide, because everything that survives is
5299
+ either a refusal — existing `issues` semantics, `ok: false`, no new field — or
5300
+ `check`'s to report. A blocking decision that evaporates once the candidates
5301
+ either side of it are settled is a sequencing error, not a hard question, and
5302
+ that is the transferable part: the shape of a report is decided by what is left
5303
+ to put in it, so the scoping goes first.
5304
+
5305
+ *What would reopen either.* For the first, an argument against design §8's
5306
+ sentence, not a fresh proposal that has not met it. For the second, a measured
5307
+ cost of the two-command loop — the same evidence the `Under consideration` entry
5308
+ on a stage-2 development-loop command is waiting for, and it would arrive from
5309
+ the same place.
5310
+
5311
+ ### A trailing-dot-and-space clause in `isSafeChangeName`, for a Windows normalisation that does not happen
5312
+
5313
+ Filed under `Under consideration` on 2026-08-09 and rejected on 2026-08-11 by
5314
+ the measurement it was waiting for. The premise was that Win32 strips trailing
5315
+ dots and spaces from a path component below the API, so `'.. '` — which passes
5316
+ every clause the guard has, being neither `''`, `'.'`, `'..'`, a separator nor
5317
+ NUL — would name `changes/`'s parent, and under `--eval` or `archive` that name
5318
+ reaches a module path that is then executed. The entry recorded that no Linux
5319
+ measurement could answer it and named both outcomes in advance: a fourth clause
5320
+ if it reproduced, or a note about the clauses' character if it did not.
5321
+
5322
+ *It does not reproduce.* Measured on Windows 11 26200, Node v22.23.2, NTFS.
5323
+ `mkdir` of `changes/.. ` creates a **literal directory named `.. `** —
5324
+ `readdir` returns `[".. "]` — and a write through the name lands inside it,
5325
+ leaving a file planted at the project root untouched. End to end through the
5326
+ CLI with a payload delta at the root, all of `status '.. '`, `status '.. '
5327
+ --eval` and `archive '.. '` fail with `change-not-found` naming
5328
+ `changes/.. /requirements.delta.ts`, and the payload never executes. Both
5329
+ readers were exercised deliberately, because they resolve differently and only
5330
+ one of them is the dangerous path: the static read and the Vite loader keep the
5331
+ trailing space alike. `'.. '`, `'.. .'`, `'...'` and `'. '` behave the same.
5332
+
5333
+ *The neighbouring win32-specific vectors were measured in the same pass*, since
5334
+ the guard's clauses are POSIX-shaped and this was the moment to ask what else
5335
+ they cannot see. `'C:'`, `'C:x'` and `'a:b'` pass the guard, and `join` splices
5336
+ no drive: every one stays under `changes/` and fails `ENOENT` at the colon.
5337
+ `'CON'` and `'NUL'` create ordinary directories. `'..x'` and `'x..'` are
5338
+ literal. Nothing escaped, and the root file was intact after all of them.
5339
+
5340
+ **What the entry's own fallback asks to keep, now earned rather than assumed:
5341
+ the clauses are the POSIX spelling of path safety, and on this platform that is
5342
+ the complete spelling.** The guard's doc comment says the test is path safety
5343
+ rather than a character whitelist — that claim survives the measurement, and it
5344
+ survives it on the platform most likely to have broken it.
5345
+
5346
+ *Rejected at the strength the evidence carries.* This is one Windows build and
5347
+ one Node version, so what is refuted is the general premise, not every future
5348
+ libuv. **The reach was always one level** — the name eaten gives the project
5349
+ root, and going above it needs a second `..`, which needs a separator the guard
5350
+ already refuses — so even a reproduction would have meant executing a file
5351
+ elsewhere *inside* the project. That bound is why a negative result closes this
5352
+ rather than merely deferring it: the cost of being wrong is bounded and the
5353
+ reopen condition is cheap, being a single `mkdir` on a machine where it behaves
5354
+ differently.
5355
+
5356
+ ### Refusing a half-loaded registry in `check` and `verify`, the way `cover` and `render` do
5357
+
5358
+ `cover` and `render` return early on `hasError(loadIssues)`,
5359
+ and the argument is recorded at both call sites: a report computed from a
5360
+ half-loaded registry is a lie, and a lie that reads as a pass is the worst
5361
+ shape it can take — `cover` in particular used to answer `ok: true` on a repo
5362
+ whose intent layer had stopped being readable, because an empty registry is
5363
+ fully covered. `check` and `verify` carry on regardless.
5364
+ *What that produces, measured* on a project with one unreadable `*.reqs.ts`:
5365
+ a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
5366
+ that file's requirements, plus `empty-spec`, `tests-red` and a
5367
+ `declared-not-run` per scenario — six derived findings from one cause. Worse,
5368
+ `orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
5369
+ for an id already sitting in the registry that failed to load, so the loudest
5370
+ advice in the report points at work that must not be done. In a multi-file
5371
+ registry it scales with the broken file's scenario count, and the one true
5372
+ finding is at the top where a wall of red pushes it out of view.
5373
+ *Why it is not simply the same call as `cover` and `render`.* Each of those
5374
+ produces exactly **one** artifact, and refusing to produce it is the whole of
5375
+ the decision. `check` is the command a pipeline runs first and its contract is
5376
+ breadth — returning three findings instead of nine because one of the nine was
5377
+ a load failure makes it quieter about problems it can still see perfectly well,
5378
+ which is the opposite of what it is for. `verify` has the same shape with a run
5379
+ attached.
5380
+ *What blocks the decision.* The narrower move — keep reporting everything
5381
+ except the findings **derived from** the missing registry, i.e. suppress
5382
+ `orphan-test` for ids whose prefix belongs to a file that failed to load —
5383
+ needs a prefix→file mapping for a file that never parsed, and that is exactly
5384
+ the file `loadRegistry` has no ids from. Recovering the mapping means reading
5385
+ the prefix out of something other than the registry's own keys (the file name,
5386
+ or a partial AST read that survives the failure), and whether either is
5387
+ trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
5388
+ weaker signal "some registry file failed to load, so suppress all
5389
+ `orphan-test`" is the other candidate and is a strictly worse `check` on a
5390
+ multi-file registry, where the other files are fine and their orphans are real.
5391
+ **The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
5392
+ fixed under `[Unreleased]`, and the blocker above was the wrong question.**
5393
+ The mapping this entry called for could not be recovered, and did not have to
5394
+ be: what a suppression needs is not the broken file's *prefix* but the ids it
5395
+ declares, and those are written in its source. Reading them back is a source
5396
+ read that returns ids and builds nothing, so the failure modes that matter —
5397
+ a truncated file, a missing default export, a non-literal value, a
5398
+ schema-invalid entry, a module that throws at import — all give them up. The
5399
+ six derived findings from one cause are now two ERRORs and one WARNING, and
5400
+ the collapse is per id rather than per prefix, which is strictly more precise
5401
+ than what was blocked: a genuine typo sharing a prefix with the broken file
5402
+ stays reported. **The lesson is the entry, not the fix.** It sat here for two
5403
+ releases behind a blocker stated as "the mapping is unavailable", which was
5404
+ true and was about a mapping nothing needed; the evidence it carried was about
5405
+ the right problem and the mechanism it proposed was not, and nothing rereads a
5406
+ blocker that has already been written down. That is the same failure the
5407
+ section's own preamble records about a figure nobody re-checks, one level up:
5408
+ the *shape* of the proposal goes stale as quietly as its numbers do.
5409
+ *What is left of this entry is only the original question* — whether `check`
5410
+ and `verify` should refuse a half-loaded registry outright, the way `cover`
5411
+ and `render` do — **and the evidence that motivated it has been spent.** The
5412
+ argument for refusing was never the load failure itself; it was the wall of
5413
+ derived findings burying it, and that wall is gone. What remains is the
5414
+ breadth argument recorded above, which points the other way for both commands.
5415
+ *What rejected it is that the argument for it was entirely the noise, and the
5416
+ noise is gone.* Refusing was never justified by the load failure itself — it was
5417
+ justified by the wall of derived findings burying it, and the collapse under
5418
+ `[Unreleased]` removed the wall without touching either command's breadth. What
5419
+ is left on the table is the breadth argument alone, and it points the other way
5420
+ for both: `check` is the command a pipeline runs first, its contract is seeing
5421
+ everything it can still see, and answering with three findings instead of nine
5422
+ because one of the nine was a load failure makes it quieter about problems it
5423
+ diagnoses perfectly well. `verify` is the same shape with a run attached.
5424
+
5425
+ *What would reopen it*, stated because a rejection whose condition is unwritten
5426
+ gets re-proposed on a hunch: another finding found to scale with a **broken
5427
+ registry** rather than with the number of real problems. That is the shape that
5428
+ justified this once and could again; a merely noisy report is not.
5429
+
5430
+ ### An engine rule for a statement whose scenarios do not span its quantifier
5431
+
5432
+ The shape was found by reading, and it had already
5433
+ cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
5434
+ a report, and the stack of a crash — MUST replace every control character",
5435
+ and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
5436
+ the two functions that existed when it was written. `render` builds a document
5437
+ by concatenation that never went past the sanitiser, and a registry could drive
5438
+ the reviewer's terminal from `attest render` with no flag at all — shipped
5439
+ under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
5440
+ specific to that requirement.
5441
+ The shape: a universal quantifier in the statement, a finite set of scenarios,
5442
+ and nothing anywhere that holds the two together. It reads as covered — the id
5443
+ has scenarios, `cover` is green, `check` is green — and the quantifier is the
5444
+ part no gate is looking at. Every one of the three mechanisms this project
5445
+ sells operates *below* it: coverage asks whether an id has a scenario,
5446
+ never-red asks whether that scenario discriminated, drift asks whether it reads
5447
+ the params. None asks whether the scenarios span what the sentence claims.
5448
+ **Everything this entry established about the shape has left it, and the two
5449
+ halves left by different doors.** The repo half shipped in `[0.7.0]` as the
5450
+ `QUANTIFIED` table it described — hand-judged, a row per flagged statement,
5451
+ every named scenario asserted to exist — and it closed the way an entry built
5452
+ from a strict read is supposed to: it found a live hole on `ATX-62`, the one
5453
+ requirement written specifically to avoid this shape, where the archive gate
5454
+ path was claimed by the statement and asserted by nothing. What that table
5455
+ taught is now **design §11**, both languages, because it is a permanent
5456
+ property of the design rather than a candidate: which surface a quantifier
5457
+ ranges over — `values`, `roster`, `sites` — decides whether scenarios can keep
5458
+ up with it, only `sites` can silently stop being true, and the rule an author
5459
+ can act on is *prefer a quantifier a scenario can iterate*. The adopter-facing
5460
+ half of that rule is in the `init` skill beside the two `params` rules it
5461
+ completes. Neither was predicted here: the entry proposed a detector and
5462
+ expected a two-way split, and what came back was a three-way one and an
5463
+ authoring rule, which is the more useful object and needed no engine at all.
5464
+ The table's own trap is recorded with it — the remedy of rewriting a statement
5465
+ to name its surfaces takes it out of the flagged set, so the table must admit
5466
+ rows for ids nothing flags, or it loses the binding exactly when the author
5467
+ does the right thing.
5468
+ *What stays here is the engine half alone, and it is undecided rather than
5469
+ blocked-with-evidence.* Detecting this means knowing which surface a statement
5470
+ names, which is prose judgement — the same judgement
5471
+ `tests/intent-rule-candidates.spec.ts` records as the reason obligation
5472
+ counting can never be an engine rule ("asserting a ground truth Attest cannot
5473
+ compute for someone else's requirements, which is exactly what §0 refuses").
5474
+ The detector that exists flags the quantifier and nothing more, and it misses
5475
+ the article form ("A registry that cannot be read MUST …") which is a universal
5476
+ too. So the shippable form may not exist, and what §11 ships instead is a
5477
+ counter-pressure that holds for one registry whose authors agreed to maintain
5478
+ it. *What rejected it is a measurement that was already in the repository when this
5479
+ entry was trimmed, and went unread.* The inline snapshot in
5480
+ `tests/intent-rule-candidates.spec.ts` scores the detector against the real
5481
+ corpus: it flags **30 of 70** requirements, and of those 30, **21 are `values`
5482
+ or `roster`** — correct by construction, nothing for an author to do. A shipped
5483
+ WARNING would therefore fire on 43% of this registry and be wrong about 70% of
5484
+ what it fired on, with no way for the author to discharge it except by rewording
5485
+ the statement.
5486
+
5487
+ *And rewording is both the evasion and the legitimate fix*, which is what makes
5488
+ the precision figure fatal rather than merely bad. `ATX-62` was improved by
5489
+ being rewritten to name its surfaces, and that rewrite takes it out of the
5490
+ flagged set by exactly the same mechanism a lazy reword would. The detector
5491
+ cannot tell them apart, so it grades the wording rather than the coverage. That
5492
+ is the failure design §11 records for every intent-layer detector tried — each
5493
+ "either saw almost nothing, or was cleared by deleting the word that triggered
5494
+ it" — and this one is squarely the second: `compound-requirement` sits
5495
+ at the other pole, never wrong and nearly blind, and the two together are the
5496
+ measured range of what a token detector over prose can do here.
5497
+
5498
+ *What survives is not this proposal.* The three-way classification and the
5499
+ authoring rule are design §11, the adopter-facing form is in the `init` skill,
5500
+ and the hand-judged table is the counter-pressure that did survive measurement.
5501
+ Reopening this needs a signal that is not the quantifier token — the reach limit
5502
+ is the detector's input, not its threshold, so tuning it is the measurement
5503
+ bending to the corpus.
5504
+
5505
+ ### A precomputed analysis written to disk for the child run to read
5506
+
5507
+ `@ark/attest` analyses the whole project once, writes it to
5508
+ `.attest/assertions/*.json`, and has the test process do position lookups
5509
+ against that file instead of a compiler — because its assertions need a full
5510
+ `ts.Program`, which cannot be afforded per test. The shape maps onto `verify`
5511
+ exactly: the parent parses registries and specs, then `runAndCollect` spawns a
5512
+ Vitest run that currently shares nothing with that work.
5513
+ *What rejected it is that the case for it was never made and the case against it
5514
+ is structural.* There is **no
5515
+ measurement** saying parsing costs anything here — this reader is
5516
+ `createSourceFile` per file with no type checking, which is the cheap half of
5517
+ what arktype pays for, and the fan-out measurement — filed as an entry in
5518
+ `Under consideration`, and now shipped as the two concurrency bounds in
5519
+ `[0.7.0]` and `[Unreleased]` — clocked `attest check` over 6000 spec files at
5520
+ 1.99 s total. Worse, a cache is a correctness hazard
5521
+ of precisely the kind this tool exists to detect: a stale analysis makes a
5522
+ **drift detector** report drift that has already been fixed, or miss drift
5523
+ that has just appeared, and it would do so silently. Any version of this needs
5524
+ an invalidation story stronger than a mtime before it is worth the seconds it
5525
+ saves, and the repo already has one cautionary precedent in the same
5526
+ direction — `bin/attest.js` loading `dist/` rather than `src/` is a cache with
5527
+ no invalidation at all, and it is in `CLAUDE.md`'s Gotchas because of how
5528
+ quietly it misleads. Note also what arktype gets wrong in the same file and
5529
+ what not to copy with it: the assertion cache and the queue of pending
5530
+ snapshot writes share one JSON document, so two things with unrelated
5531
+ lifetimes are invalidated together.
5532
+
5533
+ *What would reopen it* is a measurement, and the entry names which one: a
5534
+ parse cost large enough to be worth a cache, on a real project rather than a
5535
+ synthetic tree. Until that exists this is a correctness hazard bought with
5536
+ seconds nobody has shown are being spent, and the invalidation story would have
5537
+ to be stronger than an mtime before the trade is even worth pricing.
5538
+
5539
+ ### Zod Mini (`zod/v4-mini`) for the registry schema
5540
+
5541
+ Evaluated alongside v4 (which shipped under `[Unreleased]`), on the same
5542
+ schema and the same 69-requirement registry, and it wins both numbers it is
5543
+ supposed to win:
5544
+ **0.249 ms** per parse against v3's 3.47 ms, and — the one v4 does not deliver —
5545
+ type instantiations down from **27,681 to 6,581**, check time 0.25 s → 0.14 s.
5546
+
5547
+ *What rejected it is that both winners are collected by the wrong party.* Mini
5548
+ exists for tree-shaken bundle size in a browser. This ships as a Node CLI loaded
5549
+ from `dist/`, where bundle size is not a constraint on anything — apply the
5550
+ deletion test to mini's benefit and nothing in this repository changes. The
5551
+ instantiation saving is real but it is paid to **this project's own
5552
+ `pnpm typecheck`**, not to any adopter, and it is 0.11 s off a build already
5553
+ measured in seconds. The runtime saving is 3.2 ms against a 390 ms command.
5554
+
5555
+ *And the cost lands on the file least able to afford it.* Mini's API is
5556
+ functional rather than chained:
5557
+
5558
+ ```ts
5559
+ params: z.record(z.string(), paramValue).default({}) // v3 / v4
5560
+ params: z._default(z.record(z.string(), paramValue), {}) // mini
5561
+ ```
5562
+
5563
+ `src/core/schema.ts` describes itself as the single source of truth for the
5564
+ shape of the intent layer, and it is written as one — the comments on
5565
+ `isPlainObject` and on the `params` union are longer than the code they sit
5566
+ over, because the file is read as a document by anyone deciding what a
5567
+ requirement may contain. Trading its legibility, and accepting an
5568
+ underscore-prefixed `_default` in it, buys 3 ms on a command nobody is waiting
5569
+ for.
5570
+
5571
+ This reopens only if something makes bundle size a constraint here — a browser
5572
+ or edge build of the registry reader, which nothing currently proposes — and not
5573
+ on a faster benchmark, since the benchmark was never what was in doubt.
5574
+
4285
5575
  ### Region-marked samples, so a partial quote could be bound too
4286
5576
 
4287
5577
  Decided **yes** and filed under `Planned`, then killed by building the count it
@@ -4636,6 +5926,8 @@ requirement, and human review at propose is still the whole answer. What is
4636
5926
  rejected is grouping as a way to assist it, and this reopens only on a
4637
5927
  contradiction that grouping would have caught.
4638
5928
 
5929
+ [0.9.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.0
5930
+ [0.8.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.8.0
4639
5931
  [0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
4640
5932
  [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
4641
5933
  [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2