@am_shork/attest 0.5.0 → 0.7.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,779 @@ 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.7.0] - 2026-08-04
17
+
18
+ ### Added
19
+
20
+ - **A universally quantified statement now has to say what it quantifies over,
21
+ and the answer is checked.** `tests/intent-rule-candidates.spec.ts` gains a
22
+ `QUANTIFIED` table beside `OBLIGATIONS`: every statement carrying an open
23
+ quantifier is hand-judged into one of three kinds, and a statement flagged with
24
+ no row fails. 23 of the 62 requirements are flagged today.
25
+ *The shape it exists for.* A universal quantifier in the statement, a finite
26
+ set of scenarios under it, and nothing holding the two together. It reads as
27
+ covered — the id has scenarios, `cover` is green, `check` is green — because
28
+ all three mechanisms this project sells operate *below* the quantifier:
29
+ coverage asks whether an id has a scenario, never-red asks whether that
30
+ scenario discriminated, drift asks whether it reads the params. None asks
31
+ whether the scenarios span what the sentence claims. It had already cost
32
+ something — `ATX-37` said "everything the CLI writes to a terminal" while
33
+ `render` built a document by concatenation that never went past the sanitiser,
34
+ which shipped as a fix earlier under this heading without the *shape* being
35
+ addressed.
36
+ **The classification is the part worth keeping, because it predicts which
37
+ statements carry the risk.** `values` — the quantifier ranges over inputs one
38
+ site processes, so growth cannot escape it, there being one site. `roster` — it
39
+ ranges over a set the code itself enumerates and the scenario iterates that
40
+ same set, so a new member is covered by construction. `sites` — it ranges over
41
+ places in the implementation, discharged by one hand-written scenario each, and
42
+ **this is the only kind that can silently stop being true**. Today 14, 2 and 8
43
+ across 24 rows — one more than the 23 flagged, for the reason two paragraphs
44
+ down.
45
+ `ATX-37` was a `sites` row and `render` was the new site, which is the whole
46
+ argument in one line. The `roster` shape is the one to reach for and the repo
47
+ had already found it once without naming it — `self/targets.spec.ts` says "a
48
+ target added without a scenario would otherwise be a target nothing checks".
49
+ **It found a live hole on its first run, on the requirement written to avoid
50
+ exactly this.** `ATX-62` names its surfaces rather than quantifying over them
51
+ ("both commands that execute the suite — `verify` and the archive gate"), and
52
+ its rationale explains that choice in these words. Of the three sites it names,
53
+ the archive gate path had **no scenario asserting the diagnosis is emitted** —
54
+ only one asserting it *stands down* for a file `added-id-unmerged` already
55
+ claimed, and one asserting the withdrawal it enables. Both negative.
56
+ *Measured by mutation, because the first statement of this was wrong and the
57
+ correction is the useful part.* Deleting the `specLoadFailedIssues` call
58
+ outright does **not** survive: it sits inside `notRunIssues`, which both
59
+ commands share, so the `verify` scenario kills it. The mutant that isolates the
60
+ gate is the one that matters — `evaluateGate` filtering the diagnosis out of
61
+ what it pushes, which is a gate that drops the finding on its own path while
62
+ `verify` keeps it. That is the *exact* historical shape `ATX-62` exists to
63
+ answer, its rationale naming "the rule holding on one path and not the other".
64
+ Against the old spec that mutant runs **green, 0 errors**; against the new one
65
+ it is red. `self/gate.spec.ts` gains the missing scenario, and the run is now
66
+ 62 requirements / 160 scenarios.
67
+ *Two design decisions the table fails without.* **Rows are allowed for ids the
68
+ detector does not flag**, which is why `ATX-62` has one at all: naming the
69
+ surfaces is the *remedy*, and it takes the statement out of the flagged set, so
70
+ a table keyed strictly on the detector would drop the binding at the moment the
71
+ author did the right thing. **Every named scenario is asserted to exist** in
72
+ the specs, by name, for that id — without it the table is one more hand-written
73
+ list that goes on reading as covered after a rename, which is the failure mode
74
+ every other check in that file is written to avoid.
75
+ *And one deliberate gap, carried rather than hidden.* A site the statement
76
+ claims that nothing reaches is recorded as `null` and lands in the snapshot,
77
+ on the same footing as the compound rule's `missed` list. There is one:
78
+ `ATX-53`'s "some of several specs renamed, the rest not" — a state no fixture
79
+ can construct, because every change fixture carries a single spec.
80
+ The detector is the same kind of instrument as `compoundByKeyword` and has the
81
+ same limit, stated rather than tuned away: it catches the declared universal
82
+ and misses the article form ("A registry that cannot be read MUST …"), which is
83
+ a universal too. Two of the 23 rows are the idiom "at all" rather than a
84
+ quantifier, kept as rows because a regex tuned until it stops flagging is the
85
+ measurement bending to the corpus.
86
+ No behaviour change, no new `Issue` code, and no `ATX-n` — repo
87
+ self-discipline, for the reason at the top of that file: knowing which surface
88
+ a sentence names is prose judgement, the ground truth §0 refuses to compute for
89
+ someone else's requirements.
90
+
91
+ - **The eight `sites` rows re-read against `src/`, which is what the table was
92
+ built to make possible.** The rows were hand-judged in one pass, and the value
93
+ of the whole table is concentrated in that kind — so the first use of it is a
94
+ strict read asking one question per row: *is this list of sites complete?* Five
95
+ claimed-but-unattested sites came back, one row was retired, and one was wrong
96
+ in the good direction.
97
+ **`ATX-15` was short by two.** "Every ordering Attest commits to disk" is wider
98
+ than the rendering: `red-record.ts` sorts ids and scenario names into
99
+ `first-run.json`, and `splice.ts` sorts ids and param keys into the registry
100
+ file `--apply` writes. Both are committed and neither is attested. The property
101
+ holds everywhere — every `.sort` in `src/` goes through `byCodeUnit` and no
102
+ `localeCompare` survives — which is precisely the `ATX-37` position: held at
103
+ every site, enumerated at none.
104
+ **`ATX-37` was short by three.** `formatStatus`, `formatCoverage` and
105
+ `formatArchiveVerdict` each interpolate a reqId, a change name or a path taken
106
+ from the project under test, and the statement says *everything* the CLI
107
+ writes. All three call `inline()` today; none is attested. That is the state
108
+ `render` was in on the day `ATX-58` had to be written.
109
+ **`ATX-58` was recorded as weaker than it is, and the correction is the useful
110
+ part.** It was filed as four field-sites carrying `ATX-37`'s growth risk.
111
+ `sanitised()` rebuilds a whole `Requirement` as one typed object literal, so a
112
+ fifth field on that type is a **compile error**, not a silent gap — the axis is
113
+ closed by the compiler rather than by anyone remembering. Five sites collapse
114
+ to two, and this is the strongest of the eight rather than a repeat of the
115
+ weakest.
116
+ **`ATX-53`'s open gap was retired by building it.** The recorded `null` was
117
+ "some of several specs renamed, the rest not", said to be unconstructible
118
+ because every change fixture carries one spec. Constructing it showed the state
119
+ does not exist: a renamed spec is no longer a *proposed* spec, so it leaves
120
+ `claimedSpecs` the moment it is done, and a partial rename re-runs as the state
121
+ already covered with a shorter list. Measured rather than argued — a two-spec
122
+ project with one merged by hand passes against a merge mutated to rename only
123
+ the first claimed spec. So the scenario was **not** kept: it discriminates
124
+ nothing, and design §11 is explicit that a scenario which cannot fail is worse
125
+ than the comment recording why. `self/merge.spec.ts` carries that comment, and
126
+ the two resumption scenarios now share one `spliceByHand` helper instead of two
127
+ copies of the same insertion literal.
128
+ `ATX-40`, `ATX-41` and `ATX-60` came back complete. `ATX-60` with a caveat
129
+ worth its line: every verdict in `src/` goes through `hasError` and the two
130
+ bare spellings left are not verdicts, so the sites are right — but "never by a
131
+ second spelling" is a claim about the whole of `src/` discharged by two
132
+ examples, and nothing sweeps for a fifth.
133
+ *What the read is actually saying, across five of the eight.* The recurring
134
+ answer is **a sweep or a type**. `ATX-58` shows what closed looks like: the
135
+ compiler refuses the new site. `ATX-34` and `ATX-57` show the other closed
136
+ shape: a scenario iterating the roster the code owns. `ATX-15`, `ATX-37` and
137
+ `ATX-60` want one and have neither, which is filed under
138
+ `Under consideration` rather than built here — a rule that fires on correct
139
+ usage is the shape this file has rejected before.
140
+ Bookkeeping only: no behaviour change, no new `Issue` code, and the snapshot
141
+ now carries five unattested sites where it carried one.
142
+
143
+ - **A sketch in the design document is now bound to the file it says it is
144
+ from.** `tests/docs-consistency.spec.ts` takes every fenced `ts` block whose
145
+ first line names a `src/` path — the convention those documents already
146
+ follow — and asserts that every name the block puts at **module scope** still
147
+ exists at module scope in that file. Six blocks today, three per language. The
148
+ scan is over every hand-written page rather than the design document, through
149
+ the same enumeration the other checks use: a sketch is not something only one
150
+ file may hold, and a hardcoded list has already stopped covering a page here
151
+ once, on the day the CLI reference was split out of the README.
152
+ *Why this one, of the three candidates recorded with the entry.* Deleting the
153
+ sketches costs the design document the thing it is for; marking them
154
+ illustrative leaves an argument free to cite state that is gone. The symbol
155
+ check was the only candidate with a hit rate, and the hit rate is now measured
156
+ rather than estimated: replaying the pre-`a5899ac` document against today's
157
+ `src/runtime.ts` reproduces the original failure exactly — `currentReqId`,
158
+ named at module scope by the sketch and nowhere in the file. That replay is
159
+ what turned "would have caught it" into something checked.
160
+ **It found a third instance on its first run**, which is the fourth way this
161
+ file records an entry arriving. §5.4's `runner.ts` sketch declared
162
+ `const REQ_SUITE = /^\[(.+)\]$/` and read it with `task.name.match(...)`; the
163
+ regex moved to `core/req-suite.ts` and `runner.ts` has imported
164
+ `requirementIdOf` from it since. The sketch was not merely stale, it showed the
165
+ design document contradicting the module whose entire reason for existing is
166
+ that *"two spellings of one encoding are two encodings the moment either is
167
+ edited"* — a reader following §5.4 would have written the second spelling. Both
168
+ languages are corrected here.
169
+ *Three scoping decisions, each of which the check fails without.* **Module
170
+ scope, not every identifier the sketch names**: `runtime.ts` explains the
171
+ deleted `currentReqId` in two comments, so a substring scan over the source
172
+ text would have called the stale sketch fine — both sides are reduced to
173
+ declarations, imports and re-exports through the compiler API. **The `src/`
174
+ prefix, not file existence**: existence would make a typo'd path a silently
175
+ skipped block, the failure mode this file guards against everywhere else, so a
176
+ `src/` path that does not resolve fails instead and `requirements/auth.reqs.ts`
177
+ is left alone as what it is, a path in the reader's project. **Fenced blocks,
178
+ not prose**: prose may name a dead symbol legitimately, and §11 now does so on
179
+ purpose, as history.
180
+ **What it does not reach, because the honest count is one of two.** §11's
181
+ ESM-only bullet — the worse of the two instances, since it was an *argument*
182
+ resting on state that no longer existed — is prose, and stays outside. This
183
+ catches the sketch; the reader who comes to fix the sketch is what catches the
184
+ paragraph citing it. A sketch whose module scope survives while its body drifts
185
+ also passes, which is the same limit the byte-equality check already records
186
+ about the prose around a sample.
187
+ No behaviour change, no new `Issue` code, and no `ATX-n`: this is repo
188
+ self-discipline about this repository's own documentation, in the file that
189
+ already holds every other coupling cheap enough to assert.
190
+
191
+ ### Changed
192
+
193
+ - **`parseSpecs` held every spec file in memory at once; it now holds 32.** The
194
+ read was `Promise.all(files.map(readFile))`, so every source in the project
195
+ was resolved before the first one was parsed. Each source is now parsed at the
196
+ point it arrives, through a fixed pool, which makes the peak a constant rather
197
+ than the size of the input — the merged plan is the only thing that still grows
198
+ with the tree, and it is the output.
199
+ *Measured, both before and after.* On a synthetic tree of 6000 spec files the
200
+ old shape held ~47 MiB of source at once at a realistic 8 KiB per file;
201
+ `tests/locate-fanout.spec.ts` counts in-flight reads through the filesystem and
202
+ measures 200 before the change and 32 after, on a 200-file input. That test
203
+ lives in its own file because it mocks a Node builtin, and it reads the
204
+ property at `fs` rather than through `parseSpecs`, which is the honest seam:
205
+ peak in-flight reads is a fact about how the function uses the filesystem.
206
+ *What is deliberately **not** changed, and why the two halves are not one
207
+ decision.* `findFiles` fans out over sibling directories with no limit either,
208
+ and it stays that way. The failure it invites is descriptor exhaustion, and no
209
+ measurement on either development platform could produce one — on Windows, Node
210
+ uses Win32 handles rather than POSIX descriptors, so the shell's `ulimit -n`
211
+ does not govern the process, and the one platform where it is plausible is the
212
+ one this measurement could not reach. That half stays under
213
+ `Under consideration` on this file's own standard: no report, and no
214
+ demonstrated failure anywhere. The memory needed no such evidence — it is
215
+ arithmetic, and it is portable.
216
+ No behaviour change and no `ATX-n`: the plan is byte-identical either way, and
217
+ there is no contract here a consumer could branch on. `check` being the command
218
+ this project tells people to run first on an untrusted fork MR is what makes it
219
+ worth doing anyway — the input size is not ours to assume.
220
+
221
+ - **Every static command loaded the test runner it can never start.**
222
+ `pipeline.ts` imported `createLoader` from `loader.ts` and
223
+ `runAndCollect`/`BASE_EXCLUDE` from `runner.ts` at the top level, and
224
+ `cli/index.ts` imports `pipeline.ts` whole — so `vite`, `vitest/node` and
225
+ `vitest/config` were resolved and evaluated before `commander` had parsed an
226
+ argument. `check`, `cover`, `render`, `status` without `--eval`, and `init`
227
+ call none of them. The three are now reached through `await import()` at the
228
+ four `createLoader` sites and the two runner sites, all of which were already
229
+ in `async` functions.
230
+ *Measured on this repository, before and after, on one machine* — the earlier
231
+ figures recorded with this as a plan were taken in a different session and are
232
+ not comparable, which is the whole reason the baseline was re-taken here rather
233
+ than quoted. Importing `dist/core/pipeline.js`: **2133 ms → 1111 ms**.
234
+ `attest --version` end to end: 1700–2219 ms → 954–1220 ms. `attest check .`:
235
+ 1991–2215 ms → 1300–1478 ms. Roughly a second, on every invocation of a
236
+ command that could not have used it.
237
+ **This is not the core/adapter package split, and does not substitute for
238
+ one.** `tests/import-boundary.spec.ts` states its own gap in its own words — it
239
+ gates **direct** imports, not reachability from the static commands, because
240
+ `pipeline.ts` holds both halves of the engine — and files the answer under the
241
+ split §11 defers as not earned by one package at this size. That deferral
242
+ stands and that gap is unchanged: what moved is *when* the cost is paid, not
243
+ which module holds what.
244
+ **The one property this could have silently dropped was the boundary itself**,
245
+ and it did not: that gate reads `import(...)` and `require(...)` from the AST
246
+ alongside static imports, and has since it was written, precisely so it could
247
+ not be defeated this way. Both specifiers stay relative, so `ALLOWED` is
248
+ untouched and the reachability walk still traverses them. The comment there
249
+ saying dynamic import was unused in `src/` is now false and has been corrected;
250
+ a gate whose commentary describes a codebase that no longer exists is the
251
+ defect this file's *"six places where the source states a guarantee it does not
252
+ provide"* entry is about.
253
+ *What this does not buy, stated so the next reader does not expect it.* The
254
+ larger single item is `typescript` at ~936 ms, and `check`, `cover` and
255
+ `render` genuinely read the AST, so it stays on their real path — visible in
256
+ `--version` still costing ~1 s. Only `init` and `--version`/`--help` pay it for
257
+ nothing, and separating those means splitting `pipeline.ts` rather than moving
258
+ an import. So this closes the avoidable half and leaves the unavoidable one
259
+ measured.
260
+ **No behaviour change and no new requirement.** No command's output, exit code
261
+ or JSON shape moves, and the property worth stating — *a static command must
262
+ not load the runner* — is deliberately **not** filed as an `ATX-n`: it is not
263
+ falsifiable from inside the engine today, for the same reason the import
264
+ boundary gate cannot assert reachability, and a requirement that cannot go red
265
+ is the defect `archive`'s never-red gate exists to catch. It stays repo
266
+ discipline, in the gate that already holds it.
267
+
268
+ ### Fixed
269
+
270
+ - **The workflow `attest init` writes named the special case of a load failure
271
+ and not the general one.** `spec-load-failed` shipped in this cycle, after the
272
+ skill body was last edited, so its stage 2 table listed only
273
+ `added-id-unmerged` — which fires just when the change *also* adds an id the
274
+ registry lacks. An agent hitting a typo in a specifier therefore met an ERROR
275
+ absent from the table it was told to branch on, while the row that used to
276
+ appear, `declared-not-run`, had been withdrawn for exactly that file. The three
277
+ rows now say how they relate: the general case, the specific case that
278
+ supersedes it, and the withdrawal that keeps one file to one finding.
279
+ `ATX-57` could not have caught it. It holds one-directionally — every code the
280
+ workflow names must be emittable — which refuses a misspelling and is silent
281
+ about an omission, and that asymmetry is deliberate: the workflow has no
282
+ business naming every code. The other direction is not a rule anyone can write.
283
+
284
+ - **`check --eval` started a Vite dev server per change, plus one for the
285
+ registry.** `unclaimedProposedSpecIssues` reads every change's delta in a loop,
286
+ and `readDelta` created and closed its own loader on each iteration — so the
287
+ cost was 1 + N servers for a command that needs one. Measured on a project
288
+ with six changes: **7 loaders → 1**, and the command went from ~3.3–3.6 s to
289
+ ~1.7–1.9 s. Both readers now borrow a loader when the caller holds one, and
290
+ `runCheck` owns exactly one for the whole command through a `withLoader`
291
+ helper. Ownership sits at the command because neither reader knows how many
292
+ times it is about to be called.
293
+ Static `check` is unaffected in the way that matters: with no `--eval` it
294
+ passes `undefined` down and starts nothing at all, which is the property that
295
+ command is documented on (design §5.1).
296
+
297
+ - **`terminal.ts`'s `control` allocated three times the input to sanitise it.**
298
+ It spread the string into a per-code-point array, mapped, and rejoined, where
299
+ a character class lets the engine scan and — in the case every real registry
300
+ is — return the input untouched. Measured over 120,000 characters: **27×**
301
+ faster on plain ASCII, **54×** with newlines, **84×** on CJK. A payload that
302
+ is *entirely* control bytes is a wash at 0.9×, since then there is nothing to
303
+ fast-path and both spellings build a new string; both are linear either way,
304
+ so this is a constant factor rather than a second `ATX-59`.
305
+ Worth changing because the call count scales with the registry while the
306
+ length scales with whatever the registry chose: `render`'s `sanitised` calls
307
+ it once per statement, rationale, param key, param value and out-of-scope
308
+ entry, on the same reachable `render --check` path `ATX-59` came off.
309
+ **Byte-identical to the spelling it replaces**, which is the only thing that
310
+ mattered here: verified exhaustively over every code unit in the BMP, and over
311
+ 200,000 randomised strings mixing control bytes, CJK, astral characters and
312
+ lone surrogates — surrogates being the case the spread existed to get right.
313
+ Newlines survive as they always did, by not being in the class.
314
+
315
+ - **Four comments describing a property the code beside them did not have.**
316
+ This repository keeps its decision record in prose, so a comment claiming
317
+ something the code does not do is the same class of defect as a diagnostic
318
+ pointing at the wrong line: it is what the next reader believes *instead of*
319
+ reading. None of these produced a wrong answer today, and each is now true.
320
+ - `merge.ts`'s `archivePath` is documented as "one function because two places
321
+ ask … a date computed twice could straddle midnight and disagree with
322
+ itself", and called `new Date()` on **every** invocation — four times per
323
+ merge. A merge crossing midnight would have refused against one directory
324
+ and written to another. The stamp is now read once in `applyMerge` and
325
+ passed down, which makes `archivePath` a pure function of its arguments —
326
+ the property that turns "the two cannot disagree" into something the code
327
+ holds rather than something the clock does.
328
+ - `pipeline.ts`'s `claimedSpecsOf` is documented as "built from the plan
329
+ rather than by re-parsing, so the files `--apply` touches are exactly the
330
+ files the gate ran", and its caller handed it a fresh `parseSpecs` over
331
+ every proposed spec — the second such parse in that function, with
332
+ `changeMergedPlan` having done the first. The two agreed because the same
333
+ two functions ran twice, which is agreement by transcription: the thing this
334
+ codebase extracts `declaredNotRunIssues` and `uncoveredIssues` to stop
335
+ relying on. `changeMergedPlan` now hands back the claimed half it already
336
+ built, so there is one parse, one split and one answer — and the archive
337
+ path does one less full parse of every proposed spec.
338
+ - `pipeline.ts`'s `runCover` ordered ids with a bare `.sort()` where the rest
339
+ of the engine goes through `byCodeUnit`. The result is identical on ids that
340
+ match the grammar, which is exactly why it was worth changing: `order.ts`
341
+ exists so ordering has one spelling, and that is a property which survives an
342
+ edit where an identical result does not.
343
+ - `status.ts`'s `statusRows` filtered the whole plan once per added id, the
344
+ pattern `validator.ts`'s `detectPotentialDrift` carries a comment about
345
+ having removed — O(requirements × scenarios), and the plan here is the
346
+ *merged* one, so the inner term is the whole repository's scenario count.
347
+ Indexed once now. The cost was genuinely invisible (20 ids against 2000
348
+ scenarios is 40k comparisons, well under a millisecond), so this lands as
349
+ the inconsistency it was rather than as a slow path; scenario order within a
350
+ row is unchanged, still file then line.
351
+
352
+ - **`orphan-test`'s fix hint was wrong for the case that produces the most of
353
+ them.** "Add it to the registry, or fix the id" is right for an id that is
354
+ genuinely absent and **wrong** for one already sitting in a registry file that
355
+ failed to load: the id is there, the fix is the load error, and following the
356
+ advice adds a duplicate. One unreadable `*.reqs.ts` orphans every scenario of
357
+ every requirement it declared, so the wrong advice was also the loudest thing
358
+ in the report, with `registry-invalid` — the one true finding — pushed to the
359
+ top and out of view.
360
+ **The finding stays; only the advice changes**, and that is the whole of the
361
+ decision. Suppressing `orphan-test` needs a prefix→file mapping for a file
362
+ that never parsed, which is exactly the file no ids are available from; the
363
+ weaker signal (some registry file failed to load, so suppress all of them) is
364
+ a strictly worse `check` on a multi-file registry where the other files are
365
+ fine and their orphans are real. Both remain open under *Under consideration*.
366
+ What is shippable today is that a command which cannot tell the two cases
367
+ apart should say so rather than guess — so when any registry file fails to
368
+ load, the hint names the uncertainty and the order to work in. `Issue.message`
369
+ is prose and never API, so this costs no consumer anything.
370
+ *"A registry file failed to load" is not "loading produced an ERROR", and the
371
+ first draft of this had it wrong.* `duplicate-prefix` and
372
+ `duplicate-requirement` are ERRORs raised **after** a successful read, and the
373
+ ids they are about are present — so keying the new wording on `hasError` told
374
+ a project with a prefix collision that a file had failed to load, which is
375
+ false, about a report where the original advice was correct. `loadRegistry`
376
+ now answers the question it is the only thing in a position to answer: which
377
+ files contributed no ids, taken from the outcome it already branches on rather
378
+ than re-derived from a list of codes a caller would have to keep in step.
379
+
380
+ - **The Vite logger wrapper reported that it had never warned, however much it
381
+ had.** `sanitisedLogger` builds its wrapper by spreading the base logger, and
382
+ `hasWarned` is the one member of Vite's `Logger` that is state rather than
383
+ behaviour — so the spread copied it **by value** at construction. Measured:
384
+ after one `warn` through the wrapper, the base reads `true` and the wrapper
385
+ still reads `false`. An accessor pair makes the wrapper a view of the base
386
+ rather than a snapshot of it, which is what every other member already was.
387
+
388
+ - **`render`'s id comparator was non-transitive on an id it could not take
389
+ apart.** `compareIds` split on `lastIndexOf('-')` with no guard, so an id with
390
+ no dash silently lost its last character (`AUTH` → prefix `AUT`) and produced
391
+ `NaN` for its number. The consequence is worse than a wrong tie: measured on
392
+ `['AUTH-3','AUTH-abc','AUTH-2']` the sort returned `AUTH-3, AUTH-abc, AUTH-2`,
393
+ which is not a sorted permutation of anything. The comparator is now total by
394
+ construction, and falls back to the whole id rather than to the engine's sort
395
+ stability — `renderMarkdown` feeds `--check`, so an order that depends on
396
+ anything but the ids is a freshness gate that can disagree with the run that
397
+ wrote the file.
398
+ **Robustness, not a live defect, and the distinction is worth keeping.** A
399
+ malformed id cannot reach here through any command: `RequirementIdSchema`
400
+ rejects it and `render` returns early on a registry that failed to load —
401
+ confirmed by trying it. What was actually wrong is that two functions held two
402
+ beliefs about one input space: `locate.ts`'s `idPrefix` handles a dashless id
403
+ and records why ("this also runs over ids a delta proposed"), and this one
404
+ did not. They agree now.
405
+
406
+ - **A spec file that failed to load was reported as a file full of skipped
407
+ scenarios, and the file itself was named nowhere.** `verify` on a two-file
408
+ project with one bad import produced three ERRORs: `tests-red`, and one
409
+ `declared-not-run` per scenario in the broken file, each saying *"skipped, or
410
+ excluded by an `.only`?"*. That is a guess at a cause and the wrong one — it
411
+ sends the reader to audit a correct file for a `skip` that is not there, while
412
+ the import error explaining everything is named in no finding at all. The gate
413
+ had a diagnosis for one shape of this (`added-id-unmerged`, when the change
414
+ also adds an unmerged id); every other way a file fails to import had none, on
415
+ either command.
416
+ **The obvious repair was the wrong one, and `gate.ts` had already written down
417
+ why** — three lines from where it would have gone. Suppressing
418
+ `declared-not-run` for unloaded files produces a red report with nothing
419
+ pointing at the file: silence in place of a wrong message, and worse than what
420
+ it replaces. So the replacement comes first. `spec-load-failed` (`ATX-62`)
421
+ names the file on both commands that run a suite, and stands down where a more
422
+ specific diagnosis already named it — one file, one finding, and the one
423
+ carrying a fix wins. It deliberately does **not** restate the import error:
424
+ the run output above the report already has it, and a copy is a second place
425
+ for it to be wrong. No `reqId`, because a file that never loaded has no
426
+ established relation to any requirement.
427
+ **Both commands now ask one function**, `notRunIssues`, rather than each
428
+ spelling out the order themselves — the cause before the absences it caused,
429
+ and never the absences alone. That is how the two came to differ at all: the
430
+ gate learned to withdraw a misleading line and `verify` did not, so one run
431
+ produced a different diagnosis depending on which command asked. It is the
432
+ same argument that extracted `declaredNotRunIssues` from those two commands,
433
+ arriving one level up, and it is what stops this particular divergence from
434
+ being re-openable by an edit to either caller.
435
+ **`ATX-50` reaches `verify` now, and its line did not move.** That requirement
436
+ withdraws the absences a load-failure diagnosis replaces, and its recorded
437
+ condition was always *"a correct diagnosis exists for this file"* rather than
438
+ *"the file failed to load"* — it was scoped to the gate because the gate was
439
+ the only place that condition could hold. It now holds on both. The "and no
440
+ further" half is what an over-broad fix would have cost, and is separately
441
+ attested: a scenario that really was skipped, in a file that loaded, is still
442
+ reported.
443
+ **Behaviour change, for a project that has a spec file failing to load.**
444
+ `verify` and `archive` report one `spec-load-failed` per unloaded file instead
445
+ of a `declared-not-run` per scenario in it. Both are ERRORs and both runs were
446
+ already red, so no green becomes red and no red becomes green; what changes is
447
+ which finding is in the report and what it says. Anything branching on
448
+ `declared-not-run` for such a file — no consumer is known to — sees the new
449
+ code instead. Additive to the JSON surface (a new `code` value), so
450
+ `SCHEMA_VERSION` is unchanged.
451
+
452
+ - **`attest render` let the registry it was reading write to the terminal
453
+ reading it.** ATX-37 put every byte the CLI prints through a sanitiser, and
454
+ the scope line of that requirement says "everything the CLI writes to a
455
+ terminal" — but `render` builds a *document* by string concatenation that
456
+ never went past it. The static reader hands back the **cooked** string, so an
457
+ escape written as an escape in a `*.reqs.ts` is a real control byte by the
458
+ time it is concatenated. *Measured on the built CLI*, with no `--eval` and no
459
+ flag at all: a statement carrying `ESC [2K CR` erases the reviewer's line and
460
+ repaints a verdict, and `ESC ]0;` retitles their window. On a fork merge
461
+ request the author is not the reviewer, and a registry — not a test, not a
462
+ script — is the whole payload.
463
+ **The obvious fix was the wrong one, and the loop said so before it was
464
+ written.** Sanitising the terminal write would have left `--out` putting the
465
+ same bytes in a file that is committed, served, and read again later by `cat`,
466
+ by `less -R`, or by a static site generator. The artifact outlives the run, so
467
+ the run is the wrong place to defend: the obligation is now over the document
468
+ (`ATX-58`), which is also what keeps `--check` honest, since both sides of
469
+ that comparison are built from one function. Newlines survive, because a
470
+ rendered rationale is Markdown prose that may legitimately span lines — and
471
+ they are *not* indented the way ATX-37 indents them, because four spaces in
472
+ Markdown is a code block.
473
+ **Behaviour change, for one shape of project.** A registry that actually
474
+ contains a control character renders to different bytes than before, so a
475
+ committed rendering goes `stale-spec-doc` once and is fixed by regenerating.
476
+ A registry without one is byte-identical: this repo's own `SPEC.md` changed by
477
+ insertion only.
478
+
479
+ - **A statement padded with whitespace cost `render` quadratic time, and
480
+ `render --check` is the gate people put in CI.** The table cell collapsed a
481
+ multi-line statement with a pattern whose leading quantifier is followed by a
482
+ required character, so a whitespace run containing no newline was consumed,
483
+ failed, and re-tried one character shorter from every position in it.
484
+ *Measured through the CLI*: 120,000 spaces in one statement cost 8.6 seconds
485
+ of pure CPU, 320,000 cost 58 — under the **static** reader, so a fork merge
486
+ request can burn a runner without executing a line of anything.
487
+ **The first repair measured slower than the defect**, which is the part worth
488
+ keeping: `[^\S\n]*\n[^\S\n]*`, which stops the class matching the newline,
489
+ is just as quadratic, because the backtracking was never about which
490
+ characters the class held — it was about the quantifier having something after
491
+ it. Matching each maximal whitespace run once and deciding in a callback has
492
+ nothing after the quantifier to fail into. Byte-identical to the old pattern,
493
+ asserted over 200,000 randomised inputs and confirmed on the real corpus, and
494
+ now bounded by `ATX-59` at a budget three orders of magnitude looser than a
495
+ linear implementation needs — a defect detector, not a machine-speed detector.
496
+
497
+ - **Grouping scenarios under a `describe` took the whole spec file down, and
498
+ the static half called that file fully covered.** Vitest defers a nested
499
+ collector until after the parent's callback returns — measured: at the end of
500
+ the outer callback the inner one has not run — so the module-level
501
+ `currentReqId` that `requirement()` set and restored around its body was
502
+ already back to `null` when a grouped `scenario()` ran. It threw, and a throw
503
+ during collection is a *file* error: nothing in that file was collected.
504
+ **The blast radius is the file, not the group.** A correct, ungrouped scenario
505
+ belonging to a *different* requirement in the same file came back
506
+ `declared-not-run` too, blamed on "skipped, or excluded by an `.only`?" —
507
+ sending the reader to audit their spec for something that is not there.
508
+ **And the static commands were not merely misdirected, they were wrong.**
509
+ `check` answered `✓ No issues` and `cover` answered `0 uncovered` for a file
510
+ that could not execute a single line. Those are the two commands documented as
511
+ the fast pre-check that is safe to run first on an untrusted fork, so a
512
+ pipeline running only `check` — a use this reference recommends — would pass a
513
+ change whose specs never run. That is a false green, which is the one failure
514
+ class this tool exists to prevent.
515
+ **Fixed at both ends, because it was one seam disagreeing with itself.**
516
+ `parser.ts` has always recursed to any depth while the task-tree walk counted
517
+ only *direct* `test` children of a `[reqId]` suite, so even a runtime that
518
+ stopped throwing would have reported a grouped scenario that ran and passed as
519
+ never run. The module-level variable is **deleted** rather than repaired — it
520
+ was a second answer to a question the suite name already answers — and both
521
+ sides now go through one `req-suite.ts` that owns the `[id]` encoding.
522
+ **The narrower fix was measured and rejected.** Having the parser refuse a
523
+ `scenario()` that is not a direct child looked smaller and would have closed
524
+ the false green too, but a *synchronous* wrapper — `forEach`, or a local
525
+ helper — works correctly today at both ends, and no static rule separates one
526
+ from a deferred callback. It would have rejected working code to describe a
527
+ runtime property.
528
+ *Behaviour change, two of them.* The nesting contract is now enforced when a
529
+ scenario **runs** rather than when it is collected, so a scenario genuinely
530
+ outside any `requirement()` fails as one test instead of taking its file down.
531
+ Nothing that passed before fails now.
532
+ The second is smaller and out of contract, and is stated because it is silent:
533
+ `scenario` used to hand its function to `it` directly, so Vitest called it with
534
+ the test context, and it is now invoked with no arguments. The declared type
535
+ has always been `() => void | Promise<void>` and the design document has always
536
+ shown it that way, so TypeScript rejected any use of that argument; a
537
+ JavaScript consumer who reached for `ctx.skip()` inside a scenario would find
538
+ it gone. Preserved deliberately as-declared rather than threaded through: the
539
+ contract is the zero-argument one, and adding a parameter to keep an
540
+ undeclared behaviour alive is a hook for a need no requirement has. `docs/*/attest-design.md` §3's runtime sample is
541
+ updated, as is the ESM-only note in §11, whose stated hazard was the very
542
+ module-level state this removes.
543
+
544
+ - **Four verdict predicates that were not `hasError`, one of which would have
545
+ reported a failed gate after the merge had already written the files.**
546
+ `types.ts` states the invariant in as many words — `hasError` is "the single
547
+ predicate the pipeline verdict, the JSON `ok`, and the CLI exit code all read
548
+ from, so those three can never disagree" — and four sites spelled it by hand:
549
+ `archive`'s report and its human rendering both asked `blocking.length === 0`,
550
+ and the merge path asked `issues.some(i => i.level === 'ERROR')` twice.
551
+ **The `archive` one had a consequence its own neighbour predicts.**
552
+ `runArchiveApply` deliberately keeps the gate's non-blocking output, with the
553
+ comment that dropping a WARNING "would make `--apply` quieter than the same
554
+ command without it" — so a WARNING reaching `blocking` is not hypothetical, it
555
+ is planned for. The first one to arrive made the report answer `ok: false`, the
556
+ process exit 1, and the terminal print `✗ Gate failed` **after `--apply` had
557
+ written the registry, renamed the specs and moved the change folder**.
558
+ Reporting failure after a destructive step succeeded is the worst shape a
559
+ verdict can take, and the only thing preventing it was that every issue those
560
+ paths construct happens to carry a literal `ERROR`.
561
+ Now `ATX-60`, stated over the predicate rather than over `archive`, because the
562
+ defect is that the question had more than one spelling and any command could
563
+ grow a fifth — and stated to cover the human verdict as well as the machine
564
+ one, since both were wrong here independently and fixing either alone leaves a
565
+ command whose two surfaces disagree about whether it failed. Distinct from
566
+ `ATX-5`, which promises the implication one way only: `ok` false whenever there
567
+ is an ERROR says nothing about `ok` false when there is not, which is the half
568
+ that broke. No behaviour change today, which is the argument for doing it
569
+ before something has been reported wrong rather than after. **No
570
+ `schemaVersion` bump**: the envelope keeps every field it had and the `Issue`
571
+ interface is untouched — what changed is the value `ok` takes in a case
572
+ nothing can currently produce, and a consumer branching on `ok` and
573
+ `issues[].code` as documented reads the same report it always did.
574
+ *Found by reading, and the count was wrong when it was filed:* the note that
575
+ queued this named three sites; the rendering in `cli/index.ts` was the fourth,
576
+ and it is the one a human would actually have seen.
577
+
578
+ - **Sweeping for a second backtracking site found something that is not
579
+ backtracking.** Every regex `src/` executes was measured against 21
580
+ adversarial input shapes at doubling sizes, with the old table-cell pattern
581
+ left in as a positive control — it was the only superlinear hit, which is what
582
+ makes the other eighteen being linear evidence rather than silence. The
583
+ structural reason is worth keeping: that pattern began with a quantifier that
584
+ can match nothing, so **every index** was a valid start; every other regex here
585
+ begins with a literal or a required class, which caps the starts at how often
586
+ that character occurs.
587
+ What the sweep turned up instead was the code-span fence, which measured the
588
+ longest run of backticks with `Math.max(0, ...runs)` — one stack argument per
589
+ run, so a param holding a few hundred thousand of them exhausted it. Same
590
+ reachable path as the quadratic one (`attest render` and `render --check`,
591
+ static reader, no `--eval`), same shape of defect — a registry choosing what
592
+ rendering costs the person reading it — and a different mechanism entirely.
593
+ `ATX-59` now states both and is counted as two obligations, because a
594
+ quadratic pattern and an unbounded spread regress independently.
595
+ A third candidate was measured and **rejected**: a deeply nested literal does
596
+ overflow the stack, but TypeScript's own parser gives out around depth 1,000
597
+ and this project's AST walk not until 2,000, so the limit belongs to the
598
+ compiler Attest is built on. It also lands where the contract already puts it —
599
+ a well-formed `internal-error` envelope under `--json`, and a sanitised stack
600
+ on the side people read.
601
+
602
+ ## [0.6.0] - 2026-08-03
603
+
604
+ ### Added
605
+
606
+ - **The workflow `attest init` writes now describes this release, and one line
607
+ of it was telling agents to undo correct work.** The skill is the document an
608
+ agent reads without a human in the loop, and it had drifted twice.
609
+ **The wrong line.** Its merge step said the specs are renamed in place with
610
+ *"no import changes; if you find yourself editing a specifier, the spec was not
611
+ written at its merged location and stage 1 was the place to fix that."* Sixty
612
+ lines above, the same document teaches a stage-1 scenario to read its proposed
613
+ params from the delta — `import reqs from '../../changes/<name>/requirements.delta.ts'`
614
+ — with the comment *"only this line changes at merge"*. So the file contradicted
615
+ itself, and an agent following it either left a merged spec importing a path
616
+ that step 3 had moved into `archive/`, or concluded a correctly-written spec was
617
+ wrong and moved it, producing the very rewrite the sentence claimed to prevent.
618
+ Exactly the defect `--apply` hit while being built, sitting in the document that
619
+ is trusted furthest.
620
+ The merge step is now `attest archive <name> --apply`, with the by-hand path
621
+ kept for the operations it refuses and stated correctly: one specifier changes,
622
+ and it changes because the delta moves, not because the spec was misplaced.
623
+ Two prevention lines added where they cost nothing — a spec under `changes/`
624
+ is `spec-in-change-dir`, and a proposed name whose merged form is taken is
625
+ `proposed-spec-name-taken`, which is an authoring-time choice and so belongs in
626
+ the authoring guide rather than only in a diagnostic. `compiler-unsupported` is
627
+ deliberately **not** added to the stage-2 table: that table answers "what does
628
+ this mean for my change in flight", and a toolchain failure is not something an
629
+ agent fixes by editing the change.
630
+ **The gate that was missing** is now `ATX-57`. Troubleshooting is asserted
631
+ against `ISSUE_CODES` in both languages and the CLI reference against this file,
632
+ while the skill's content was held by four `toContain` calls and otherwise by
633
+ eye — the wrong footing for the one document whose reader is not a person and
634
+ who branches on the codes in it. Every code-shaped token it names is checked
635
+ against the roster, one-directionally: the workflow has no business naming every
636
+ code, so the obligation is only that what it does name exists. `ATX-40` is the
637
+ same shape pointing the other way over the other document. Given a requirement
638
+ rather than left a plain test for the reason this project exists: a gate that is
639
+ not itself gated is a gate someone deletes, and without one over it, removing
640
+ the check is a green diff.
641
+ `attest init` is idempotent and takes no input, so re-running it after upgrading
642
+ is the whole update mechanism; nothing here changes that.
643
+
644
+ - **A compiler with no AST API is now a diagnosis rather than a crash
645
+ (`compiler-unsupported`).** Discovered by building the `typescript` matrix in
646
+ `[0.4.2]`, which is what a matrix is for. `typescript@7` is the native port: its
647
+ package resolves, imports cleanly and still answers `version`, while the AST
648
+ moved behind `typescript/unstable/ast*` — so `createSourceFile` and every
649
+ `ts.isX` guard both registry readers and the spec parser call is `undefined`,
650
+ and the first one touched throws. Measured: `attest check` died with
651
+ `TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
652
+ stack — no code, no fix hint, and nothing saying which compiler it wanted, on
653
+ the crash path `[0.4.2]` had just finished sanitising.
654
+ **The declared range excludes 7, and that is not the same as being safe from
655
+ it.** A caret is a request, not a constraint an adopter cannot override:
656
+ `pnpm.overrides` and Yarn `resolutions` both pin a transitive dependency across
657
+ a whole tree, and a repo standardising on one compiler is exactly the repo that
658
+ uses them — this one has an `overrides` block of its own. So the reachable case
659
+ is a deliberate, reasonable act answered with a stack trace.
660
+ Every AST-reading command now refuses before it opens a file, naming the version
661
+ it found and the supported range. The range lives in `ATX-56`'s `params` and its
662
+ scenario compares that against `package.json`: a diagnostic naming a range that
663
+ has drifted from the dependency it describes is worse than one naming none.
664
+ The refusal takes a compiler rather than reaching for the import, because the
665
+ condition exists only on a compiler this repository cannot install beside the
666
+ one it builds with — and a check nothing can make fail is the `never-red` shape
667
+ the gate exists to catch.
668
+ *What it does not do is make Attest work on TypeScript 7.* That is a migration
669
+ to `typescript/unstable/ast`, against an API whose own name says not to depend
670
+ on it yet. The honest answer for now is a supported ceiling that says so, and
671
+ `troubleshooting.md` carries the override-scoping escape for anyone pinning the
672
+ compiler tree-wide.
673
+ This is a behaviour change only in the sense that a crash became an exit code:
674
+ nothing that worked before reports it.
675
+
676
+ - **`attest archive <change> --apply` finishes the merge its gate approved.**
677
+ The gate proved a delta green, covered and drift-free, and a human then
678
+ transcribed it by hand with nothing checking the transcription — the verdict did
679
+ not cover the step acting on it. Reported by `mine-capablanca`: eight changes,
680
+ eight hand-written Python scripts, one of which located the delta's block by
681
+ `split('added: {', 1)`, trimmed two spaces of indent per line and re-terminated
682
+ the registry on `assert t.endswith('});')`.
683
+ Three steps, and the gate runs first in the same invocation: splice each ADDED
684
+ requirement into the registry file owning its prefix, repoint and rename the
685
+ change's proposed specs, move `changes/<name>/` to `archive/<date>-<name>/`.
686
+ Every path touched is printed. A red gate writes nothing, and there is no flag
687
+ that skips it — a merge acting on an earlier run's verdict could file an
688
+ unfinished change as done, which is the one thing this tool must not ship.
689
+ **Why this is not the `AGENTS.md` merge tool, which stays rejected.** That was
690
+ refused because "each of its failure modes is destructive on a file the user
691
+ cannot regenerate". The registry is a literal Attest defines, statically
692
+ parseable, with two readers already asserted to agree on it — so the result of an
693
+ edit is checkable by re-reading it. That clause only holds if the edit is a
694
+ **pure insertion**, so nothing renders a registry back out of a `Registry`
695
+ object: doing so would be easier and would drop every comment and layout choice
696
+ in a hand-written file while passing a re-read with flying colours, because the
697
+ values all survived. `tests/splice.spec.ts` asserts the insertion is pure by
698
+ reconstructing the original from the result, rather than by looking for the
699
+ comments — the comments are a sample of the property, not the property.
700
+ **Re-runnable rather than atomic, which is what pinned the step order.** No
701
+ primitive spans one edit, N renames and a directory move, and a scratch copy of
702
+ the project root would have to be swapped back through the same non-atomic set
703
+ again. So every step is derived from the tree as it currently is, and a partial
704
+ application is finished by running the same command again. `ATX-53` states that
705
+ and deliberately **not** the order it is bought with: an order is not observable —
706
+ every ordering agrees on the happy path — so a requirement about it could not be
707
+ falsified, which is the `never-red` shape the gate exists to catch. Resumability
708
+ can be falsified, and it fails exactly when the order is wrong: move the folder
709
+ first and the recovery has no delta left to re-derive from; rename before
710
+ splicing and the intermediate tree has merged specs reading a registry without
711
+ their ids.
712
+ **ADDED only, and refused whole otherwise** (`apply-unsupported-delta`,
713
+ `apply-no-prefix-owner`). The gate still applies all four operations in memory,
714
+ so nothing becomes unverifiable — only the write-back stops, because REMOVED
715
+ cannot say which comments belonged to the entry it deletes and MODIFIED is a
716
+ formatting-preserving edit inside an existing literal. Both are the shape the
717
+ rejection named. Whole rather than partial because an implementation that merged
718
+ the half it understood would hand back a change the author believes is done.
719
+ **The collision that would have destroyed work** ships with it as
720
+ `proposed-spec-name-taken`, reported by `check`. `isSpecFile` and
721
+ `isProposedSpecFile` are disjoint, so `session.spec.ts` and
722
+ `session.proposed.spec.ts` coexist with nothing objecting — and the rename would
723
+ overwrite the first. Not a corner: a proposed spec is written beside the code it
724
+ attests, so a change to an already-tested module reaches for exactly that name.
725
+ Held in `check` rather than only refused at merge time, so the fix is a rename
726
+ while renaming is still free.
727
+ **One clause of this entry's own argument was wrong, and building it is what
728
+ found out.** It recorded that `[0.5.0]` had removed the import-rewriting blocker
729
+ outright, because a proposed spec sits at its merged location and merging it is
730
+ "a rename in place". True of its *location* — but `[0.5.0]` also made a delta
731
+ read as the registry it proposes, so a stage-1 scenario imports the delta, and
732
+ step 3 moves the delta away. The first working version of this command moved all
733
+ three things correctly and left every merged spec importing a path that no longer
734
+ existed; `check` was silent, and only running the merged suite showed it. So one
735
+ specifier is repointed after all — located by AST, replaced as text, with the
736
+ expression around it untouched because `reqs['AUTH-7'].params.x` reads the same
737
+ on both sides. Far smaller than "rewriting arbitrary TypeScript", but not the
738
+ zero this file claimed. `ATX-52` therefore attests that the *merged project
739
+ verifies clean*, not that three files moved: the broken version would have passed
740
+ the second.
741
+ **It does not re-render a committed `SPEC.md`,** and cannot: nothing in a project
742
+ records where its rendering lives, so the entry listing that as a fourth step was
743
+ describing something with no input. `render --out` afterwards, which a pipeline
744
+ running `render --check` already asks for.
745
+ `ATX-52`–`ATX-55`, with eight scenarios. `--apply` is additive — no exit code
746
+ moves for anyone not passing it — but `proposed-spec-name-taken` is a new ERROR
747
+ on `check` and **will turn some CI red**: a layout that passed before now fails,
748
+ and everything it fails would have been destroyed by the first merge.
749
+
750
+ - **`check` now names a spec left under `changes/`, where nothing walks it
751
+ (`spec-in-change-dir`).** `[0.5.0]` moved a change's specs to their merged
752
+ location and told anyone with an open change directory to move each file,
753
+ rename it `*.proposed.spec.ts` and fix its imports once. It did not account for
754
+ the state *before* that is carried out. `changes` is in the project walk's
755
+ skip list, and `[0.5.0]` removed `changeExcludeGlobs` — the mechanism by which
756
+ `attest archive <name>` used to include `changes/<name>/specs/` explicitly.
757
+ Both halves are right on their own; together they mean the old location is
758
+ walked by no command.
759
+ **Measured before it was fixed**, on a copy of `fixtures/e2e-archive` with its
760
+ proposed spec put back at `changes/add-totp/specs/totp.spec.ts` and its two
761
+ specifiers re-depthed: `attest check` exited **0** with `✓ No issues.`, and the
762
+ only command that spoke was the gate — `uncovered-requirement`, verbatim
763
+ *"Requirement "AUTH-7" has no scenario attesting it. Add a scenario, or mark it
764
+ deferred."* The author had written two, and both were correct. So the one
765
+ diagnostic available told them to write what they had written, or to defer a
766
+ requirement they had finished. That is the misdiagnosis shape `ATX-49` records,
767
+ arriving from the other direction — there the verdict pointed away from a load
768
+ failure, here away from a path — and in both cases it spends the reader's
769
+ attention on files that are fine.
770
+ In `check` rather than the gate: the gate at least fails, while `check` is both
771
+ the command a pipeline runs first and the one that said nothing at all. Both
772
+ spellings are reported, because a `*.proposed.spec.ts` under `changes/` is a
773
+ migration done halfway — the right marker at the wrong path — and no more
774
+ reachable than one never begun. An ERROR rather than a warning for `ATX-47`'s
775
+ reason: a spec that runs nowhere is a total failure, not a misplaced one.
776
+ *It is not transitional and does not expire.* After `[0.5.0]` a spec under
777
+ `changes/` is wrong permanently; catching an unfinished upgrade is what it is
778
+ worth first, not what it is for.
779
+ **This is a behaviour change and turns some CI red** — a layout that passed
780
+ `check` in `[0.5.0]` now fails it. That is the point, and everything it fails
781
+ was already running nowhere. `ATX-51`, with three scenarios: the pre-move
782
+ spelling, the half-migrated one, and a proposed spec at its merged location
783
+ that must *not* be reported, because a check firing on the documented workflow
784
+ would be §9's "usually wrong about staleness" failure again.
785
+ Found while asking whether a *different* proposed check — the merged-name
786
+ collision under `attest archive --apply`, still in `Planned` — would help
787
+ anyone upgrading. It would not; this does.
788
+
16
789
  ## [0.5.0] - 2026-08-03
17
790
 
18
791
  ### Changed
@@ -2249,75 +3022,100 @@ feedback reports are triaged into issues or `Unreleased` and deliberately not
2249
3022
  kept in this tree; that rule stands, and holds for anything with a reporter, a
2250
3023
  repro, or a state to track. What is kept here is the narrower thing this file
2251
3024
  already keeps one section of: a **decision with an argument attached**, where
2252
- losing the argument would cost more than losing the task. The first below came out
2253
- of building something queued here, which is one way an entry arrives: a gate that
2254
- finds a defect on its first run files the defect. The second came out of reading
2255
- another repository against this one `msw`. The last came out of an adoption
2256
- report (`mine-capablanca`, 0.4.2, 34 requirements / 56 scenarios / greenfield),
2257
- which is the third way: not a defect, but a shape the tool made an adopter work
2258
- around eight times without ever erroring. Two further entries from that report
2259
- left this section by shipping in `[0.5.0]`a change's specs moving to their
2260
- merged location, and the delta reading as the registry of what it adds — and
2261
- their arguments are recorded there, including the part of the first one that
2262
- turned out to be wrong.
2263
-
2264
- - **A diagnosable refusal when the resolved compiler has no AST API.**
2265
- Discovered by building the `typescript` matrix (see `[0.4.2]`), which is
2266
- what a matrix is for. `typescript@7` is the native port: its npm package's main
2267
- export is `{ version, versionMajorMinor }`, and the AST moved behind
2268
- `typescript/unstable/ast*` so `ts.createSourceFile` and every `ts.isX` guard
2269
- the two readers call is `undefined`. Measured: `attest check` on it dies with
2270
- `TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
2271
- stack, on the crash path this release just finished sanitising. No issue code,
2272
- no fix hint, and nothing saying which compiler it wanted.
2273
- **The range excludes 7, and that is not the same as being safe from it.** A
2274
- caret is a request, not a constraint the adopter cannot override: `pnpm.overrides`
2275
- and Yarn `resolutions` both pin a transitive dependency across the whole tree,
2276
- and a repo standardising on one compiler is exactly the repo that uses them
2277
- this one has an `overrides` block of its own. So the reachable case is a
2278
- deliberate, reasonable act by an adopter, answered with a stack trace.
2279
- What is planned is a check of the API surface where the readers are entered,
2280
- reported as an ERROR naming the resolved version and the supported range. Cheap,
2281
- and it converts the one dependency failure this tool cannot survive from a crash
2282
- into a diagnosis. *Deliberately not folded into the matrix commit*: a new ERROR
2283
- is a behaviour change, it needs an issue code, a troubleshooting section in both
2284
- languages and an `ATX-n` with a scenario, and none of that belongs in a commit
2285
- whose whole claim is that nothing about behaviour moved.
2286
- *What it cannot do:* make Attest work on TypeScript 7. That is a migration to
2287
- `typescript/unstable/ast`, against an API whose own name says not to depend on
2288
- it yet — a separate decision, with the honest answer for now being that the
2289
- supported range has a ceiling and says so.
2290
-
2291
- - **Region-marked samples, so a partial quote can be bound too.** The whole-file
2292
- half of this shipped in `[0.4.3]`: the README's two getting-started blocks
2293
- are now byte-equal to `fixtures/consumer/`. That buys 2 of the 23 fenced `ts`
2294
- blocks in the README and `docs/en/**`, and the ceiling is structural rather
2295
- than lazy the other 21 are elided (`{ /* */ }`), annotated (`// ✓ the single
2296
- source`, `// a literal that can drift`), or deliberately rejected by the engine
2297
- (`params: { maxMb: MAX_MB }`), and none of those can equal a runnable file.
2298
- **What raises it is marking spans, not a second mechanism.** A
2299
- `// #region readme:registry` and its close inside the fixture, with the
2300
- assertion running block against dedented span, lets one file feed several
2301
- passages and makes a partial quote bindable by construction. Where that
2302
- actually reaches, counted rather than hoped: `troubleshooting.md`'s
2303
- single-source pair is two verbatim lines of `fixtures/consumer/session.spec.ts`
2304
- and its `AUTH-3` block is the registry entry at a different indent — so a
2305
- handful more, not the remainder. Anything carrying a `✓`/`✗` is authored
2306
- commentary and stays a fragment; that is what it is for.
2307
- *The blocks are a separate question, and worth not conflating.* A sample
2308
- showing input the engine must reject could be bound to a fixture whose
2309
- *rejection* is asserted which is what `fixtures/eval-invalid-registry`
2310
- already is. Binding those is the more valuable half, because a counterexample
2311
- that stopped being a counterexample is the one kind of stale sample that
2312
- actively teaches the wrong thing. It is also the one that needs a fixture per
2313
- diagnostic, so it is not free the way the regions are.
2314
- *The mirror rule still caps what can be claimed on the Chinese side.*
2315
- `docs/zh/**` matches `docs/en/**` down to the comments inside its code samples,
2316
- so byte-equality holds on one side only and the zh blocks stay translated prose
2317
- around the same code the parity gate in `[0.4.3]` is what covers them now,
2318
- and it covers structure, not meaning. Unchanged from the whole-file half: the residual
2319
- risk recorded with it in `[0.4.3]` that the gate and the run proving the
2320
- fixture still works live in different suites applies here identically.
3025
+ losing the argument would cost more than losing the task. The one entry left
3026
+ below came out of reading another repository against this one `msw`.
3027
+
3028
+ Eleven entries have left by being built: three together in `[0.6.0]`, and six
3029
+ under `[0.7.0]` the verdict predicates, the nested `describe`, the runner
3030
+ the static commands loaded and could not start, the diagnosis for a spec file
3031
+ that failed to load, the six comments describing a property the code lacked, and
3032
+ the sketches now bound to the files they name which are the first to leave by
3033
+ being **read** rather than by being reported or gated. The last of those left
3034
+ from `Under consideration` rather than from here, which is the move this file
3035
+ describes as going up before going out, and it went up on the strength of one
3036
+ thing: of its three candidates, exactly one could be measured against instances
3037
+ that had already happened.
3038
+ The second of those left *partially*: one of its three parts came back here as an
3039
+ entry of its own, because implementing the other two showed the estimate on it
3040
+ was wrong. An entry leaving is not evidence that all of it shipped, and this is
3041
+ the first time that has mattered. That returned part has now shipped as well, and
3042
+ it closed the way it said it would: the estimate was wrong because the fix was a
3043
+ new `Issue` code rather than a suppression, and what made it so was a decision
3044
+ recorded three lines from where the suppression would have gone. **The thing that
3045
+ cost the most was the thing already written down** — which is an argument for
3046
+ this section, since the note that corrected the estimate survived only because
3047
+ someone had filed the reasoning next to the code.
3048
+ `compiler-unsupported` came from building something queued here,
3049
+ which is one way an entry arrives: a gate that finds a defect on its first run
3050
+ files the defect, and the `typescript` matrix `[0.4.2]` added is what found this
3051
+ one.
3052
+ `--apply` came from an adoption report (`mine-capablanca`, 0.4.2, 34 requirements
3053
+ / 56 scenarios / greenfield) a third way an entry arrives: not a defect, but a
3054
+ shape the tool made an adopter work around eight times without ever erroring. It
3055
+ took `spec-in-change-dir` out with it, by a fourth way worth naming: designing one
3056
+ of `--apply`'s checks surfaced a different one, against a migration this file had
3057
+ itself shipped. **What a release costs its adopters is not fully known on the day
3058
+ it is cut** — and building the entry is what finds out, which is also how
3059
+ `--apply` discovered that one clause of its own argument here was wrong. Both are
3060
+ recorded under `[0.6.0]`, and two earlier entries from the same report left
3061
+ by shipping in `[0.5.0]` — a change's specs moving to their merged location, and
3062
+ the delta reading as the registry of what it adds — their arguments recorded
3063
+ there, including the part of the first one that turned out to be wrong.
3064
+ The entries added after `[0.6.0]` arrived a **fifth** way, and it is the
3065
+ one with no external input at all: a strict read of `src/` with nothing failing.
3066
+ Every gate in this repository was green while all three were true, which is the
3067
+ whole of what that way is for a defect no test is shaped to ask about is
3068
+ reachable only by someone reading for it. **All three have now shipped under
3069
+ `[0.7.0]`**, and each closed having found something a strict read is
3070
+ apparently good for and a gate is not. The runner one: the gate that would have
3071
+ had to catch it *already* looked for the shape, so what the read bought was not a
3072
+ missing gate but a cost nobody had priced. The load-failure diagnosis: every gate
3073
+ was green because the report was *complete* three ERRORs on a red run — and
3074
+ what was wrong was which three, a judgement no assertion in this repository is
3075
+ shaped to make. The third, eight items under one heading: six comments naming a
3076
+ property the code lacked and two costs beside them.
3077
+
3078
+ **One entry has now left this section the other way by being rejected** — and
3079
+ it is the first, which is why it is worth a line. The region-marked samples were
3080
+ decided *yes*, and building the count they rested on is what killed them: the two
3081
+ spans the entry named are not spans, and both carry a mark the entry's own rule
3082
+ excludes. It moved down with that measurement attached. The lesson is not about
3083
+ regions. **An entry can sit in `Planned` for releases on the strength of a figure
3084
+ nobody has checked**, because the section's discipline is aimed at keeping the
3085
+ *argument* rather than at re-testing the evidence under it so the first act of
3086
+ building one should be re-measuring what it claims, not implementing what it
3087
+ proposes. That is what happened here, and it cost an hour instead of a mechanism.
3088
+
3089
+ *What the fifth way cost and returned, now that all of it is spent.* Sixteen
3090
+ items, no external input, and nothing in the suite shaped to catch any of them.
3091
+ The returns were not evenly distributed and the split is the useful part: two
3092
+ defects a user could hit today (a report that misdiagnosed a load failure, a
3093
+ merge crossing midnight), two costs worth a second each on a real command
3094
+ (`check --eval` at 1 + N Vite servers, `control` at 27–84x), one fixed toll on
3095
+ every static invocation (~1 s), and the rest true-but-unobservable — identical
3096
+ output either way. Only the last group is genuinely optional, and it is also the
3097
+ group that would never have been found any other way, because *no observable
3098
+ symptom* is precisely the definition of what a gate cannot see. The transferable
3099
+ lesson is the batching: individually none of the unobservable ones justifies a
3100
+ commit, and together they cost about an hour.
3101
+
3102
+ - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3103
+ showing input the engine must *reject* can be bound the way a runnable one is,
3104
+ to a fixture whose rejection is the thing under test — which is what
3105
+ `fixtures/eval-invalid-registry` already is. This is the more valuable half of
3106
+ what was once one entry with the region-marking below, because a counterexample
3107
+ that quietly stopped being a counterexample is the one kind of stale sample
3108
+ that actively teaches the wrong thing: a reader shown "this fails" about input
3109
+ the engine now accepts is worse off than one shown nothing. It is also the
3110
+ expensive half, needing a fixture per diagnostic, which is why it was never the
3111
+ free part of that entry and is now the only part left in it.
3112
+ *The mirror rule caps what can be claimed on the Chinese side.* `docs/zh/**`
3113
+ matches `docs/en/**` down to the comments inside its code samples, so
3114
+ byte-equality holds on one side only and the zh blocks stay translated prose
3115
+ around the same code — the parity gate in `[0.4.3]` covers them, and it covers
3116
+ structure, not meaning. The residual risk recorded with the whole-file half in
3117
+ `[0.4.3]` — that the gate and the run proving the fixture still works live in
3118
+ different suites — applies here identically.
2321
3119
 
