@am_shork/attest 0.8.0 → 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,457 @@ 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
+
16
467
  ## [0.8.0] - 2026-08-09
17
468
 
18
469
  ### Security
@@ -4304,8 +4755,8 @@ symptom are worth batching**: individually none justifies a commit, together the
4304
4755
  cost about an hour, and they are the group no gate could have found, since having
4305
4756
  no symptom is the definition of what a gate cannot see.
4306
4757
 
4307
- *The five entries this section carried before the one below* were built and are
4308
- recorded under `[Unreleased]`. The second is where this preamble's own
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
4309
4760
  discipline paid again: it went in on an entry whose headline type
4310
4761
  measurement — instantiations unchanged — was false on re-measure, and the
4311
4762
  re-measure is the only reason the release notes do not repeat it. The fourth is
@@ -4323,6 +4774,30 @@ four more fields and a nested scenario name carry the same untrusted text and
4323
4774
  never pass the envelope. An entry that ends with a fix is the one least likely
4324
4775
  to have its fix re-examined, which is the case worth naming.
4325
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
+
4326
4801
  - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
4327
4802
  The provenance work in `[0.7.2]` signs the release but does not change
4328
4803
  what authorises it: a long-lived automation token, masked and protected, that
@@ -4412,32 +4887,6 @@ deletion. What each entry has to carry is the evidence currently blocking the
4412
4887
  decision, because that is what a re-proposal a year from now would otherwise
4413
4888
  have to gather again.
4414
4889
 
4415
- - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
4416
- project with no concurrency limit: `findFiles` recurses through
4417
- `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
4418
- once, and `parseSpecs` does `Promise.all(files.map(readFile))`, which reads
4419
- every spec file into memory before parsing any of them. Neither is bounded by
4420
- anything, and `check` is the command this repository tells people to run first
4421
- in a pipeline on an untrusted fork MR — so the input size is not ours to
4422
- assume.
4423
- *The evidence, and why it is not enough to act on.* Measured on a synthetic
4424
- tree of 6000 spec files: the fan-out is real and unbounded — peak in-flight
4425
- `readFile` calls came back as exactly 6000 — and `attest check` finished in
4426
- 1.99 s without erroring. It could not be made to fail. Windows is why: Node
4427
- uses Win32 handles there rather than POSIX descriptors, so the `ulimit -n` of
4428
- 3200 that the shell reports does not govern the process, and the one platform
4429
- where descriptor exhaustion is plausible is the one CI runs and this
4430
- measurement could not reach (`CLAUDE.md`'s two-platform rule, pointing the
4431
- unusual way round). What *is* portable is the memory: `parseSpecs` holds every
4432
- source at once, which at a realistic 8 KiB per spec file is ~47 MiB at this
4433
- count — noticeable, not fatal.
4434
- So the fix is known and cheap — a concurrency limit of ~32 at both sites, and
4435
- parsing each source as it arrives rather than after all of them, which makes
4436
- the peak constant — and it is held back by this file's own standard: no report,
4437
- and no demonstrated failure on any platform. Recorded here so that the day
4438
- someone hits an `EMFILE` from `attest check`, the diagnosis is already written
4439
- down rather than rediscovered.
4440
-
4441
4890
  - **An assertion that recomputes its expectation from the param the code under
4442
4891
  test just read is a tautology, and nothing says so.** Reported by
4443
4892
  `mine-capablanca`, which probed it by mutating the registry and running the
@@ -4660,29 +5109,137 @@ have to gather again.
4660
5109
  falling, which is the premise the whole entry rests on, and it is the first
4661
5110
  time this file has had two measurements of it to compare.
4662
5111
 
4663
- - **Whether `isSafeChangeName` is wide enough on Windows, where the filesystem
4664
- strips trailing dots and spaces from a path component.** The guard's stated
4665
- test is path safety rather than a character whitelist, and it refuses `''`,
4666
- `'.'`, `'..'`, both separators and NUL. Win32 normalises `'.. '` to `'..'`
4667
- below the API, so a name that passes every one of those clauses could still
4668
- name the parent and under `--eval` or `archive` the name reaches a module
4669
- path that is then executed.
4670
- **What is not known is whether that reproduces at all**, and it is the reason
4671
- this is here rather than in `Planned`. `path.win32.join` was checked on Linux
4672
- on 2026-08-09 and does *not* normalise it `join('C:\\r\\changes', '.. ')`
4673
- keeps the space so the whole question is what the Win32 layer does with the
4674
- resulting path, which no Linux measurement can answer. The repository's own
4675
- rule applies: say which half is unmeasured rather than describing it as
4676
- covered.
4677
- **Even granting it, the reach is one level.** `changes/<name>/…` with the name
4678
- eaten gives the project root, and going above it needs a second `..`, which
4679
- needs a separator the guard already refuses. So the outcome is executing a
4680
- file at another path *inside* the project, not an escape from it — which is
4681
- what keeps this an open question about the guard's completeness rather than a
4682
- filed defect. If it reproduces, the fix is one more clause of the same kind:
4683
- refuse a name that is empty, `.` or `..` after trailing dots and spaces are
4684
- trimmed. If it does not, the finding to keep is that the guard's clauses are
4685
- the POSIX spelling of path safety and the file should say so.
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.
4686
5243
 
4687
5244
  ## Considered and rejected
4688
5245
 
@@ -4694,6 +5251,108 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
4694
5251
  sinking one version deeper each time a release was cut above it, and a rejection
4695
5252
  filed under a version reads as belonging to it.
4696
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
+
4697
5356
  ### Refusing a half-loaded registry in `check` and `verify`, the way `cover` and `render` do
4698
5357
 
4699
5358
  `cover` and `render` return early on `hasError(loadIssues)`,
@@ -4855,8 +5514,10 @@ Vitest run that currently shares nothing with that work.
4855
5514
  is structural.* There is **no
4856
5515
  measurement** saying parsing costs anything here — this reader is
4857
5516
  `createSourceFile` per file with no type checking, which is the cheap half of
4858
- what arktype pays for, and the fan-out entry above measured `attest check`
4859
- over 6000 spec files at 1.99 s total. Worse, a cache is a correctness hazard
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
4860
5521
  of precisely the kind this tool exists to detect: a stale analysis makes a
4861
5522
  **drift detector** report drift that has already been fixed, or miss drift
4862
5523
  that has just appeared, and it would do so silently. Any version of this needs
@@ -5265,6 +5926,7 @@ requirement, and human review at propose is still the whole answer. What is
5265
5926
  rejected is grouping as a way to assist it, and this reopens only on a
5266
5927
  contradiction that grouping would have caught.
5267
5928
 
5929
+ [0.9.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.0
5268
5930
  [0.8.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.8.0
5269
5931
  [0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
5270
5932
  [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3