@am_shork/attest 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,594 @@ 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.1] - 2026-08-13
17
+
18
+ ### Changed
19
+
20
+ - **Every command starts 11–28% faster, from three lines in `bin/attest.js`.**
21
+ `typescript` is the fixed toll on an `attest` invocation and nothing about the
22
+ design can move it: measured, the CLI's dependency graph costs ~790 ms to load
23
+ and the compiler is ~672 ms of that, against ~28 ms for commander, ~17 ms for
24
+ chalk and ~70 ms for zod. It is also not deferrable — every command except
25
+ `init` reads a registry or a spec through the AST, so the import is needed and
26
+ not merely eager. What *is* movable is the price of the same load, and Node's
27
+ compile cache moves it: V8 keeps its compilation output between runs. Two
28
+ independent A/B rounds, nine interleaved runs each, median — `check self`
29
+ 1153 → 970 and 1106 → 971 ms, `cover self` 1122 → 951 and 1346 → 1195 ms,
30
+ `status` 1110 → 923 ms, `init` 904 → 711 and 1365 → 984 ms. The absolute
31
+ numbers move with the machine; the direction did not, in either round or on
32
+ any command.
33
+ **The launcher's import had to become dynamic, and that is the load-bearing
34
+ half.** A static `import` is hoisted and its whole graph evaluated before any
35
+ statement in the file runs, so with one the cache is enabled *after*
36
+ `typescript` has already been compiled and does nothing whatever — while still
37
+ reporting success and leaving every command working. Measured: the static
38
+ spelling came back at 873 ms against an unpatched 897 ms, inside the noise,
39
+ where the dynamic one is 669 ms. A defence that reads as present and is not is
40
+ this repository's dominant failure shape, and it fits in a three-line file.
41
+ No behaviour changes: same output, same exit codes, same files. The cache is
42
+ optional-chained, because `engines` allows Node 20 and the API arrived in
43
+ 22.1 — on an older runtime the speed-up is simply absent. It never throws, and
44
+ a cache directory that cannot be written returns a status this ignores, since
45
+ the only thing a failure costs is the saving. It is written under the OS temp
46
+ directory and never into the project, which is the one place every other file
47
+ Attest writes belongs.
48
+
49
+ - **`loadRegistry`'s fan-out is bounded — the third one, where `[0.9.0]` closed
50
+ "both".** That entry is not wrong about what it did; it is wrong about how many
51
+ there were. `locate.ts` reads registry files through a
52
+ `Promise.all(paths.map(…))` of its own, so `check` on a tree carrying N
53
+ `*.reqs.ts` held N reads and N `SourceFile`s at once — the same shape, on the
54
+ same command, against the same input nobody here chooses. Measured in
55
+ `tests/locate-fanout.spec.ts`, which now counts a third pool: **64 before, 32
56
+ after**, on 64 registry files. Unbounded, the figure is the count of the files;
57
+ bounded, it is the constant.
58
+ *Why it hid is the part worth keeping.* The other two live in functions named
59
+ for walking and parsing, and both had a comment about concurrency saying
60
+ nothing about a bound. This one has a comment that reads as a considered
61
+ decision — "read the files concurrently, then fold the results in sorted file
62
+ order: the issue list stays deterministic regardless of which one finished
63
+ first" — and it is a correct, load-bearing answer to a *different* question.
64
+ Something that has visibly been thought about does not get thought about again,
65
+ which is what put a third copy of one shape past a fix that named itself as
66
+ covering all of them.
67
+ *The reader is what makes it more than arithmetic here.* `parseSpecs` bounds
68
+ sources; this bounds whatever the reader holds, which under `--eval`,
69
+ `verify` and `archive` is a Vite SSR module evaluation rather than a string.
70
+ Hence a third constant rather than sharing one, on the reason `WALK_CONCURRENCY`
71
+ already gives: the three bound different resources and cannot be tuned
72
+ together.
73
+ Descriptor exhaustion is still the failure it invites and still unmeasured —
74
+ Windows uses Win32 handles rather than POSIX descriptors, so `ulimit -n` does
75
+ not govern it there, and Linux is the half CI runs. The peak is portable
76
+ arithmetic, which is the standard both earlier halves were accepted on.
77
+ No behaviour change and no `ATX-n`, on that same precedent: the registry, the
78
+ issue list, its order and `prefixOwners` are identical, all asserted beside the
79
+ peak. The fold is now driven by index rather than by arrival, which is what
80
+ keeps "sorted file order" true through a pool that completes out of order —
81
+ the same indexing `parseSpecs` uses, for the same reason.
82
+
83
+ ### Fixed
84
+
85
+ - **`--apply` wrote a `*.reqs.ts` that no longer parses, when a MODIFIED entry
86
+ changed two `params` and one of them had an integer-like key.** The write-back
87
+ rests on an ordering property stated in `splice.ts` and nowhere enforced: the
88
+ walk emits its edits in ascending offset order, so the pass at the bottom can
89
+ be a `reverse()` rather than a numeric sort — "an ordering that holds by
90
+ construction beats one restored after the fact". It did not hold by
91
+ construction. `registryEntryLayouts` handed the layout back as plain objects,
92
+ and **JavaScript hoists an integer-like key to the front of every object**, so
93
+ `params: { budgetMb: 12, 10: 'ten' }` was described in an order the file never
94
+ wrote. A `params` key genuinely can be a number: `staticName` spells a numeric
95
+ literal key as `String(value)` and the schema's `z.record(z.string(), …)`
96
+ accepts the result, on both reader paths.
97
+ The consequence is the one shape this command must not have. With the order
98
+ reversed, the lower edit was applied first and every offset after it shifted
99
+ underneath the spans still to be written — measured, changing `budgetMb`
100
+ `12 → 4096` alongside `10: 'ten' → 'a'` produced `params: { budgetMb: 4096,
101
+ 10'a'n' },`, off by exactly the two bytes the first replacement grew. The
102
+ registry was **already on disk** by then: `applyMerge` writes each file before
103
+ `verifyWritten` re-reads it, so the re-read caught it and stopped the merge —
104
+ which is what that check is for — but what it stopped at was a hand-written,
105
+ hand-commented registry left unparseable, and the merge's own account of the
106
+ failure is an `internal-error` naming the file.
107
+ The fix is the containers, not a sort. `fields`, `paramKeys` and the layout map
108
+ itself are `Map`s, which keep insertion order for every key type, so the
109
+ property is back to holding by construction rather than by the keys happening
110
+ not to be numbers — the standard `keySource` and `compareIds` are already
111
+ written to, arriving at the one place that had been resting on an upstream
112
+ grammar. Contained to `splice.ts`, which is the only consumer.
113
+ No new requirement: ATX-76 already says the write-back replaces *the source
114
+ span of each value that changed*, and this was a defect against that sentence
115
+ rather than a gap in it. The regression sits in `tests/splice.spec.ts`, at the
116
+ seam the invariant lives on; reaching it through a `self/` scenario would need
117
+ a whole fixture project to express one numeric key, for a weaker signal.
118
+
119
+ - **The package shipped a file its own `exports` map forbade reading.** npm puts
120
+ `package.json` in every tarball whatever `files` says, and the map named only
121
+ `.` and `./define` — so `@am_shork/attest/package.json`, which tooling reaches
122
+ for to read a dependency's version, came back
123
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`. Measured against a real installed tarball,
124
+ not reasoned about. Fixed by naming it, which is the whole change.
125
+ *The test that should have caught it was checking the wrong half.* "Ships the
126
+ entry points the exports map promises" asserted that `dist/index.js`,
127
+ `dist/core/registry.js` and `bin/attest.js` **exist** in the install — which an
128
+ `exports` map blocking every subpath would leave exactly as true. Existence and
129
+ reachability are decided by different fields, `files` shipping the bytes and
130
+ `exports` deciding what may address them, so one cannot stand in for the other.
131
+ `tests/consumer.spec.ts` now resolves each promised specifier from inside the
132
+ consumer project, asserting line by line so the diff names the one that
133
+ stopped. It went red on `./package.json` and green on the other two, which is
134
+ what says the new test measures the thing rather than the fix.
135
+
136
+ - **`cli/report.ts` was carrying a false reason for a defence, and it
137
+ contradicted `render.ts`.** The comment on `formatCoverage` justified
138
+ sanitising a requirement id with "under `--eval` the registry is whatever the
139
+ module exported, and `evalReader` checks that it is an object and nothing
140
+ further" — true when it was written, and untrue since ATX-38 made the
141
+ evaluating reader validate against the same schema as the static one. So
142
+ `^[A-Z]+-\d+$` holds on both paths, `render.ts` says so explicitly, and the two
143
+ modules read as disagreeing about whether a registry id can carry a control
144
+ character.
145
+ The sanitising stays and the reason is corrected: the grammar is held
146
+ somewhere else, `CoverageRow.reqId` is a `string`, and the function is
147
+ exported — a defence that lapses when the other one does is not a defence,
148
+ which is the standard `splice.ts`'s `keySource` is already written to. No
149
+ behaviour change; the call was, and remains, one that cannot fire through any
150
+ command. Recorded because in this repository a comment is the decision record,
151
+ and a false one is read as evidence the next time the question comes up.
152
+
153
+ ## [0.9.0] - 2026-08-12
154
+
155
+ ### Added
156
+
157
+ - **`--apply` writes MODIFIED requirements back, at the granularity of a value
158
+ rather than an entry.** `merge.ts` refused any delta carrying `modified` for
159
+ five releases, and what that refusal cost is on the record rather than
160
+ estimated: in one adopting project **5 of 19 changes carried `modified`**, each
161
+ merged by hand with a script the author rewrote five times in a session, which
162
+ located ` 'AUTH-7': { … },` in the registry **by regex**. `[0.6.0]` cites
163
+ eight hand-written Python scripts — one of them that same regex locator — as
164
+ the evidence that this step must not be left to the adopter, so the refusal had
165
+ reproduced the tool it was meant to retire, inside a project that had `--apply`
166
+ available.
167
+ **The argument the refusal rested on was subtly wrong, and correcting it is
168
+ what made this buildable.** `--apply` is allowed to edit a hand-written
169
+ registry because "the result is checkable by re-reading it", and the entry
170
+ proposing this narrowed on the wrong axis in consequence — accept an entry only
171
+ when its whole span is comment-free, which lowers the *rate* of destruction and
172
+ not its *size*. A re-read compares values and is blind to what was **lost**: a
173
+ replacement that ate a comment passes one cleanly. So the unit of the edit is
174
+ the value, not the entry. `statement` and `rationale` replace a string
175
+ literal's span, where a comment cannot be present by construction; a `params`
176
+ key replaces that key's value span and a new key is a pure insertion;
177
+ `outOfScope` replaces the array literal. Everything else in the entry — the
178
+ keys, the commas, the layout, every comment on a field that did not move — is
179
+ bytes the edit never addresses.
180
+ **Refused, still whole, when a span that would be overwritten carries a
181
+ comment**, as `apply-unsupported-delta` naming the requirement and the field.
182
+ Measured over this repository's own registry before it was built, on
183
+ 2026-08-11: **75 entries, 3 carrying a comment anywhere inside them, all three
184
+ inside a `params` value**, and none at all inside a statement or a rationale.
185
+ So the refusal is the rare case rather than the categorical one, and every
186
+ refusal is still a merge nobody has to trust.
187
+ **RENAMED and REMOVED are deliberately still refused, and the granularity is
188
+ the reason they are now separable.** "REMOVED cannot say which comments
189
+ belonged to the entry it deletes" is the sentence the two shared, and it
190
+ survives untouched: deleting an entry has no smaller span to fall back to,
191
+ while the same question one level in is answered by not replacing that level.
192
+ **Two things this brought with it.** The re-read is now performed rather than
193
+ argued — after writing, the registry is read back and every id the edit was
194
+ answerable for is compared against what the gate proved, ADDED included, since
195
+ a check covering only the newer half would leave the older claim exactly as it
196
+ was. And every registry edit is computed before any file is written, so
197
+ "refused whole" is true of the write-back and not only of the delta's shape;
198
+ before this, the one thing that could fail during text generation — a
199
+ `__proto__` param key — failed one file at a time.
200
+ Held as `ATX-76`, whose scenarios read the registry as **text**: a merge that
201
+ regenerated the entry would produce a file reading back as exactly the same
202
+ requirement, so nothing about the parsed end state can tell a value-sized edit
203
+ from a rewritten one. `ATX-54` keeps the refusal half over what is left.
204
+ *One limit, recorded so nobody assumes otherwise:* a patch cannot express
205
+ **deleting** a `params` key — `applyDelta` merges keys and never drops one — so
206
+ write-back does not cover that, and no amount of span work would.
207
+
208
+ - **The workflow document now describes an inner loop for stage 2.** Its only
209
+ instruction there was `attest archive`, which applies the delta and runs the
210
+ base suite, so an agent iterating toward green paid the full gate after every
211
+ edit — and `status` cannot help, its two obligations both being settled at the
212
+ end of stage 1. The addition needs no engine change: run the proposed spec file
213
+ directly while you are still making it pass, keep the gate for the verdict.
214
+ Measured before it was written — a `*.proposed.spec.ts` runs under a plain
215
+ Vitest invocation, delta import included — and it carries the two things that
216
+ make it safe and the one that makes it not a verdict: nothing but the gate ever
217
+ writes `first-run.json`, `status` still answers the readable half, and a green
218
+ file does not predict the gate, because the direct run gets the project's
219
+ environment and the gate's child run is isolated. That last point is the
220
+ `tests-red` section's existing subject, so it is pointed at rather than
221
+ restated.
222
+
223
+ - **`status` applies the change's delta and refuses the report when it does not
224
+ fit, which no static command checked at all.** `applyDelta` produces six
225
+ diagnostics — a rename whose target exists or whose source does not, a
226
+ modification of an absent id or one leaving the requirement invalid, an
227
+ addition that is invalid or conflicts — and nothing reached them without
228
+ running the suite first. `check` reads deltas, but only to attribute proposed
229
+ specs; it never applies one. So a delta wrong in any of those six ways was
230
+ first heard from `archive`, in the workflow position where the specs and the
231
+ implementation are already written, about a fact that was available the moment
232
+ the delta was saved.
233
+ Refusal rather than a row, because there is no row that could be right: the
234
+ states this command reports are progress toward an *applied* registry, and a
235
+ delta that cannot be applied has none to be progress toward — an `unproven`
236
+ against it is a true-looking sentence about a change that cannot exist.
237
+ Held as `ATX-78` rather than under `ATX-33`, which the cheap implementation
238
+ makes clear: `ATX-33` constrains what an ERROR from this command *means*, and
239
+ an implementation that never detects an unapplicable delta satisfies it
240
+ completely, vacuously, having no ERROR to constrain. This is the detection —
241
+ the same split `ATX-69` records against `ATX-65`.
242
+ **Behaviour change, and the reader it costs is not the one being fixed.**
243
+ Applying a delta needs the registry, which this command did not read before, so
244
+ a project whose registry is not a literal now gets `registry-not-static` and no
245
+ report where it got a report, and gets it back only under `--eval`. That is the
246
+ trade `ATX-16` already makes for `check`, `cover` and `render`, arriving at the
247
+ fourth static command. The registry is read through whichever reader the
248
+ command was asked for, so `--eval` moves both halves together rather than
249
+ quietly evaluating one to make the other work.
250
+
251
+ ### Fixed
252
+
253
+ - **`status` told the reader to write a scenario that was already on disk.**
254
+ `changeMergedPlan` returns the specs neither parse could read, and says in
255
+ place why they block: an unreadable proposed spec is a scenario the gate would
256
+ otherwise report as absent. `archive` acts on that list before it runs
257
+ anything, on the argument that a plan known to be short cannot grade a change
258
+ (ATX-65). `status` took the plan from the same call and destructured only
259
+ `merged`, dropping the issues — and every state it prints is computed *from*
260
+ that plan, so a claimed `*.proposed.spec.ts` that failed to parse left its
261
+ requirement with no scenarios in the plan and the row came back
262
+ `no-scenario`, with `issues` empty and `ok` true.
263
+ **Not an under-report but the inverse of one**, which is why it is a fix rather
264
+ than a widening: the command whose stated contract is to be a projection of the
265
+ gate answered the opposite of the gate about the same change, and the advice it
266
+ produced — write the scenario — is work the reader has already done. The one
267
+ thing that makes it recoverable is that `status` writes nothing.
268
+ **Behaviour change, and it moves an exit code.** A change in that state now
269
+ fails with `unreadable-file` and exits 1 where it printed a table and exited 0.
270
+ Anything wrapping `status` in a loop sees red on input that used to pass — but
271
+ the input is a spec file that does not parse, and the table it used to print
272
+ was wrong about it.
273
+ Held under `ATX-33` rather than as a new requirement: the rule it is an
274
+ exception to is untouched — an unmet obligation is still a row, never a verdict
275
+ — and what moved is which input that exception covers.
276
+ **Stating it exposed that the exception could not be enumerated, so it is now a
277
+ class and the code list is gone rather than extended.** `ATX-33` had named the
278
+ one code that could make `status` fail, and adding a second is what showed the
279
+ shape: the roster it can *actually* fail with was already longer than the list
280
+ on the day the list was written — a rejected change name and an unusable
281
+ compiler were never in it — and it grows with every input the command learns to
282
+ read. A param extended per failure path is a name for the implementation rather
283
+ than a contract, and it fails in the direction that hides, since a code missing
284
+ from it makes the requirement quietly false rather than red. What the statement
285
+ carries instead is the property worth preserving: **an ERROR from `status`
286
+ always means there is no report**, so a reader who sees one never has to ask
287
+ whether progress was being scored. That is **the first `params` key ever
288
+ removed from this registry**, against 57 added and one value changed; the
289
+ removal is the safe direction for the same reason those additions were, since
290
+ what the scenarios assert is broader than what the list named, not narrower.
291
+ **The measurement suite priced it in two places and one of them was a
292
+ finding.** The restated sentence is universally quantified, so
293
+ `tests/intent-rule-candidates.spec.ts` demanded a `QUANTIFIED` row and got the
294
+ `sites` shape — the members are early returns in one function, so nothing
295
+ enumerates them and no scenario can iterate them, which is the `ATX-37`
296
+ position that table exists to record. Two sites were unreached; one is closed
297
+ here by a scenario, and **the compiler path stays open as a measured gap,
298
+ taking that table from one to two**. It was not created here: `status` could
299
+ always fail that way with nothing attesting it, and naming the failing half as
300
+ a class is what made the site visible at all.
301
+ No `SCHEMA_VERSION` bump: `issues[]` already carries this code and the envelope
302
+ is unchanged. `unreadable-file` is not a new code either — what is new is a
303
+ fourth command that can raise it, which is why the troubleshooting section now
304
+ says which commands stop on it and which keep going.
305
+
306
+ - **The workflow document said a proposed spec stays out of a plain `vitest run`,
307
+ and it does not.** `*.proposed.spec.ts` ends in `.spec.ts`, so an ordinary
308
+ `**/*.spec.ts` include — Vitest's own default among them — matches it;
309
+ confirmed by running one. Only *Attest's* scope excludes it. The claim was
310
+ wrong in the direction that surprises: during stage 1 those scenarios are red
311
+ by construction, so an adopter following this document is told their own
312
+ `npm test` will be unaffected and then watches it go red, at the exact moment
313
+ the framework is asking them to trust a workflow they have just met. The
314
+ sentence now claims only what is true, and the optional `exclude` is shown —
315
+ as the reader's decision about their own suite, which is not one Attest makes
316
+ for them.
317
+ *That sample spreads `configDefaults.exclude`, and the reason is worth the
318
+ line it costs.* Vitest's `exclude` **replaces** its defaults rather than
319
+ extending them, so the obvious one-liner silently un-excludes
320
+ `**/node_modules/**` — measured, not inferred: a bare
321
+ `exclude: ['**/*.proposed.spec.ts']` collects and runs a `*.spec.ts` planted
322
+ inside `node_modules`. This repository's own `vitest.config.ts` has always
323
+ re-added those globs by hand, so the trap was already known here and would
324
+ still have shipped in advice written for someone else. A sample in a document
325
+ is executed by whoever copies it, and this one was two lines from turning a
326
+ cosmetic complaint into a broken suite.
327
+
328
+ - **The workflow document contradicted itself about `apply-unsupported-delta`,
329
+ and the half an agent branches on was the stale half.** The commit that taught
330
+ `--apply` to write MODIFIED back edited the prose in `skill.ts` and left the
331
+ code table eleven lines above it reading `--apply` writes back ADDED only, and
332
+ this delta carries more. So an agent meeting that code looked it up, concluded
333
+ the delta carried something unsupported, and would have gone off splitting
334
+ MODIFIED out of it — when the actual cause is now most often the other one the
335
+ same code covers, a comment sitting inside the span a MODIFIED value would be
336
+ written over. **A wrong cause costs more than a missing one**, because it sends
337
+ the reader to a repair that cannot work; the row now names both.
338
+ *What this says about the gate that exists.* `ATX-57` holds that every code the
339
+ workflow names is one the engine can emit, and its rationale records the
340
+ one-directionality as deliberate — the document has no business naming every
341
+ code, so the obligation is only that what it names exists. That is untouched
342
+ here and still right: the code existed, and what moved was its *meaning*.
343
+ `skill.ts`'s own note said "read this file when a release adds a diagnostic",
344
+ which is the rule that would not have caught this. It now says *or changes what
345
+ one means*, and to grep the body for the code rather than trusting that the
346
+ section just edited was its only mention.
347
+ *No `ATX-n`.* The obligation this would state — every description in the
348
+ workflow matches what the engine does — is the one nothing can check, which is
349
+ why `ATX-57` stops where it does. Adding a requirement that no scenario can
350
+ falsify would be a green tick over an unguarded property.
351
+
352
+ - **`attest status`'s documented surface was wider than the command, in three
353
+ places at once.** All three say some version of "`status` reports what the gate
354
+ still wants, and the one thing it cannot see is whether the tests pass". It is
355
+ a strict subset of that: `statusRows` projects the *uncovered* half of gate
356
+ check 1 and the whole of check 4, both restricted to the ids a change ADDs.
357
+ Check 3 reads `runtimeCoverage`, which only the suite produces — so a scenario
358
+ left `skip`ped blocks `archive` with the suite fully green and `status` fully
359
+ clean, which is exactly the conclusion the sentence invites a reader to rule
360
+ out. Orphans and unbound params are not projected either. Corrected in design
361
+ §8 (both languages), in the workflow document, in that document's command
362
+ table, and in the README's command list — four copies of one gloss, which is
363
+ the count worth recording: the phrase was not repeated because anyone reasoned
364
+ it four times, and correcting three of them would have left the repo saying two
365
+ different things about one command.
366
+ **Design §8's sentence was wrong on the day it was written, not stale, and the
367
+ difference was worth checking rather than assuming.** The tidy explanation was
368
+ that `declared-not-run` had been inserted as check 3 and renumbered the list
369
+ under a sentence nobody re-read — this file's own recurring lesson, and it fits
370
+ so well it was nearly written down. `git log -S` says otherwise: the numbering
371
+ has had check 3 reading `runtimeCoverage` since 2026-07-21, and the sentence
372
+ claiming check 3 is decidable without running anything arrived six days later.
373
+ So the mechanism here is not drift at all. **A sentence that enumerates a list
374
+ written a few lines above it is not checked against that list by anyone,
375
+ including its author**, and no gate reads prose.
376
+ *Separately, `status`'s History table was missing `0.8.0`.* Moving the
377
+ first-run record to `version: 2` — filed against `archive`, where the change
378
+ was made — also moved `status`'s output, because the two read that file with
379
+ the same predicate. Measured: the same change reports `✓ AUTH-7 2 scenarios,
380
+ seen red / 1 ready to archive` on a version-2 record and `● AUTH-7 2 never
381
+ run / 0 ready to archive` on the version-1 record a 0.7.x change carries. The
382
+ row is owed by the rule that a History table is the only thing that can tell a
383
+ reader on an older build which half of the page applies to them, and it is the
384
+ shape that hides: `status` exits 0 either way, so nothing anywhere turned red.
385
+
386
+ - **The workflow document never said a delta must be a literal.** It states the
387
+ rule for `*.reqs.ts` and stops there, while `check` and `status` read
388
+ `requirements.delta.ts` through the same static reader — so an agent lifting a
389
+ `const` out of a delta, which is the ordinary thing to do when two params share
390
+ a value, meets `registry-not-static` pointing at a file the rule it was given
391
+ never mentioned. Confirmed by running it rather than by reading the reader:
392
+ `attest status` on a delta whose param is an identifier reports
393
+ `registry-not-static` at that line. `changes/` is also the more exposed of the
394
+ two files, being by definition the one still under review, so it is the worse
395
+ of the two to leave undocumented.
396
+ *The same roster was short in `CLAUDE.md`*, which named three commands that
397
+ execute nothing where `ATX-16.staticCommands` names four — `status` being the
398
+ one missing, for the same reason: what it reads statically is a delta rather
399
+ than a registry, so it falls outside a sentence scoped to `*.reqs.ts` while
400
+ being squarely inside the security property that sentence exists to teach.
401
+
402
+ - **`check` had no `empty-spec` guard for four releases, and the reference said
403
+ it did.** A root with zero requirements answered `✓ No issues.` and exit 0,
404
+ while `verify` reported it as an ERROR. Reported by an adoption on 0.8.0,
405
+ which reached it the way it will usually be reached: `check` is the cheap CI
406
+ pre-check, so it is the command most likely to be pointed at a directory a
407
+ moved registry or a wrong `dir` argument has emptied — and the expensive gate
408
+ that would have caught it runs minutes later, or on a matrix leg that does not
409
+ run at all. What makes it a defect rather than a proposal is that the contract
410
+ was already written down and the implementation was what disagreed with it:
411
+ `cli-reference.md` lists `empty-spec` among the codes **`check`** reports, and
412
+ its own History row credits `0.2.0` with adding it, so the `0.2.0` change
413
+ landed on one of the two commands the reference describes it on and nothing
414
+ compared them since. `troubleshooting.md` closes the loop by telling a reader
415
+ who meets this to run `attest check .` — advice to a command that could not
416
+ say it.
417
+ **The fix is a layer move, not a branch added to a second command**, and that
418
+ is what settles the ambiguity the entry was filed with. The rule needs nothing
419
+ executed to decide it — the registry alone answers it — so it belongs to
420
+ structural validation, which is where `validateStructure` already lives and
421
+ which both commands already call with the same three arguments. Both design
422
+ documents said this twice and did not agree: the sentence stating the rule sat
423
+ in **§5.4**, under "what a run runs, and what `passed` may not mean", while
424
+ the account of what `init` writes cited **§5.3** for the same code in a
425
+ context where nothing has run at all. §5.3 is now the one place it is stated,
426
+ which makes that citation correct for the first time.
427
+ **Re-measured before a line was written, and the re-measure found a case the
428
+ entry had not counted** — the section's own discipline, and the third time it
429
+ has paid on the *reachability* question rather than on a number. The reported
430
+ half reproduced exactly. What the walk added is that `verify` fires
431
+ `empty-spec` **beside** `registry-invalid` when the registry was found and
432
+ failed to load, and its message tells the reader to point Attest at the
433
+ directory holding the `*.reqs.ts` files — a file that is sitting right there,
434
+ where the fix is the load error already in the report and following the hint
435
+ would move a path that was correct. So the obvious implementation, "make
436
+ `check` agree with `verify`", would have imported a latent misdiagnosis into
437
+ the command adopters run first. `empty-spec` is now withdrawn when a registry
438
+ file failed to load, on both commands: the same withdrawal `validateStructure`
439
+ already makes for `orphan-test`, against the same input, for the same reason.
440
+ A registry that could not be read is not an absent one, and the two take
441
+ opposite repairs.
442
+ **Stated beside the findings it explains, not instead of them.** The louder
443
+ symptom in the report was measured at 135 `orphan-test` ERRORs, one distinct
444
+ code, and the actual diagnosis — there is no registry under this root — not
445
+ among them. Each orphan is true of the root as given, so the alternative is
446
+ rejected explicitly rather than by omission: what was missing is the single
447
+ line saying why they are all true at once. `verify` already had it this way,
448
+ which answered the question by measurement rather than by argument.
449
+ Held as `ATX-18`, widened from `verify` to the layer, and `ATX-77` for the
450
+ withdrawal — kept apart because the two are falsifiable in opposite
451
+ directions, and both were observed doing exactly that before the branch was
452
+ written: reporting nothing satisfies `ATX-77` and fails `ATX-18`'s four
453
+ scenarios, reporting unconditionally satisfies `ATX-18` and fails `ATX-77`'s
454
+ two.
455
+ *A root that reported success now reports an error, which this file's
456
+ Versioning section makes a **minor** bump.*
457
+
458
+ - **`--apply` skipped the delta import it is supposed to repoint whenever the
459
+ specifier carried no file extension, and reported success.** `[0.6.0]` records
460
+ this exact failure being found and fixed — a merged spec left importing a path
461
+ step 3 has just moved to `archive/`, `check` silent, only the merged suite
462
+ showing it — and the fix covered the two spellings *this* repository writes.
463
+ `resolvesTo` (`core/splice.ts`) accepted the delta's own path or the NodeNext
464
+ `.js`-for-`.ts` spelling of it, and nothing else. A project on bundler
465
+ resolution writes neither: every import in it is extensionless, including the
466
+ one this is looking for. Reported by an adoption that is extensionless
467
+ throughout — application modules and all 35 spec files — so it hit this on
468
+ **every** applied change, nine in a row, and after the first one pre-empted it
469
+ by hand. That is the shape worth recording rather than the bug: the workaround
470
+ is cheap enough to become habit, so the defect stops producing reports while
471
+ continuing to happen.
472
+ **Re-measured before a line was written, and the entry's own account of the
473
+ repair was wrong.** The reading half reproduced exactly as filed, against the
474
+ current source rather than the `dist/` the report used. The writing half was
475
+ filed as needing nothing — "extensionless in, extensionless out falls out of
476
+ the empty case" — and the empty case sat behind `if (ext)`, so it left the
477
+ target's `.ts` in place. That guard was correct only while the empty case was
478
+ unreachable, which is precisely what widening the match changes. Fixing the
479
+ reported half alone would have written an extension into a project that writes
480
+ none: the reading half repaired, the writing half newly broken, and the
481
+ uniformity the whole fix exists to respect broken by the fix for it. This is
482
+ the second time an entry that named its own fix had the fix turn out to be the
483
+ wrong half, after the C1 stripping in `[0.8.0]`, and it is the case least
484
+ likely to be re-examined for exactly that reason.
485
+ Held as `ATX-75` with a scenario per spelling rather than by widening
486
+ `ATX-52`, which attests that the merged project verifies clean: a merged
487
+ project whose spec never imported the delta verifies clean either way. The
488
+ assertions are on the specifier the command wrote, not on the health of what
489
+ surrounds it, which is what makes the two separately falsifiable in the
490
+ direction that matters.
491
+
492
+ - **`ATX-72`'s second scenario could not run on Windows at all, and the
493
+ requirement's own rationale said it could.** The injection needs the registry
494
+ file's name to carry the *specifier's own* quote — that is what makes the two
495
+ quote styles two scenarios rather than a loop — and the rationale argued the
496
+ name was "legal on Windows too since neither is among the characters it
497
+ reserves". True of the apostrophe, and false of the double quote, which is in
498
+ the set that same sentence enumerates. So `writeFile` threw before the emitter
499
+ was reached and `verify self` was red on win32, from `[0.8.0]`.
500
+ **What hid it is worth more than the fix.** CI is Linux, where both names are
501
+ legal, so no pipeline could have reported it — `CLAUDE.md`'s two-platform rule
502
+ arriving on a requirement that had already written the platform argument down
503
+ and got it half right. The argument was written while this requirement covered
504
+ one quote; widening it to two left the reasoning for the first standing over
505
+ both, unre-examined. A rationale is not re-read when the thing it justifies
506
+ grows.
507
+ The obligation is unchanged and holds on both platforms, because it is about
508
+ what the emitter writes. What is platform-limited is the end-to-end route to
509
+ it, so the win32 run now reaches the same assertion through `repointImport`
510
+ directly rather than skipping — a skipped scenario is `declared-not-run`
511
+ (`ATX-71`), which would trade a red suite for a red gate. The honest reading,
512
+ recorded in the requirement: a repository holding that name cannot be checked
513
+ out on Windows at all, so the exposure the end-to-end case stands for is
514
+ POSIX-only.
515
+
516
+ ### Changed
517
+
518
+ - **`status`'s closing line names `check` as well as `archive`.** It said what
519
+ this report is short of on one side — the verdict — and nothing about the
520
+ other: a proposed spec no delta claims, or one whose merged name is already
521
+ taken, is a static fact about this change that `check` already reports. Naming
522
+ it is deliberately the whole of the fix. Reporting those here too would put a
523
+ second answer to one question in the tree, which is the arrangement this
524
+ project takes apart everywhere else, and the reader who wanted one command was
525
+ going to run `archive` anyway.
526
+
527
+ - **`divergent-param` re-keyed a fourth time, and this one carries something the
528
+ first three did not.** `ATX-77` writes `ATX-18` out of the `code` group by
529
+ carrying the same `empty-spec`, on the rule's usual arithmetic — one id per
530
+ distinct value, later writer wins. The churn itself is now unremarkable and
531
+ that is the point of counting it. What is new is *which* pair it hid: `ATX-18`
532
+ and `ATX-77` were split deliberately, because each is falsifiable in a
533
+ direction the other is blind to, which makes them the most tightly related
534
+ pair in this registry — and the group can show only one of them. That is the
535
+ second time the one real relationship in this group has been the invisible
536
+ one, after `ATX-66`/`ATX-73`, and twice makes the mechanism legible rather
537
+ than the coincidence: two ids are related when they name the **same**
538
+ constant, which is exactly the case the deduplication discards. The rule is
539
+ not blind to relatedness by omission — its key is built by throwing the
540
+ evidence of it away.
541
+
542
+ - **`divergent-param`'s finding for `flag` had to be re-keyed a second time**, on
543
+ a requirement carrying the same `--apply` as the member it displaced. The
544
+ divergence itself did not change — the same three unrelated constants under
545
+ the same param name — but the grouping keeps one id per distinct *value* and
546
+ the later writer wins, so the row's identity moved and a human had to re-judge
547
+ a finding about which nothing was new. Once was a curiosity worth a line; twice
548
+ makes it the candidate rule's behaviour, and names a maintenance cost paid per
549
+ addition on top of the zero precision already recorded. Note which additions
550
+ trigger it: the ones that **agree** with an existing member.
551
+
552
+ - **`findFiles` opened one directory per directory in the tree; it now opens
553
+ 32.** The recursion was `Promise.all(subdirs.map(walk))`, so every sibling at a
554
+ level was in flight at once and each of their children after them. The walk is
555
+ now a bounded pool a level at a time, which makes the peak a constant — the
556
+ same figure and the same shape as `parseSpecs`, whose bound `[0.7.0]` added.
557
+ *The entry this closes was half stale, and that is the part worth recording.*
558
+ `[0.7.0]` fixed the `parseSpecs` half and wrote down, in this file, that the
559
+ `findFiles` half was deliberately left; the `Under consideration` entry was
560
+ never narrowed to match, so it went on describing `parseSpecs` as reading every
561
+ source into memory for four releases after that stopped being true. An entry
562
+ whose other half ships is not re-read either — the same shape as a rationale
563
+ not re-read when the thing it justifies grows (`ATX-72`, above), arriving on
564
+ the section whose own discipline is to re-measure before building. Re-measuring
565
+ is what found it: the memory arithmetic the entry rested on was the half
566
+ already fixed.
567
+ *What retires the blocker is that it named the wrong failure.* `[0.7.0]` held
568
+ this back on "no report, and no demonstrated failure anywhere", where the
569
+ failure it meant was descriptor exhaustion — unreachable on either development
570
+ platform, and on Windows not even governed by `ulimit -n`, since Node uses
571
+ Win32 handles there. That is still true and still unmeasured. But it is not the
572
+ only cost: the peak itself is portable arithmetic, which is the standard the
573
+ `parseSpecs` half was accepted on. Measured in `tests/locate-fanout.spec.ts`,
574
+ which now counts in-flight `readdir` as well as in-flight `readFile`, on a
575
+ 64-wide tree two levels deep: **64 before, 32 after** — and 200 before on a
576
+ 200-wide one, which is the point. Unbounded, the figure is the width of the
577
+ level; the tree is the attacker's to choose, and `check` is the command this
578
+ project tells people to run first on an untrusted fork MR.
579
+ *The 200-wide tree is not what ships, and why is worth a line.* Building it
580
+ timed this file's `beforeAll` out on win32 under the parallel suite,
581
+ intermittently — two runs in three — and the failure arrived in the shape
582
+ `CLAUDE.md` records for `tests/consumer.spec.ts`: a throw in a hook reports
583
+ every test in the file as **skipped**, so the count moves from 385 passed to
584
+ 383 passed and 2 skipped, and nothing says the property went unchecked. The
585
+ tree is now 64 wide, built concurrently, with an explicit hook timeout. A test
586
+ that is sometimes not run is worth less than a smaller one that always is.
587
+ *Both fan-outs now go through one `forEachBounded`*, extracted at the second
588
+ call site rather than the first: `parseSpecs` had the pool written inline, and
589
+ a shape written twice is the one a later fix reaches only one copy of — which
590
+ is, in miniature, what this entry is about. `parseSpecs` keeps its own
591
+ constant rather than sharing one, because the two bound different resources
592
+ and a shared figure could not be tuned for either. Its peak is unchanged at 32,
593
+ re-measured through the helper rather than assumed.
594
+ *A level at a time rather than one pool over a growing queue*, because a queue
595
+ fed by its own workers has to keep them alive while it is momentarily empty and
596
+ another worker may still push — a termination condition worth not owning for a
597
+ barrier paid in tree *depth*, which the filesystem bounds, against a fan-out
598
+ paid in tree *width*, which nothing does.
599
+ No behaviour change and no `ATX-n`, on the precedent `[0.7.0]` set for the
600
+ other half: the returned list is byte-identical, sorted the same way, with
601
+ `SKIP_DIRS` excluded the same way — all three asserted beside the peak — so
602
+ there is no contract here a consumer could branch on.
603
+
16
604
  ## [0.8.0] - 2026-08-09