2322
3120
  ## Under consideration
2323
3121
 
@@ -2331,6 +3129,41 @@ deletion. What each entry has to carry is the evidence currently blocking the
2331
3129
  decision, because that is what a re-proposal a year from now would otherwise
2332
3130
  have to gather again.
2333
3131
 
3132
+ - **A static sweep for the two properties claimed over the whole of `src/`.**
3133
+ The quantifier table's first strict read left three rows wanting the same
3134
+ thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
3135
+ a sweep over the import graph asserting a cross-cutting property no runtime
3136
+ test can see, written for the same reason — "only the graph shows it".
3137
+ *The two candidates, and they are not equally shippable.* `ATX-15` is the easy
3138
+ one: **no `.sort` outside `byCodeUnit`, and no `localeCompare`.** Measured over
3139
+ `src/` today — 21 `.sort` call sites, every one reaching `byCodeUnit`, but only
3140
+ 19 naming it on the call line: `render.ts` goes through its own `compareIds`,
3141
+ and `merge.ts` through a comparator that spans two lines. The only four
3142
+ occurrences of `localeCompare` are comments explaining why it is not used. A sweep would have **zero false positives on the current corpus**,
3143
+ which is the bar `divergent-param` failed and `compound-requirement` cleared —
3144
+ and those two exceptions are already the warning that this needs the AST rather
3145
+ than a line-wise regex, plus a rule for a comparator that delegates. `ATX-37` is the hard one: what it needs is "every project-derived
3146
+ string interpolated into a terminal line goes through `inline`", and that is
3147
+ not a token scan. The shape that would work is a scenario driving every
3148
+ exported `format*` in `src/cli/report.ts` with hostile input — a real roster,
3149
+ since the module's exports are what a new formatter joins — but each takes a
3150
+ different argument type, so it needs a hostile fixture per signature rather
3151
+ than one loop.
3152
+ *What blocks the decision.* Two things, one per candidate. The `ATX-15` sweep
3153
+ is cheap and precise and would be **the first rule in this repository that
3154
+ polices how `src/` is written rather than what it does** — a lint rule wearing
3155
+ a scenario's clothes, and the question is whether that belongs in `self/` (as
3156
+ an obligation `ATX-15` already carries) or in `tests/` beside the import
3157
+ boundary. Filing it under `ATX-15` makes it a permanent obligation with a
3158
+ scenario; filing it in `tests/` makes it deletable in a green diff, which
3159
+ `ATX-57`'s rationale argues against in as many words. The `ATX-37` roster is
3160
+ blocked on cost rather than principle: eight formatters, eight fixtures, and
3161
+ the honest count of what it buys is three currently-correct sites.
3162
+ *Why neither is urgent.* Both properties hold everywhere today, verified by
3163
+ reading. What is missing is not correctness but the thing that keeps it — and
3164
+ the measurement above is what a re-proposal would otherwise have to gather
3165
+ again.
3166
+
2334
3167
  - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