17
605
 
18
606
  ### Security
@@ -4304,8 +4892,8 @@ symptom are worth batching**: individually none justifies a commit, together the
4304
4892
  cost about an hour, and they are the group no gate could have found, since having
4305
4893
  no symptom is the definition of what a gate cannot see.
4306
4894
 
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
4895
+ *The five entries this section carried before the ones below* were built and are
4896
+ recorded under `[0.8.0]`. The second is where this preamble's own
4309
4897
  discipline paid again: it went in on an entry whose headline type
4310
4898
  measurement — instantiations unchanged — was false on re-measure, and the
4311
4899
  re-measure is the only reason the release notes do not repeat it. The fourth is
@@ -4323,6 +4911,30 @@ four more fields and a nested scenario name carry the same untrusted text and
4323
4911
  never pass the envelope. An entry that ends with a fix is the one least likely
4324
4912
  to have its fix re-examined, which is the case worth naming.
4325
4913
 
4914
+ *That has now happened twice, so it is a pattern rather than an incident.* The
4915
+ extensionless-import entry likewise reproduced exactly on its reported half and
4916
+ was wrong about its own repair — it recorded that the writing half needed
4917
+ nothing, and the writing half was guarded by a condition that was correct only
4918
+ while the reading half stayed broken. Both times the error had the same shape: an
4919
+ entry reasons about the code path it can see failing, and the repair makes a
4920
+ second path reachable that nobody has looked at because nothing could reach it.
4921
+ So the re-measure that matters is not "does the reported symptom reproduce" —
4922
+ both did, exactly — but **what does the proposed fix make reachable for the first
4923
+ time**.
4924
+
4925
+ *The first time that question was asked in advance, it paid, and how it paid is
4926
+ worth a line because it is not what the two cases above would predict.* The
4927
+ `check` `empty-spec` entry — `[Unreleased]`, the third entry to reproduce exactly
4928
+ on its reported half — was not wrong about its own repair. Asking the question
4929
+ anyway found the defect one step to the side: the fix it proposed was correct,
4930
+ and the *obvious implementation of it* — make `check` agree with `verify` —
4931
+ would have copied a misdiagnosis `verify` had been emitting unnoticed, because
4932
+ the reference implementation an entry points at is not itself re-read. So the
4933
+ question generalises past the shape it was learned from. It is not only about a
4934
+ path the repair makes reachable; it is about **everything the repair would make
4935
+ `check` agree with**, and an entry that names the right fix can still be
4936
+ implemented wrongly by copying the half nobody has audited.
4937
+
4326
4938
  - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
4327
4939
  The provenance work in `[0.7.2]` signs the release but does not change
4328
4940
  what authorises it: a long-lived automation token, masked and protected, that
@@ -4412,32 +5024,6 @@ deletion. What each entry has to carry is the evidence currently blocking the
4412
5024
  decision, because that is what a re-proposal a year from now would otherwise
4413
5025
  have to gather again.
4414
5026
 
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
5027
  - **An assertion that recomputes its expectation from the param the code under
4442
5028
  test just read is a tautology, and nothing says so.** Reported by
4443
5029
  `mine-capablanca`, which probed it by mutating the registry and running the
@@ -4660,29 +5246,137 @@ have to gather again.
4660
5246
  falling, which is the premise the whole entry rests on, and it is the first
4661
5247
  time this file has had two measurements of it to compare.
4662
5248
 
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.
5249
+ - **Running `verify` over a subset of the scenarios, and what a partial run may
5250
+ be allowed to say.** `verify` is all-or-nothing and `check` executes nothing,
5251
+ so there is no middle. Reported by an adoption where `verify` takes **7m33s**
5252
+ wall clock three requirements are strength gates that play twelve long games
5253
+ each with the consequence stated as a change in *when* the tool gets used:
5254
+ `verify` became a run-at-the-end command, and `archive` became the everyday
5255
+ one, paying the full suite on every invocation because its verdict was the one
5256
+ needed. The ask is a selector `--only`/`--skip` over ids or tags, or simply
5257
+ honouring a Vitest name filter with the partial scope reported loudly enough
5258
+ that a subset pass cannot be read as a pass.
5259
+ *The cost is not Attest's, and that is the first thing the decision turns on.*
5260
+ Every expensive scenario in that report is behavioural: the engine's own
5261
+ overhead is not what makes 7m33s. So this is a selector over someone else's
5262
+ suite, and Vitest already has one — the question is whether Attest reporting
5263
+ *over* a filtered run is worth owning, not whether the run can be filtered.
5264
+ *What blocks it three, and they are about the report rather than the run.*
5265
+ **(1)** Coverage is computed against the whole registry: under a filter every
5266
+ unselected requirement has no scenario that ran, which is `uncovered-requirement`
5267
+ and `declared-not-run` firing on requirements that are fine. Either those codes
5268
+ gain a meaning that depends on which flags the run carried and a `code` is
5269
+ the one thing in a report consumers are told to branch on — or the report has to carry its scope
5270
+ explicitly and the codes have to be computed within it. **(2)** `archive`'s
5271
+ gate cannot accept a partial run at all: "done" having a hard definition is the
5272
+ claim the tool is for. So the flag has to be refused there, which means two
5273
+ shapes of `verify` and a rule for which commands may carry it. **(3)** `--json`
5274
+ is where a partial run does its damage — a consumer branching on `ok` must not
5275
+ read a subset pass as a pass, so this needs a field naming the scope and a
5276
+ decision about `SCHEMA_VERSION`, and that is the cheapest part to price first
5277
+ because it decides whether the feature is additive at all.
5278
+ *What would move it.* A second report of the same shape, ideally one where the
5279
+ slow half is smaller, so the question is a selector's ergonomics rather than
5280
+ one project's gates; and a scored answer to whether a scope-carrying report can
5281
+ be stated in a way that a `--json` consumer written before it cannot misread.
5282
+ **Measured on 2026-08-12, before building anything, per this section's
5283
+ discipline. The second of those two is now answered, and the answer is a
5284
+ refusal; the first blocker was wrong; and the whole feature turns out to be a
5285
+ different, much cheaper one.**
5286
+ *The cost claim reproduces on a second corpus.* `verify self`: the static half
5287
+ — scan, registry load, `parseSpecs` — is **79 ms of 30,539 ms, 0.26%**. So a
5288
+ selector cannot save engine work anywhere; it can only save scenarios, and the
5289
+ entry was right that this is a selector over someone else's suite.
5290
+ *Blocker **(1)** conflates two codes with different inputs, and no arrangement
5291
+ fires both.* `uncovered-requirement` reads registry × plan and is purely
5292
+ static; `declared-not-run` reads plan × runtime coverage. Measured over 77
5293
+ requirements and 214 scenarios, selecting one requirement: filtering the **run
5294
+ scope** while leaving the plan whole gives 209–213 spurious `declared-not-run`
5295
+ and **zero** `uncovered-requirement`; filtering the **plan** gives 76
5296
+ `uncovered-requirement` and **zero** `declared-not-run`. The choice of where
5297
+ the filter goes decides which one misfires, and the entry priced a case that
5298
+ cannot occur.
5299
+ *And on the run-scope arrangement it does not misfire at all, which is the
5300
+ finding that collapses the rest.* A run that did not execute a declared
5301
+ scenario really has not attested it, so `declared-not-run` is **true** under a
5302
+ filter — only its message guesses the wrong cause ("skipped, or excluded by an
5303
+ `.only`?"). The premise under "the codes have to be computed within [the
5304
+ scope]" is therefore false: the report is already honest, and a subset run is
5305
+ already loudly not a pass. 209 ERRORs on a one-requirement filter is the
5306
+ loudness the entry asked to build.
5307
+ *Blocker **(3)**, scored, comes back **impossible rather than expensive**.* The
5308
+ contract `cli-reference.md` publishes to consumers is that "a consumer can
5309
+ always parse stdout and branch on `ok`"; the next paragraph describes
5310
+ `schemaVersion` as a producer-side promise — what the emitter bumps — and
5311
+ checked across the whole tree rather than that page, every other mention of it
5312
+ is producer-side too, `ATX-5` included, which obliges the report to *declare*
5313
+ it. Nothing tells a consumer to gate on it before trusting `ok`. A consumer
5314
+ written before a `scope` field does not read new fields — that is what makes it
5315
+ a consumer written before them — so no
5316
+ additive shape can protect it, and a `SCHEMA_VERSION` bump is a signal to a
5317
+ reader who is not looking. Exactly one design is safe: a subset run never
5318
+ reports `ok: true`. That is what already happens, for free, by the paragraph
5319
+ above. So the correct answer to "does this need a scope field and a schema
5320
+ decision" is **neither**.
5321
+ *Blocker **(2)** dissolves into that.* `archive` refusing a partial run stops
5322
+ being a rule to design and becomes a consequence: a partial run cannot be
5323
+ `ok`, and the gate already refuses what is not.
5324
+ *What the measurement also shows is that the feature as asked would not have
5325
+ helped the reporter.* Their report says `archive` became the everyday command
5326
+ **because its verdict was the one needed**. A subset run cannot carry a
5327
+ verdict — that is the whole of blocker (3) — so a selector would have given
5328
+ them a fast non-verdict, which they already have by running Vitest directly.
5329
+ *The argument that survives is one the entry does not make, and it is the
5330
+ strongest available.* The child run is `config: false` — no aliases, no DOM, no
5331
+ plugins — so `npx vitest run one.spec.ts` executes in a **different
5332
+ environment** from the one the verdict comes from, and a green there does not
5333
+ predict `verify`. Owning a selector is the only way to run a subset in Attest's
5334
+ environment. That moves the justification off speed, where Vitest is the
5335
+ incumbent and wins, and onto parity between the fast loop and the verdict,
5336
+ where nothing else can serve.
5337
+ *So the thing to build, if a second report arrives, is not what this entry
5338
+ proposed.* Not a scoped verdict but a **development-loop command**: runs a
5339
+ subset in Attest's environment, reports what did not run as `declared-not-run`
5340
+ because that is true, and exits non-zero always. It claims no pass, so it never
5341
+ touches the impossibility above; it needs no field, no `SCHEMA_VERSION`
5342
+ decision, and no code whose meaning depends on a flag.
5343
+ *A second argument arrived on 2026-08-12, from the author's reason for building
5344
+ `status` rather than from an adoption, and it says a hole exists at all — which
5345
+ is the half the parity argument above does not supply.* `status` was added so
5346
+ that when collaborating with an agent there would be a fast way to know where a
5347
+ change stands without paying `verify` every turn. It delivers that for **stage
5348
+ 1 only**, and structurally rather than by omission: its two obligations — has
5349
+ each ADDed id a scenario, has each been recorded failing — are both settled by
5350
+ the end of stage 1 and never move again, so it reads all-`proven` for the whole
5351
+ of stage 2. Stage 2 is the long half, and the workflow document tells the agent
5352
+ to run `attest archive` there every iteration, correctly, because
5353
+ `*.proposed.spec.ts` is deliberately outside `verify`'s scope: the gate is the
5354
+ only Attest command that runs a change's own scenarios at all. So the everyday
5355
+ full-gate run in that adoption report was not a habit — it is what this
5356
+ project's own shipped instructions require.
5357
+ **The cost curve is per-turn, not per-CI-run**, which is what makes this
5358
+ different from the report the entry was filed on. A saving that looks small
5359
+ against one pipeline run is multiplied by every iteration of a conversation, and
5360
+ the agent is the consumer this framework writes a document *for*.
5361
+ *Labelled for what it is:* not the second adoption report this entry asks for,
5362
+ but a structural argument from this repository's own workflow, which is a
5363
+ different kind of evidence — no user was surveyed, and nothing here measures how
5364
+ much stage 2 actually costs anyone. What it does is narrow the thing to build
5365
+ one more notch. The development-loop command above is a **stage-2** tool, and
5366
+ its user is mostly an agent.
5367
+ *The cheap interim was written into the workflow document rather than left for
5368
+ this entry to deliver*, since it needs no engine change: run the proposed spec
5369
+ file directly for the inner loop, keep the gate for the verdict. Measured
5370
+ first — a `*.proposed.spec.ts` runs under a plain Vitest invocation, delta
5371
+ import and all — and it is safe in the one way that matters, because only the
5372
+ gate writes `first-run.json`. It is *not* a substitute, for the parity reason
5373
+ above, and that is why the entry stays open: the interim buys speed by leaving
5374
+ Attest's environment, which is precisely what a real selector would not do.
5375
+ *Still unmeasured, and both need the reporter's repo rather than this one:*
5376
+ whether their fast loop and their verdict actually diverge under `config:
5377
+ false`, which is what decides whether the surviving argument is real for them;
5378
+ and the second report of the same shape, which this measurement does not
5379
+ supply and does not replace.
4686
5380
 
4687
5381
  ## Considered and rejected
4688
5382
 
@@ -4694,6 +5388,142 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
4694
5388
  sinking one version deeper each time a release was cut above it, and a rejection
4695
5389
  filed under a version reads as belonging to it.
4696
5390
 
5391
+ ### Deferring the `typescript` import so `init` and `--version` do not pay it
5392
+
5393
+ Priced on 2026-08-13, in the session that enabled the compile cache above, and
5394
+ rejected on the size of what it actually reaches. The proposal was the move
5395
+ `pipeline.ts` already makes for the runner half — `vite`/`vitest` behind an
5396
+ `await import(...)`, on the argument that a short-lived process pays cold start
5397
+ as the whole bill — applied a second time to the AST half.
5398
+
5399
+ The two are not the same shape, and the measurement is why. The runner lazy-load
5400
+ took a cost off five of seven commands, because `check`, `cover`, `render`,
5401
+ `status` and `init` genuinely cannot start a run. The AST half has one
5402
+ beneficiary: **every command except `init` reads a registry or a spec through the
5403
+ compiler**, so for `check`, `cover`, `render`, `status`, `verify` and `archive`
5404
+ deferring the import moves when the 672 ms is paid and not whether. What is left
5405
+ is `init`, which is run once per project, and `--version` / `--help`. Against the
5406
+ compile cache — which took 11–28% off *all* of them for three lines — the
5407
+ remaining prize is roughly a further 260 ms on `init` alone.
5408
+
5409
+ The structural price is not small either, and it is concentrated in the wrong
5410
+ place. Everything funnels into the compiler through `pipeline.ts`'s static graph,
5411
+ including two edges that are easy to miss: `status` reaches it via
5412
+ `red-record.ts` → `merge.ts` → `splice.ts`, for the single path function
5413
+ `mergedSpecPath`. So deferring it is not one thunk beside the existing two; it is
5414
+ `runInit` and its destination resolution leaving `pipeline.ts`, and
5415
+ `resolveOutFile`'s stated property — private to that module "rather than
5416
+ something a shell is trusted to call" — weakening to pay for it. A once-per-project
5417
+ command is not worth that.
5418
+
5419
+ Worth reopening if either half of the arithmetic moves: a `typescript` whose AST
5420
+ surface can be imported without the checker (the `typescript/unstable/ast*` entry
5421
+ points `core/compiler.ts` already names, once they stop saying unstable), or a
5422
+ second command that needs none of it. `mergedSpecPath` sitting in the heavy
5423
+ module is independently worth fixing whether or not this is.
5424
+
5425
+ ### Widening `status` to the rest of the gate's static half
5426
+
5427
+ Scoped and decided on 2026-08-12, in the session that gave `status` its two
5428
+ refusals (an input it could not read, and a delta that will not apply). The
5429
+ question behind both was whether `status` should report *everything* about a
5430
+ change that is decidable without running anything — the gate has a static half,
5431
+ and `status` was projecting two of its checks. Four candidates were priced. Two
5432
+ shipped; these two did not, for opposite reasons, and the reasons are worth
5433
+ keeping because the shape of the proposal will recur.
5434
+
5435
+ *`validateStructure`'s ERRORs over the merged plan — already decided, and not
5436
+ here.* The proposal was to project the gate's first check whole: `orphan-test`,
5437
+ duplicate ids, and `uncovered-requirement` for every id rather than only the
5438
+ ADDed ones. Design §8 had settled it before the proposal was written — `status`
5439
+ projects "the *uncovered* half of check 1 and the whole of check 4, both
5440
+ restricted to the ids the change ADDs", and orphans and unbound params are named
5441
+ as **not** projected, "being facts about the whole applied registry rather than
5442
+ progress on an added id". So this was not a new decision to make but an old one
5443
+ the proposal had not read, and the finding is procedural rather than technical:
5444
+ the design document answers scoping questions about `status` that a reading of
5445
+ `status.ts` alone will not. Rebuilding it means changing §8 first and arguing
5446
+ against that sentence, which nothing here does.
5447
+
5448
+ *The proposed-spec findings — the only genuinely new shape, rejected on
5449
+ duplication.* `proposed-spec-unclaimed`, `proposed-spec-name-taken` and
5450
+ `spec-in-change-dir` are static facts about *this change*, and unlike the
5451
+ refusals that shipped they leave the report perfectly computable — every row
5452
+ stays correct. So they are neither progress nor a failure to compute, which is
5453
+ the one case the existing `issues` contract has no room for, and pricing that
5454
+ room was expected to be the expensive part. It never had to be priced: **`check`
5455
+ already reports all three**, and already reads each change's delta to attribute
5456
+ proposed specs, so `status` reporting them would put a second answer to one
5457
+ question in the tree — the arrangement this project takes apart everywhere else,
5458
+ and the reason `declaredNotRunIssues` and `hasError` are each one function. What
5459
+ was built instead is one line: `status`'s closing line now names `check`
5460
+ alongside `archive`. The cost is real and is not hidden — an agent in stage 2
5461
+ runs two commands where it wanted one — and naming it is the whole of the
5462
+ compensation.
5463
+
5464
+ *The consequence worth recording is that the `--json` question dissolved.* The
5465
+ open design decision that had been sequenced ahead of all four candidates was
5466
+ what shape new `status` findings take in the report: whether `issues` widens
5467
+ from "the report could not be computed" to "here are findings", or a second
5468
+ field arrives, and whether either moves `SCHEMA_VERSION`. After these two
5469
+ rejections there is nothing left to decide, because everything that survives is
5470
+ either a refusal — existing `issues` semantics, `ok: false`, no new field — or
5471
+ `check`'s to report. A blocking decision that evaporates once the candidates
5472
+ either side of it are settled is a sequencing error, not a hard question, and
5473
+ that is the transferable part: the shape of a report is decided by what is left
5474
+ to put in it, so the scoping goes first.
5475
+
5476
+ *What would reopen either.* For the first, an argument against design §8's
5477
+ sentence, not a fresh proposal that has not met it. For the second, a measured
5478
+ cost of the two-command loop — the same evidence the `Under consideration` entry
5479
+ on a stage-2 development-loop command is waiting for, and it would arrive from
5480
+ the same place.
5481
+
5482
+ ### A trailing-dot-and-space clause in `isSafeChangeName`, for a Windows normalisation that does not happen
5483
+
5484
+ Filed under `Under consideration` on 2026-08-09 and rejected on 2026-08-11 by
5485
+ the measurement it was waiting for. The premise was that Win32 strips trailing
5486
+ dots and spaces from a path component below the API, so `'.. '` — which passes
5487
+ every clause the guard has, being neither `''`, `'.'`, `'..'`, a separator nor
5488
+ NUL — would name `changes/`'s parent, and under `--eval` or `archive` that name
5489
+ reaches a module path that is then executed. The entry recorded that no Linux
5490
+ measurement could answer it and named both outcomes in advance: a fourth clause
5491
+ if it reproduced, or a note about the clauses' character if it did not.
5492
+
5493
+ *It does not reproduce.* Measured on Windows 11 26200, Node v22.23.2, NTFS.
5494
+ `mkdir` of `changes/.. ` creates a **literal directory named `.. `** —
5495
+ `readdir` returns `[".. "]` — and a write through the name lands inside it,
5496
+ leaving a file planted at the project root untouched. End to end through the
5497
+ CLI with a payload delta at the root, all of `status '.. '`, `status '.. '
5498
+ --eval` and `archive '.. '` fail with `change-not-found` naming
5499
+ `changes/.. /requirements.delta.ts`, and the payload never executes. Both
5500
+ readers were exercised deliberately, because they resolve differently and only
5501
+ one of them is the dangerous path: the static read and the Vite loader keep the
5502
+ trailing space alike. `'.. '`, `'.. .'`, `'...'` and `'. '` behave the same.
5503
+
5504
+ *The neighbouring win32-specific vectors were measured in the same pass*, since
5505
+ the guard's clauses are POSIX-shaped and this was the moment to ask what else
5506
+ they cannot see. `'C:'`, `'C:x'` and `'a:b'` pass the guard, and `join` splices
5507
+ no drive: every one stays under `changes/` and fails `ENOENT` at the colon.
5508
+ `'CON'` and `'NUL'` create ordinary directories. `'..x'` and `'x..'` are
5509
+ literal. Nothing escaped, and the root file was intact after all of them.
5510
+
5511
+ **What the entry's own fallback asks to keep, now earned rather than assumed:
5512
+ the clauses are the POSIX spelling of path safety, and on this platform that is
5513
+ the complete spelling.** The guard's doc comment says the test is path safety
5514
+ rather than a character whitelist — that claim survives the measurement, and it
5515
+ survives it on the platform most likely to have broken it.
5516
+
5517
+ *Rejected at the strength the evidence carries.* This is one Windows build and
5518
+ one Node version, so what is refuted is the general premise, not every future
5519
+ libuv. **The reach was always one level** — the name eaten gives the project
5520
+ root, and going above it needs a second `..`, which needs a separator the guard
5521
+ already refuses — so even a reproduction would have meant executing a file
5522
+ elsewhere *inside* the project. That bound is why a negative result closes this
5523
+ rather than merely deferring it: the cost of being wrong is bounded and the
5524
+ reopen condition is cheap, being a single `mkdir` on a machine where it behaves
5525
+ differently.
5526
+
4697
5527
  ### Refusing a half-loaded registry in `check` and `verify`, the way `cover` and `render` do
4698
5528
 
4699
5529
  `cover` and `render` return early on `hasError(loadIssues)`,
@@ -4855,8 +5685,10 @@ Vitest run that currently shares nothing with that work.
4855
5685
  is structural.* There is **no
4856
5686
  measurement** saying parsing costs anything here — this reader is
4857
5687
  `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
5688
+ what arktype pays for, and the fan-out measurement filed as an entry in
5689
+ `Under consideration`, and now shipped as the two concurrency bounds in
5690
+ `[0.7.0]` and `[Unreleased]` — clocked `attest check` over 6000 spec files at
5691
+ 1.99 s total. Worse, a cache is a correctness hazard
4860
5692
  of precisely the kind this tool exists to detect: a stale analysis makes a
4861
5693
  **drift detector** report drift that has already been fixed, or miss drift
4862
5694
  that has just appeared, and it would do so silently. Any version of this needs
@@ -5265,6 +6097,7 @@ requirement, and human review at propose is still the whole answer. What is
5265
6097
  rejected is grouping as a way to assist it, and this reopens only on a
5266
6098
  contradiction that grouping would have caught.
5267
6099
 
6100
+ [0.9.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.0
5268
6101
  [0.8.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.8.0
5269
6102
  [0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
5270
6103
  [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3