2335
3168
  project with no concurrency limit: `findFiles` recurses through
2336
3169
  `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
@@ -2435,79 +3268,125 @@ have to gather again.
2435
3268
  here rather than moving to `Considered and rejected`: documenting a trap is
2436
3269
  not the same as deciding not to detect it.
2437
3270
 
2438
- - **`attest archive <change> --apply`, finishing the merge the gate approved.**
2439
- `mine-capablanca`'s single loudest item: eight changes, eight hand-written
2440
- Python scripts, and the report quotes one it locates the delta's block by
2441
- `split('added: {', 1)`, trims two spaces of indent per line, and re-terminates
2442
- the registry on `assert t.endswith('});')`. After the gate goes green the
2443
- adopter hand-splices the delta into the registry, moves specs into the suite,
2444
- rewrites their imports, moves the folder to `archive/` and re-renders `SPEC.md`.
2445
- **The cost is the smaller half of the argument. The hole is that the gate's
2446
- verdict does not cover the step that acts on it** `archive` proves the delta
2447
- is green, covered and drift-free, and then a human transcribes it by hand with
2448
- nothing checking the transcription. A delta can be spliced in wrong, partially,
2449
- or twice, and the next `check` sees only whatever ended up in the file.
2450
- **The objection that has to be answered is the `AGENTS.md` block under
2451
- `Considered and rejected`.**
2452
- The `AGENTS.md` block was rejected because "that is a merge tool, and each of
2453
- its failure modes is destructive on a file the user cannot regenerate". The
2454
- argument for why it does not transfer, which needs to be right before this
2455
- ships: there, Attest would edit prose whose grammar it does not own; here the
2456
- registry is a **literal** Attest defines, statically parseable, with a
2457
- differential suite already asserting two readers agree on it so the result of
2458
- a splice is checkable by re-reading it, and the delta was validated by the gate
2459
- that just ran. Idempotence is verifiable rather than assumed. That reasoning
2460
- covers delta→registry, the folder move and the re-render. It used **not** to
2461
- cover import rewriting, which is editing arbitrary TypeScript and is squarely
2462
- the rejected shape.
2463
- *That blocker is gone, and it was removed rather than solved.* Proposed specs
2464
- now live at their merged location under `*.proposed.spec.ts` (`[0.5.0]`), so
2465
- the merge step for a spec is a rename in place — there are no specifiers to
2466
- rewrite, and `--apply` no longer inherits the one part of the job that could
2467
- not be made safe. What is left for it is the delta→registry splice, the rename,
2468
- the folder move to `archive/` and the re-render, all of which the paragraph
2469
- above argues are checkable by re-reading the result.
2470
- *What still blocks the decision:* two questions with no answer yet what
2471
- `--apply` does when the working tree is dirty (`archive` already has a position
2472
- on this; whether a file-mutating flag takes the same one is not obvious), and
2473
- whether a partially applied merge is recoverable or whether the flag must be
2474
- all-or-nothing against a scratch copy.
2475
- *The frequency blocker has moved.* This entry used to close on "one report, one
2476
- repo: the cost evidence is strong and the frequency evidence is a single
2477
- adopter." It is now two. This project's own maintainer proposed the same command
2478
- independently, without the entry in front of them, having felt the same step by
2479
- hand which is the second data point that clause was waiting for. Not
2480
- conclusive about frequency in the field, and it does not touch either question
2481
- above, but it removes the reason to keep waiting for someone else to ask.
2482
-
2483
- *A narrower shape was proposed with it and is rejected — `attest merge <change>`,
2484
- moving the change folder to `archive/<date>-<name>/` and nothing else.* Three
2485
- reasons, and the third is the one that decides it.
2486
- **(1) It targets the cheap half.** The expensive step in the report is the
2487
- delta→registry splice the quoted Python locates the block by
2488
- `split('added: {', 1)`, trims two spaces per line, and re-terminates on
2489
- `assert t.endswith('});')`. The folder move is `mv`. A new command that saves a
2490
- shell invocation and leaves the TypeScript-literal surgery is the wrong half to
2491
- automate. The date it would stamp is real ergonomics but small: `archive/` is
2492
- named in three places and all three are exclusions (`locate.ts`'s SKIP_DIRS,
2493
- and the two exclude globs), so nothing reads the directory and a mis-stamped
2494
- date has no mechanical consequence at all.
2495
- **(2) It would make two command names mislead instead of one.** `attest archive`
2496
- archives nothing today it is purely the gate. Adding a second verb that does
2497
- the archiving leaves `archive` still lying and `merge` doing its job; `--apply`
2498
- goes the other way and makes the existing verb finally mean what it says.
2499
- **(3) A move that does not re-run the gate can file an unfinished change as
2500
- done.** That is the one thing this tool must not ship: the whole claim is that
2501
- "done" has a hard definition, and a command able to mark completion around that
2502
- definition removes it. The alternative is that `merge` re-runs the gate — at
2503
- which point it *is* `--apply` and should be spelled that way. There is no third
2504
- option, which is why this is a rejection of the shape rather than of the idea.
2505
- *Recorded here rather than under `Considered and rejected`,* because nothing
2506
- was decided against: the idea is this entry, and only the narrow spelling of it
2507
- died. Also worth stating because it needed no release to ride a new command
2508
- changes no exit code, rejects no previously-valid input, and removes no API, so
2509
- it is additive under this file's own Versioning rule and was never blocked on a
2510
- version bump.
3271
+ - **Whether `check` and `verify` should refuse a half-loaded registry the way
3272
+ `cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
3273
+ and the argument is recorded at both call sites: a report computed from a
3274
+ half-loaded registry is a lie, and a lie that reads as a pass is the worst
3275
+ shape it can take — `cover` in particular used to answer `ok: true` on a repo
3276
+ whose intent layer had stopped being readable, because an empty registry is
3277
+ fully covered. `check` and `verify` carry on regardless.
3278
+ *What that produces, measured* on a project with one unreadable `*.reqs.ts`:
3279
+ a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
3280
+ that file's requirements, plus `empty-spec`, `tests-red` and a
3281
+ `declared-not-run` per scenario six derived findings from one cause. Worse,
3282
+ `orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
3283
+ for an id already sitting in the registry that failed to load, so the loudest
3284
+ advice in the report points at work that must not be done. In a multi-file
3285
+ registry it scales with the broken file's scenario count, and the one true
3286
+ finding is at the top where a wall of red pushes it out of view.
3287
+ *Why it is not simply the same call as `cover` and `render`.* Each of those
3288
+ produces exactly **one** artifact, and refusing to produce it is the whole of
3289
+ the decision. `check` is the command a pipeline runs first and its contract is
3290
+ breadth returning three findings instead of nine because one of the nine was
3291
+ a load failure makes it quieter about problems it can still see perfectly well,
3292
+ which is the opposite of what it is for. `verify` has the same shape with a run
3293
+ attached.
3294
+ *What blocks the decision.* The narrower move keep reporting everything
3295
+ except the findings **derived from** the missing registry, i.e. suppress
3296
+ `orphan-test` for ids whose prefix belongs to a file that failed to load —
3297
+ needs a prefix→file mapping for a file that never parsed, and that is exactly
3298
+ the file `loadRegistry` has no ids from. Recovering the mapping means reading
3299
+ the prefix out of something other than the registry's own keys (the file name,
3300
+ or a partial AST read that survives the failure), and whether either is
3301
+ trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
3302
+ weaker signal "some registry file failed to load, so suppress all
3303
+ `orphan-test`" is the other candidate and is a strictly worse `check` on a
3304
+ multi-file registry, where the other files are fine and their orphans are real.
3305
+ **The wrong *advice* has been separated out and fixed; the noise has not.**
3306
+ `[0.7.0]` changes `orphan-test`'s fix hint when any registry file failed
3307
+ to load, so the loudest line in that report stops telling the reader to add an
3308
+ id that already exists. That needed no mapping, because it claims nothing about
3309
+ *which* orphans came from the broken file only that the command cannot tell,
3310
+ and what order to work in. It is worth separating because the two halves were
3311
+ filed as one problem and are not: **being wrong** was fixable today at the cost
3312
+ of a sentence, and **being loud** is the part still blocked on the mapping
3313
+ above. The entry stays open on exactly that, with one blocker retired: the
3314
+ measured six derived findings from one cause are still six, and only one of
3315
+ them has stopped pointing at work that must not be done.
3316
+
3317
+ - **A statement that quantifies over a surface, with scenarios covering only the
3318
+ part that existed when it was written.** Found by reading, and it had already
3319
+ cost something: `ATX-37` says "**everything** the CLI writes to a terminal
3320
+ a report, and the stack of a crash MUST replace every control character",
3321
+ and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
3322
+ the two functions that existed when it was written. `render` builds a document
3323
+ by concatenation that never went past the sanitiser, and a registry could drive
3324
+ the reviewer's terminal from `attest render` with no flag at all shipped
3325
+ under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
3326
+ specific to that requirement.
3327
+ The shape: a universal quantifier in the statement, a finite set of scenarios,
3328
+ and nothing anywhere that holds the two together. It reads as covered the id
3329
+ has scenarios, `cover` is green, `check` is green and the quantifier is the
3330
+ part no gate is looking at. Every one of the three mechanisms this project
3331
+ sells operates *below* it: coverage asks whether an id has a scenario,
3332
+ never-red asks whether that scenario discriminated, drift asks whether it reads
3333
+ the params. None asks whether the scenarios span what the sentence claims.
3334
+ *What blocks the decision.* Detecting it means knowing which surface a
3335
+ statement names, which is prose judgement the same judgement
3336
+ `tests/intent-rule-candidates.spec.ts` records as the reason obligation
3337
+ counting can never be an engine rule ("asserting a ground truth Attest cannot
3338
+ compute for someone else's requirements, which is exactly what §0 refuses").
3339
+ So the shippable form may not exist.
3340
+ **The repo half has shipped, and this entry is now only about the engine
3341
+ half.** `[0.7.0]` adds the `QUANTIFIED` table it described hand-judged,
3342
+ a row per flagged statement, every named scenario asserted to exist and it
3343
+ closed the way an entry built from a strict read is supposed to: it found a
3344
+ live hole on `ATX-62`, the one requirement written specifically to avoid this
3345
+ shape, where the archive gate path was claimed by the statement and asserted by
3346
+ nothing. Two things came back that the entry did not predict. The useful one is
3347
+ that "names a surface" splits three ways rather than two — `values`, `roster`,
3348
+ `sites` — and only `sites` carries the risk, because a `roster` scenario
3349
+ iterates the set the code owns and grows with it. That is a sharper statement
3350
+ of the defect than "the scenarios do not span the sentence", and it is a rule
3351
+ an author can act on: **prefer a quantifier a scenario can iterate.** The other
3352
+ is that the remedy — rewriting the statement to name its surfaces, as `ATX-62`
3353
+ did — takes it out of the flagged set, so the table has to allow rows for ids
3354
+ the detector does not flag or it loses the binding exactly when the author does
3355
+ the right thing.
3356
+ *What stays open is the engine half, and nothing above touches it.* The three
3357
+ kinds are prose judgement, the detector misses the article form ("A registry
3358
+ that cannot be read MUST …") which is a universal too, and no rule can tell
3359
+ which surface a sentence claims without being told. The repo half is a
3360
+ counter-pressure, not a detector, and it holds for one registry whose authors
3361
+ agreed to maintain it.
3362
+
3363
+ - **Whether a requirement can ever be retired, and what it costs that none can.**
3364
+ The registry is at 62 requirements and 160 scenarios, roughly 2.6 each, and
3365
+ every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
3366
+ requirement without a scenario cannot exist, and nothing ever removes one.
3367
+ `delta.removed` exists and `applyDelta` applies it, but `--apply` refuses to
3368
+ write it back, deliberately and with the reason recorded — REMOVED "cannot say
3369
+ which comments belonged to the entry it deletes". So retiring one is a manual
3370
+ registry edit, which is fine as a mechanism and says nothing about whether it
3371
+ is ever the right move.
3372
+ *What the growth actually costs, measured on this session rather than
3373
+ estimated.* One small fix took: a requirement, its scenarios, a hand-judged
3374
+ row in the `OBLIGATIONS` table, two inline snapshots, a `[Unreleased]` entry,
3375
+ History rows in two languages, and a regenerated `SPEC.md`. That is
3376
+ proportionate for a spec framework that has to dogfood — the ceremony *is* the
3377
+ product being used on itself — and it is also a number that only goes up.
3378
+ *What blocks the decision, and it is not the mechanism.* It is that "retire"
3379
+ has at least three meanings this file has never had to separate: a requirement
3380
+ that turned out **wrong** (which should leave a record of being wrong, and
3381
+ `Considered and rejected` is where that already goes); one still **true** but
3382
+ no longer worth a permanently-run scenario; and one **subsumed** by a later
3383
+ requirement, where the scenario should move rather than go. Only the second is
3384
+ really about cost, and it is the one where deleting the intent to save the
3385
+ scenario inverts what the whole tool claims — that intent outlives its tests.
3386
+ Nothing here is urgent while the number is 61. It is filed now because the
3387
+ moment to write the argument down is while the reasoning is available, and
3388
+ because a project whose requirement count only rises should have decided in
3389
+ advance what would make one leave.
2511
3390
 
2512
3391
  ## Considered and rejected
2513
3392
 
@@ -2519,6 +3398,45 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
2519
3398
  sinking one version deeper each time a release was cut above it, and a rejection
2520
3399
  filed under a version reads as belonging to it.
2521
3400
 
3401
+ ### Region-marked samples, so a partial quote could be bound too
3402
+
3403
+ Decided **yes** and filed under `Planned`, then killed by building the count it
3404
+ rested on. The whole-file half shipped in `[0.4.3]` — the README's two
3405
+ getting-started blocks are byte-equal to `fixtures/consumer/` — and the proposal
3406
+ was to raise that with `// #region readme:registry` markers inside the fixture,
3407
+ asserting a doc block against the dedented span rather than the whole file. The
3408
+ mechanism is sound and cheap. What it has no supply of is spans to bind.
3409
+
3410
+ **The entry named its own two candidates, and both fail on inspection.** It said
3411
+ `troubleshooting.md`'s single-source pair is "two verbatim lines of
3412
+ `fixtures/consumer/session.spec.ts`": they are lines 8 and 11, so no span
3413
+ contains them, and line 8 reads `// single source, typed \`30\` — no cast` in the
3414
+ fixture against `// ✓ the single source` on the page. It said the `AUTH-3` block
3415
+ is "the registry entry at a different indent": the fixture wraps `statement:`
3416
+ onto a second line and the page does not, and the page's comment carries a `✓`
3417
+ the fixture has no reason to. **Both candidates carry a `✓`, which the same entry
3418
+ excludes by rule** — "anything carrying a `✓`/`✗` is authored commentary and
3419
+ stays a fragment". The entry's estimate contradicted the entry's own rule, and
3420
+ neither half was checked against the files until now.
3421
+
3422
+ Swept the rest rather than stopping at the two: of the 23 fenced `ts` blocks in
3423
+ the README and `docs/en/**`, 2 are already bound whole, 3 are `src/` sketches the
3424
+ module-scope check in `[0.7.0]` covers, and the remaining 18 are elisions
3425
+ (`{ /* … */ }`), `✓`/`✗` fragments, input the engine must reject, or teaching
3426
+ sketches of the *reader's* project carrying inline commentary that is the point
3427
+ of them. **Zero spans.** So building it would ship a gate binding nothing, which
3428
+ this project treats as worse than the comment recording it — the argument §11
3429
+ makes and `ATX-43` and `ATX-44` both decline a promise on.
3430
+
3431
+ What that leaves is the honest general form: **the reach of a byte-equality gate
3432
+ over documentation is set by how the documentation is written, not by the
3433
+ granularity the gate can address.** Raising it would mean rewriting the pages to
3434
+ be quotable, which is the tail wagging the dog — those `✓`/`✗` annotations and
3435
+ elisions are what makes a sample teach. The counterexample half was never
3436
+ dependent on this and stays under `Planned` as its own entry. If a passage is
3437
+ ever written that *is* a verbatim span of a fixture, this is three lines of
3438
+ `fencedBlocks` away and the reasoning above is what to re-read first.
3439
+
2522
3440
  ### `typescript` as a peer dependency instead of a bundled one
2523
3441
 
2524
3442
  The open question the matrix entry was queued to settle (see `[0.4.2]`), and
@@ -2829,7 +3747,9 @@ The permanent half goes to design §11 with the params rule, not here.
2829
3747
  adopter owes their users, whose rows a reviewer would want in the rendered spec.
2830
3748
  None has been seen; the one that raised it was tuning.
2831
3749
 
2832
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.5.0...main
3750
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.0...main
3751
+ [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
3752
+ [0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
2833
3753
  [0.5.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.5.0
2834
3754
  [0.4.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.3
2835
3755
  [0.4.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.2