@am_shork/attest 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,727 @@ 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.1] - 2026-08-05
17
+
18
+ ### Added
19
+
20
+ - **`params` takes any JSON value, and the constraint moved to the place it was
21
+ actually protecting.** A param was a scalar or a list of scalars. The argument
22
+ for admitting lists — the values most likely to drift are the composite ones,
23
+ and rejecting them left exactly those outside the one mechanism built to pin
24
+ them — does not stop at a list: a `kind -> payload` table drifts harder than a
25
+ flat list of names, and it was the one shape still excluded. `null` comes with
26
+ it, because refusing it forces a sentinel (`''`, `-1`) that means something
27
+ else to every later reader.
28
+ What the old union was defending was not the registry but the **rendering**:
29
+ `{payloadKinds}` interpolated into a statement as `[object Object]`, in a
30
+ document reviewers and audit read as the system's promise. That is a property
31
+ of the interpolation point, so it is stated there — a new
32
+ **`non-scalar-interpolation`** ERROR from `check` and `verify`. Narrower and
33
+ truer: it refuses what actually breaks and admits the data a scenario reads,
34
+ which the schema rule could not tell apart. What the schema keeps is what does
35
+ not survive being written down — a function, a `Date`, a class instance, an
36
+ object whose prototype was swapped.
37
+ *The part that is not a schema edit.* Depth. Four obligations had exactly one
38
+ level of reach, which is correct only while a param cannot nest, and none of
39
+ them fails loudly: the canonical form behind `add-conflict` (an identical copy
40
+ written with its inner keys in another order would have conflicted with
41
+ itself), the ATX-58 control-character stripping (nested strings would have
42
+ gone unsanitised, reopening the hole `[0.7.0]` closed), the key order behind a
43
+ byte-stable rendering, and `splice.ts`, which writes a requirement back out as
44
+ TypeScript source and would have committed `[object Object]` into a
45
+ `*.reqs.ts` through `archive --apply`. Three of the four are ordering, and are
46
+ now one shared `sortDeep` in `order.ts` rather than three sorts a level deep —
47
+ they wanted the same guarantee for the same reason, which is what makes it one
48
+ function and not a helper each.
49
+ *Two divergences between the readers, which is what the differential suite is
50
+ for.* `null` is a keyword rather than a literal node, so the AST reader fell
51
+ off the end of `literalValue` and reported `registry-not-static` for the whole
52
+ file while the evaluating reader accepted it. And `{ __proto__: … }` swaps the
53
+ prototype instead of creating a key, so `z.record` copies own keys into a fresh
54
+ object and the taint is invisible one step later — the static reader refuses
55
+ that source at any depth, and the schema now refuses the object the evaluator
56
+ builds from it, both spellings (a literal `__proto__:` and the own key
57
+ `JSON.parse` produces).
58
+ *Rendering.* A structured param gets a fenced JSON block below the params
59
+ table, keys sorted at every depth (ATX-10: the same registry renders the same
60
+ bytes) with the fence measured against backtick runs in the value, as `code()`
61
+ already did. It is below the table rather than in it because a fence cannot
62
+ live in a table cell. And `render` defends the document on its own: it reads
63
+ the registry with no spec parse, so no `AttestPlan` and no `validateStructure`
64
+ — `attest render` runs to completion on a registry `check` refuses. Leaning on
65
+ the diagnostic would have left the document defended by a command nobody is
66
+ obliged to run first.
67
+ *The writing side of the `__proto__` rule.* `splice.ts` turns a requirement
68
+ back into TypeScript source for `--apply`, and it is the only site that does.
69
+ Extending it to depth meant it would have emitted a `__proto__` key as a bare
70
+ property — text whose *evaluation* swaps a prototype rather than being the
71
+ value it was handed. Unreachable through any command (the schema rejects a
72
+ nested `__proto__` and `z.record` drops a top-level one), and refused anyway
73
+ with a new `UnwritableValue`, reported as `internal-error` by the merge with
74
+ its account of what it had already written intact. Quoting is not the repair —
75
+ a quoted `__proto__` swaps the prototype in a literal exactly as the bare form
76
+ does, and the bracketed form that would create an own property is a computed
77
+ key no static reader will read. The reader's guard is worth nothing if the
78
+ writer can produce the file it exists to refuse.
79
+ *Not breaking, with one edge worth naming.* No previously-green registry goes
80
+ red — a structured param was `registry-invalid` before, so nothing that
81
+ compiled and passed now fails — and an all-scalar registry renders
82
+ byte-identical, so a committed `render --check` stays green across the
83
+ upgrade. What did widen is the exported type in a **read** position:
84
+ `Requirement['params'][string]` is now the recursive JSON value. The const
85
+ generic is untouched, so `reqs['AUTH-3'].params.idleTimeoutMin` is still the
86
+ literal `30` and every anti-drift test keeps its narrow type. A consumer only
87
+ notices if they assign an *undeclared* key to the old scalar-or-list union
88
+ (`Type 'null' is not assignable`), or wrote their own handler over that type
89
+ and does `String(v)` in it — which is the `[object Object]` defect this entry
90
+ fixes in `render` and `splice`, seen from outside.
91
+ *This overturns a standing rejection, and not on its stated terms.* *Nested
92
+ objects in `params`* sat under **Considered and rejected** since `[0.4.2]`,
93
+ raised by `mine-capablanca`, and that entry is removed by this release. Its
94
+ argument was that a param has two properties a table lacks: rendered as a value
95
+ a human checks at a glance, and read by a scenario as the one place the value
96
+ lives. The first is answered rather than waived — `non-scalar-interpolation`
97
+ keeps a table out of the *sentence*, so what a reader takes in at a glance is
98
+ still glance-sized, and the table is rendered below it as a block. The second
99
+ is answered by the criterion that admitted arrays in the first place: a
100
+ `kind -> payload` table drifts *harder* than a flat list, so the reason arrays
101
+ were let in is the reason this shape should have been.
102
+ **The reopening condition it named was never met, and that is worth stating
103
+ plainly.** The entry said it reopens on "a report where the value is genuinely
104
+ promised — a table an adopter owes their users"; none has been seen. This
105
+ shipped on the strength of the argument alone. What changed is the recognition
106
+ that the schema was answering the wrong question: *params for promises, plain
107
+ constants for tuning* is the rule, and a type can only ever check shape, so it
108
+ refused a promised table while admitting a list of tuning knobs. Design §11
109
+ now carries that as a judgement rather than a type, which is the permanent half
110
+ — and it is weaker than what it replaces, because nothing enforces a judgement.
111
+ New requirements: `ATX-7` rewritten, `ATX-63` added.
112
+
113
+ ### Changed
114
+
115
+ - **Rationale that only a comment held now has a design section, and the comment
116
+ cites it.** Measured over the diffs in three eras split at `[0.3.0]` and
117
+ `[0.5.0]`: comment blocks got better by every specificity signal — naming a
118
+ symbol 50 → 60%, a measured number 3 → 13%, a causal argument 26 → 54% — and
119
+ roughly doubled per line of code, while the share citing a `§` went 15 → 12 →
120
+ 11% and the design document stopped growing (sixteen sections since `[0.3.0]`,
121
+ 105 lines against `src/`'s 2,862). **The anchoring did not fall; it never
122
+ scaled**, and a `§` is the only part of a comment any gate reads.
123
+ Nineteen blocks written since `[0.5.0]` carried the cost — ten lines or more,
124
+ fully argued, unanchored — and they split three ways. *Six were properties a
125
+ rewrite would have to preserve*, and now sit in the document: a new **§9.1**
126
+ for the artifacts Attest writes, an extension to **§8** (`--apply` acts on this
127
+ run's verdict), and one to **§5.4** (the two readers of one plan must descend
128
+ the same way). *Four were history* — a quadratic backtrack, a 27–84x constant
129
+ factor, a 47 MiB peak, an indexing cost filed as invisible — and were
130
+ compressed to the invariant a maintainer would otherwise re-break; every
131
+ measurement in them was already under `[0.7.0]`, so this deleted duplicates
132
+ rather than moving anything. *Ten were correct as they stood and were left
133
+ alone*, which is the half worth naming: `§` is low because the design document
134
+ stalled, not because comments are too long, so a pass that shortened comments
135
+ and left it at one in nine would have moved the wrong quantity and called it
136
+ done. Net `src/`: 29 lines removed, all of them comments and no code touched,
137
+ which is also how the deletion was checked.
138
+ **One of the six corrected the document rather than extending it.** §8
139
+ summarised a merge as renaming a proposed spec "in place", which is true of its
140
+ location and not of its imports — the delta it reads its proposed params from
141
+ is what moves to `archive/`, so the sentence hid the one specifier the merge
142
+ has to repoint.
143
+ *What the measurement cost.* A first pass reported the `§` share as halving,
144
+ 25 → 12%, an artefact of bucketing by `git blame` over the current tree: that
145
+ sees only surviving lines, and `§`-citing comments survive better, being tied
146
+ to sections that do not move, so the early era was measured on its most durable
147
+ residue. Re-measuring from the diffs before building is what caught it.
148
+ The routing test this produced is in `pre-commit-check` §6, and the obligation
149
+ a new diagnostic code owes the CLI reference is now in §2.
150
+
151
+ ## [0.7.0] - 2026-08-04
152
+
153
+ ### Added
154
+
155
+ - **A universally quantified statement now has to say what it quantifies over,
156
+ and the answer is checked.** `tests/intent-rule-candidates.spec.ts` gains a
157
+ `QUANTIFIED` table beside `OBLIGATIONS`: every statement carrying an open
158
+ quantifier is hand-judged into one of three kinds, and a statement flagged with
159
+ no row fails. 23 of the 62 requirements are flagged today.
160
+ *The shape it exists for.* A universal quantifier in the statement, a finite
161
+ set of scenarios under it, and nothing holding the two together. It reads as
162
+ covered — the id has scenarios, `cover` is green, `check` is green — because
163
+ all three mechanisms this project sells operate *below* the quantifier:
164
+ coverage asks whether an id has a scenario, never-red asks whether that
165
+ scenario discriminated, drift asks whether it reads the params. None asks
166
+ whether the scenarios span what the sentence claims. It had already cost
167
+ something — `ATX-37` said "everything the CLI writes to a terminal" while
168
+ `render` built a document by concatenation that never went past the sanitiser,
169
+ which shipped as a fix earlier under this heading without the *shape* being
170
+ addressed.
171
+ **The classification is the part worth keeping, because it predicts which
172
+ statements carry the risk.** `values` — the quantifier ranges over inputs one
173
+ site processes, so growth cannot escape it, there being one site. `roster` — it
174
+ ranges over a set the code itself enumerates and the scenario iterates that
175
+ same set, so a new member is covered by construction. `sites` — it ranges over
176
+ places in the implementation, discharged by one hand-written scenario each, and
177
+ **this is the only kind that can silently stop being true**. Today 14, 2 and 8
178
+ across 24 rows — one more than the 23 flagged, for the reason two paragraphs
179
+ down.
180
+ `ATX-37` was a `sites` row and `render` was the new site, which is the whole
181
+ argument in one line. The `roster` shape is the one to reach for and the repo
182
+ had already found it once without naming it — `self/targets.spec.ts` says "a
183
+ target added without a scenario would otherwise be a target nothing checks".
184
+ **It found a live hole on its first run, on the requirement written to avoid
185
+ exactly this.** `ATX-62` names its surfaces rather than quantifying over them
186
+ ("both commands that execute the suite — `verify` and the archive gate"), and
187
+ its rationale explains that choice in these words. Of the three sites it names,
188
+ the archive gate path had **no scenario asserting the diagnosis is emitted** —
189
+ only one asserting it *stands down* for a file `added-id-unmerged` already
190
+ claimed, and one asserting the withdrawal it enables. Both negative.
191
+ *Measured by mutation, because the first statement of this was wrong and the
192
+ correction is the useful part.* Deleting the `specLoadFailedIssues` call
193
+ outright does **not** survive: it sits inside `notRunIssues`, which both
194
+ commands share, so the `verify` scenario kills it. The mutant that isolates the
195
+ gate is the one that matters — `evaluateGate` filtering the diagnosis out of
196
+ what it pushes, which is a gate that drops the finding on its own path while
197
+ `verify` keeps it. That is the *exact* historical shape `ATX-62` exists to
198
+ answer, its rationale naming "the rule holding on one path and not the other".
199
+ Against the old spec that mutant runs **green, 0 errors**; against the new one
200
+ it is red. `self/gate.spec.ts` gains the missing scenario, and the run is now
201
+ 62 requirements / 160 scenarios.
202
+ *Two design decisions the table fails without.* **Rows are allowed for ids the
203
+ detector does not flag**, which is why `ATX-62` has one at all: naming the
204
+ surfaces is the *remedy*, and it takes the statement out of the flagged set, so
205
+ a table keyed strictly on the detector would drop the binding at the moment the
206
+ author did the right thing. **Every named scenario is asserted to exist** in
207
+ the specs, by name, for that id — without it the table is one more hand-written
208
+ list that goes on reading as covered after a rename, which is the failure mode
209
+ every other check in that file is written to avoid.
210
+ *And one deliberate gap, carried rather than hidden.* A site the statement
211
+ claims that nothing reaches is recorded as `null` and lands in the snapshot,
212
+ on the same footing as the compound rule's `missed` list. There is one:
213
+ `ATX-53`'s "some of several specs renamed, the rest not" — a state no fixture
214
+ can construct, because every change fixture carries a single spec.
215
+ The detector is the same kind of instrument as `compoundByKeyword` and has the
216
+ same limit, stated rather than tuned away: it catches the declared universal
217
+ and misses the article form ("A registry that cannot be read MUST …"), which is
218
+ a universal too. Two of the 23 rows are the idiom "at all" rather than a
219
+ quantifier, kept as rows because a regex tuned until it stops flagging is the
220
+ measurement bending to the corpus.
221
+ No behaviour change, no new `Issue` code, and no `ATX-n` — repo
222
+ self-discipline, for the reason at the top of that file: knowing which surface
223
+ a sentence names is prose judgement, the ground truth §0 refuses to compute for
224
+ someone else's requirements.
225
+
226
+ - **The eight `sites` rows re-read against `src/`, which is what the table was
227
+ built to make possible.** The rows were hand-judged in one pass, and the value
228
+ of the whole table is concentrated in that kind — so the first use of it is a
229
+ strict read asking one question per row: *is this list of sites complete?* Five
230
+ claimed-but-unattested sites came back, one row was retired, and one was wrong
231
+ in the good direction.
232
+ **`ATX-15` was short by two.** "Every ordering Attest commits to disk" is wider
233
+ than the rendering: `red-record.ts` sorts ids and scenario names into
234
+ `first-run.json`, and `splice.ts` sorts ids and param keys into the registry
235
+ file `--apply` writes. Both are committed and neither is attested. The property
236
+ holds everywhere — every `.sort` in `src/` goes through `byCodeUnit` and no
237
+ `localeCompare` survives — which is precisely the `ATX-37` position: held at
238
+ every site, enumerated at none.
239
+ **`ATX-37` was short by three.** `formatStatus`, `formatCoverage` and
240
+ `formatArchiveVerdict` each interpolate a reqId, a change name or a path taken
241
+ from the project under test, and the statement says *everything* the CLI
242
+ writes. All three call `inline()` today; none is attested. That is the state
243
+ `render` was in on the day `ATX-58` had to be written.
244
+ **`ATX-58` was recorded as weaker than it is, and the correction is the useful
245
+ part.** It was filed as four field-sites carrying `ATX-37`'s growth risk.
246
+ `sanitised()` rebuilds a whole `Requirement` as one typed object literal, so a
247
+ fifth field on that type is a **compile error**, not a silent gap — the axis is
248
+ closed by the compiler rather than by anyone remembering. Five sites collapse
249
+ to two, and this is the strongest of the eight rather than a repeat of the
250
+ weakest.
251
+ **`ATX-53`'s open gap was retired by building it.** The recorded `null` was
252
+ "some of several specs renamed, the rest not", said to be unconstructible
253
+ because every change fixture carries one spec. Constructing it showed the state
254
+ does not exist: a renamed spec is no longer a *proposed* spec, so it leaves
255
+ `claimedSpecs` the moment it is done, and a partial rename re-runs as the state
256
+ already covered with a shorter list. Measured rather than argued — a two-spec
257
+ project with one merged by hand passes against a merge mutated to rename only
258
+ the first claimed spec. So the scenario was **not** kept: it discriminates
259
+ nothing, and design §11 is explicit that a scenario which cannot fail is worse
260
+ than the comment recording why. `self/merge.spec.ts` carries that comment, and
261
+ the two resumption scenarios now share one `spliceByHand` helper instead of two
262
+ copies of the same insertion literal.
263
+ `ATX-40`, `ATX-41` and `ATX-60` came back complete. `ATX-60` with a caveat
264
+ worth its line: every verdict in `src/` goes through `hasError` and the two
265
+ bare spellings left are not verdicts, so the sites are right — but "never by a
266
+ second spelling" is a claim about the whole of `src/` discharged by two
267
+ examples, and nothing sweeps for a fifth.
268
+ *What the read is actually saying, across five of the eight.* The recurring
269
+ answer is **a sweep or a type**. `ATX-58` shows what closed looks like: the
270
+ compiler refuses the new site. `ATX-34` and `ATX-57` show the other closed
271
+ shape: a scenario iterating the roster the code owns. `ATX-15`, `ATX-37` and
272
+ `ATX-60` want one and have neither, which is filed under
273
+ `Under consideration` rather than built here — a rule that fires on correct
274
+ usage is the shape this file has rejected before.
275
+ Bookkeeping only: no behaviour change, no new `Issue` code, and the snapshot
276
+ now carries five unattested sites where it carried one.
277
+
278
+ - **A sketch in the design document is now bound to the file it says it is
279
+ from.** `tests/docs-consistency.spec.ts` takes every fenced `ts` block whose
280
+ first line names a `src/` path — the convention those documents already
281
+ follow — and asserts that every name the block puts at **module scope** still
282
+ exists at module scope in that file. Six blocks today, three per language. The
283
+ scan is over every hand-written page rather than the design document, through
284
+ the same enumeration the other checks use: a sketch is not something only one
285
+ file may hold, and a hardcoded list has already stopped covering a page here
286
+ once, on the day the CLI reference was split out of the README.
287
+ *Why this one, of the three candidates recorded with the entry.* Deleting the
288
+ sketches costs the design document the thing it is for; marking them
289
+ illustrative leaves an argument free to cite state that is gone. The symbol
290
+ check was the only candidate with a hit rate, and the hit rate is now measured
291
+ rather than estimated: replaying the pre-`a5899ac` document against today's
292
+ `src/runtime.ts` reproduces the original failure exactly — `currentReqId`,
293
+ named at module scope by the sketch and nowhere in the file. That replay is
294
+ what turned "would have caught it" into something checked.
295
+ **It found a third instance on its first run**, which is the fourth way this
296
+ file records an entry arriving. §5.4's `runner.ts` sketch declared
297
+ `const REQ_SUITE = /^\[(.+)\]$/` and read it with `task.name.match(...)`; the
298
+ regex moved to `core/req-suite.ts` and `runner.ts` has imported
299
+ `requirementIdOf` from it since. The sketch was not merely stale, it showed the
300
+ design document contradicting the module whose entire reason for existing is
301
+ that *"two spellings of one encoding are two encodings the moment either is
302
+ edited"* — a reader following §5.4 would have written the second spelling. Both
303
+ languages are corrected here.
304
+ *Three scoping decisions, each of which the check fails without.* **Module
305
+ scope, not every identifier the sketch names**: `runtime.ts` explains the
306
+ deleted `currentReqId` in two comments, so a substring scan over the source
307
+ text would have called the stale sketch fine — both sides are reduced to
308
+ declarations, imports and re-exports through the compiler API. **The `src/`
309
+ prefix, not file existence**: existence would make a typo'd path a silently
310
+ skipped block, the failure mode this file guards against everywhere else, so a
311
+ `src/` path that does not resolve fails instead and `requirements/auth.reqs.ts`
312
+ is left alone as what it is, a path in the reader's project. **Fenced blocks,
313
+ not prose**: prose may name a dead symbol legitimately, and §11 now does so on
314
+ purpose, as history.
315
+ **What it does not reach, because the honest count is one of two.** §11's
316
+ ESM-only bullet — the worse of the two instances, since it was an *argument*
317
+ resting on state that no longer existed — is prose, and stays outside. This
318
+ catches the sketch; the reader who comes to fix the sketch is what catches the
319
+ paragraph citing it. A sketch whose module scope survives while its body drifts
320
+ also passes, which is the same limit the byte-equality check already records
321
+ about the prose around a sample.
322
+ No behaviour change, no new `Issue` code, and no `ATX-n`: this is repo
323
+ self-discipline about this repository's own documentation, in the file that
324
+ already holds every other coupling cheap enough to assert.
325
+
326
+ ### Changed
327
+
328
+ - **`parseSpecs` held every spec file in memory at once; it now holds 32.** The
329
+ read was `Promise.all(files.map(readFile))`, so every source in the project
330
+ was resolved before the first one was parsed. Each source is now parsed at the
331
+ point it arrives, through a fixed pool, which makes the peak a constant rather
332
+ than the size of the input — the merged plan is the only thing that still grows
333
+ with the tree, and it is the output.
334
+ *Measured, both before and after.* On a synthetic tree of 6000 spec files the
335
+ old shape held ~47 MiB of source at once at a realistic 8 KiB per file;
336
+ `tests/locate-fanout.spec.ts` counts in-flight reads through the filesystem and
337
+ measures 200 before the change and 32 after, on a 200-file input. That test
338
+ lives in its own file because it mocks a Node builtin, and it reads the
339
+ property at `fs` rather than through `parseSpecs`, which is the honest seam:
340
+ peak in-flight reads is a fact about how the function uses the filesystem.
341
+ *What is deliberately **not** changed, and why the two halves are not one
342
+ decision.* `findFiles` fans out over sibling directories with no limit either,
343
+ and it stays that way. The failure it invites is descriptor exhaustion, and no
344
+ measurement on either development platform could produce one — on Windows, Node
345
+ uses Win32 handles rather than POSIX descriptors, so the shell's `ulimit -n`
346
+ does not govern the process, and the one platform where it is plausible is the
347
+ one this measurement could not reach. That half stays under
348
+ `Under consideration` on this file's own standard: no report, and no
349
+ demonstrated failure anywhere. The memory needed no such evidence — it is
350
+ arithmetic, and it is portable.
351
+ No behaviour change and no `ATX-n`: the plan is byte-identical either way, and
352
+ there is no contract here a consumer could branch on. `check` being the command
353
+ this project tells people to run first on an untrusted fork MR is what makes it
354
+ worth doing anyway — the input size is not ours to assume.
355
+
356
+ - **Every static command loaded the test runner it can never start.**
357
+ `pipeline.ts` imported `createLoader` from `loader.ts` and
358
+ `runAndCollect`/`BASE_EXCLUDE` from `runner.ts` at the top level, and
359
+ `cli/index.ts` imports `pipeline.ts` whole — so `vite`, `vitest/node` and
360
+ `vitest/config` were resolved and evaluated before `commander` had parsed an
361
+ argument. `check`, `cover`, `render`, `status` without `--eval`, and `init`
362
+ call none of them. The three are now reached through `await import()` at the
363
+ four `createLoader` sites and the two runner sites, all of which were already
364
+ in `async` functions.
365
+ *Measured on this repository, before and after, on one machine* — the earlier
366
+ figures recorded with this as a plan were taken in a different session and are
367
+ not comparable, which is the whole reason the baseline was re-taken here rather
368
+ than quoted. Importing `dist/core/pipeline.js`: **2133 ms → 1111 ms**.
369
+ `attest --version` end to end: 1700–2219 ms → 954–1220 ms. `attest check .`:
370
+ 1991–2215 ms → 1300–1478 ms. Roughly a second, on every invocation of a
371
+ command that could not have used it.
372
+ **This is not the core/adapter package split, and does not substitute for
373
+ one.** `tests/import-boundary.spec.ts` states its own gap in its own words — it
374
+ gates **direct** imports, not reachability from the static commands, because
375
+ `pipeline.ts` holds both halves of the engine — and files the answer under the
376
+ split §11 defers as not earned by one package at this size. That deferral
377
+ stands and that gap is unchanged: what moved is *when* the cost is paid, not
378
+ which module holds what.
379
+ **The one property this could have silently dropped was the boundary itself**,
380
+ and it did not: that gate reads `import(...)` and `require(...)` from the AST
381
+ alongside static imports, and has since it was written, precisely so it could
382
+ not be defeated this way. Both specifiers stay relative, so `ALLOWED` is
383
+ untouched and the reachability walk still traverses them. The comment there
384
+ saying dynamic import was unused in `src/` is now false and has been corrected;
385
+ a gate whose commentary describes a codebase that no longer exists is the
386
+ defect this file's *"six places where the source states a guarantee it does not
387
+ provide"* entry is about.
388
+ *What this does not buy, stated so the next reader does not expect it.* The
389
+ larger single item is `typescript` at ~936 ms, and `check`, `cover` and
390
+ `render` genuinely read the AST, so it stays on their real path — visible in
391
+ `--version` still costing ~1 s. Only `init` and `--version`/`--help` pay it for
392
+ nothing, and separating those means splitting `pipeline.ts` rather than moving
393
+ an import. So this closes the avoidable half and leaves the unavoidable one
394
+ measured.
395
+ **No behaviour change and no new requirement.** No command's output, exit code
396
+ or JSON shape moves, and the property worth stating — *a static command must
397
+ not load the runner* — is deliberately **not** filed as an `ATX-n`: it is not
398
+ falsifiable from inside the engine today, for the same reason the import
399
+ boundary gate cannot assert reachability, and a requirement that cannot go red
400
+ is the defect `archive`'s never-red gate exists to catch. It stays repo
401
+ discipline, in the gate that already holds it.
402
+
403
+ ### Fixed
404
+
405
+ - **The workflow `attest init` writes named the special case of a load failure
406
+ and not the general one.** `spec-load-failed` shipped in this cycle, after the
407
+ skill body was last edited, so its stage 2 table listed only
408
+ `added-id-unmerged` — which fires just when the change *also* adds an id the
409
+ registry lacks. An agent hitting a typo in a specifier therefore met an ERROR
410
+ absent from the table it was told to branch on, while the row that used to
411
+ appear, `declared-not-run`, had been withdrawn for exactly that file. The three
412
+ rows now say how they relate: the general case, the specific case that
413
+ supersedes it, and the withdrawal that keeps one file to one finding.
414
+ `ATX-57` could not have caught it. It holds one-directionally — every code the
415
+ workflow names must be emittable — which refuses a misspelling and is silent
416
+ about an omission, and that asymmetry is deliberate: the workflow has no
417
+ business naming every code. The other direction is not a rule anyone can write.
418
+
419
+ - **`check --eval` started a Vite dev server per change, plus one for the
420
+ registry.** `unclaimedProposedSpecIssues` reads every change's delta in a loop,
421
+ and `readDelta` created and closed its own loader on each iteration — so the
422
+ cost was 1 + N servers for a command that needs one. Measured on a project
423
+ with six changes: **7 loaders → 1**, and the command went from ~3.3–3.6 s to
424
+ ~1.7–1.9 s. Both readers now borrow a loader when the caller holds one, and
425
+ `runCheck` owns exactly one for the whole command through a `withLoader`
426
+ helper. Ownership sits at the command because neither reader knows how many
427
+ times it is about to be called.
428
+ Static `check` is unaffected in the way that matters: with no `--eval` it
429
+ passes `undefined` down and starts nothing at all, which is the property that
430
+ command is documented on (design §5.1).
431
+
432
+ - **`terminal.ts`'s `control` allocated three times the input to sanitise it.**
433
+ It spread the string into a per-code-point array, mapped, and rejoined, where
434
+ a character class lets the engine scan and — in the case every real registry
435
+ is — return the input untouched. Measured over 120,000 characters: **27×**
436
+ faster on plain ASCII, **54×** with newlines, **84×** on CJK. A payload that
437
+ is *entirely* control bytes is a wash at 0.9×, since then there is nothing to
438
+ fast-path and both spellings build a new string; both are linear either way,
439
+ so this is a constant factor rather than a second `ATX-59`.
440
+ Worth changing because the call count scales with the registry while the
441
+ length scales with whatever the registry chose: `render`'s `sanitised` calls
442
+ it once per statement, rationale, param key, param value and out-of-scope
443
+ entry, on the same reachable `render --check` path `ATX-59` came off.
444
+ **Byte-identical to the spelling it replaces**, which is the only thing that
445
+ mattered here: verified exhaustively over every code unit in the BMP, and over
446
+ 200,000 randomised strings mixing control bytes, CJK, astral characters and
447
+ lone surrogates — surrogates being the case the spread existed to get right.
448
+ Newlines survive as they always did, by not being in the class.
449
+
450
+ - **Four comments describing a property the code beside them did not have.**
451
+ This repository keeps its decision record in prose, so a comment claiming
452
+ something the code does not do is the same class of defect as a diagnostic
453
+ pointing at the wrong line: it is what the next reader believes *instead of*
454
+ reading. None of these produced a wrong answer today, and each is now true.
455
+ - `merge.ts`'s `archivePath` is documented as "one function because two places
456
+ ask … a date computed twice could straddle midnight and disagree with
457
+ itself", and called `new Date()` on **every** invocation — four times per
458
+ merge. A merge crossing midnight would have refused against one directory
459
+ and written to another. The stamp is now read once in `applyMerge` and
460
+ passed down, which makes `archivePath` a pure function of its arguments —
461
+ the property that turns "the two cannot disagree" into something the code
462
+ holds rather than something the clock does.
463
+ - `pipeline.ts`'s `claimedSpecsOf` is documented as "built from the plan
464
+ rather than by re-parsing, so the files `--apply` touches are exactly the
465
+ files the gate ran", and its caller handed it a fresh `parseSpecs` over
466
+ every proposed spec — the second such parse in that function, with
467
+ `changeMergedPlan` having done the first. The two agreed because the same
468
+ two functions ran twice, which is agreement by transcription: the thing this
469
+ codebase extracts `declaredNotRunIssues` and `uncoveredIssues` to stop
470
+ relying on. `changeMergedPlan` now hands back the claimed half it already
471
+ built, so there is one parse, one split and one answer — and the archive
472
+ path does one less full parse of every proposed spec.
473
+ - `pipeline.ts`'s `runCover` ordered ids with a bare `.sort()` where the rest
474
+ of the engine goes through `byCodeUnit`. The result is identical on ids that
475
+ match the grammar, which is exactly why it was worth changing: `order.ts`
476
+ exists so ordering has one spelling, and that is a property which survives an
477
+ edit where an identical result does not.
478
+ - `status.ts`'s `statusRows` filtered the whole plan once per added id, the
479
+ pattern `validator.ts`'s `detectPotentialDrift` carries a comment about
480
+ having removed — O(requirements × scenarios), and the plan here is the
481
+ *merged* one, so the inner term is the whole repository's scenario count.
482
+ Indexed once now. The cost was genuinely invisible (20 ids against 2000
483
+ scenarios is 40k comparisons, well under a millisecond), so this lands as
484
+ the inconsistency it was rather than as a slow path; scenario order within a
485
+ row is unchanged, still file then line.
486
+
487
+ - **`orphan-test`'s fix hint was wrong for the case that produces the most of
488
+ them.** "Add it to the registry, or fix the id" is right for an id that is
489
+ genuinely absent and **wrong** for one already sitting in a registry file that
490
+ failed to load: the id is there, the fix is the load error, and following the
491
+ advice adds a duplicate. One unreadable `*.reqs.ts` orphans every scenario of
492
+ every requirement it declared, so the wrong advice was also the loudest thing
493
+ in the report, with `registry-invalid` — the one true finding — pushed to the
494
+ top and out of view.
495
+ **The finding stays; only the advice changes**, and that is the whole of the
496
+ decision. Suppressing `orphan-test` needs a prefix→file mapping for a file
497
+ that never parsed, which is exactly the file no ids are available from; the
498
+ weaker signal (some registry file failed to load, so suppress all of them) is
499
+ a strictly worse `check` on a multi-file registry where the other files are
500
+ fine and their orphans are real. Both remain open under *Under consideration*.
501
+ What is shippable today is that a command which cannot tell the two cases
502
+ apart should say so rather than guess — so when any registry file fails to
503
+ load, the hint names the uncertainty and the order to work in. `Issue.message`
504
+ is prose and never API, so this costs no consumer anything.
505
+ *"A registry file failed to load" is not "loading produced an ERROR", and the
506
+ first draft of this had it wrong.* `duplicate-prefix` and
507
+ `duplicate-requirement` are ERRORs raised **after** a successful read, and the
508
+ ids they are about are present — so keying the new wording on `hasError` told
509
+ a project with a prefix collision that a file had failed to load, which is
510
+ false, about a report where the original advice was correct. `loadRegistry`
511
+ now answers the question it is the only thing in a position to answer: which
512
+ files contributed no ids, taken from the outcome it already branches on rather
513
+ than re-derived from a list of codes a caller would have to keep in step.
514
+
515
+ - **The Vite logger wrapper reported that it had never warned, however much it
516
+ had.** `sanitisedLogger` builds its wrapper by spreading the base logger, and
517
+ `hasWarned` is the one member of Vite's `Logger` that is state rather than
518
+ behaviour — so the spread copied it **by value** at construction. Measured:
519
+ after one `warn` through the wrapper, the base reads `true` and the wrapper
520
+ still reads `false`. An accessor pair makes the wrapper a view of the base
521
+ rather than a snapshot of it, which is what every other member already was.
522
+
523
+ - **`render`'s id comparator was non-transitive on an id it could not take
524
+ apart.** `compareIds` split on `lastIndexOf('-')` with no guard, so an id with
525
+ no dash silently lost its last character (`AUTH` → prefix `AUT`) and produced
526
+ `NaN` for its number. The consequence is worse than a wrong tie: measured on
527
+ `['AUTH-3','AUTH-abc','AUTH-2']` the sort returned `AUTH-3, AUTH-abc, AUTH-2`,
528
+ which is not a sorted permutation of anything. The comparator is now total by
529
+ construction, and falls back to the whole id rather than to the engine's sort
530
+ stability — `renderMarkdown` feeds `--check`, so an order that depends on
531
+ anything but the ids is a freshness gate that can disagree with the run that
532
+ wrote the file.
533
+ **Robustness, not a live defect, and the distinction is worth keeping.** A
534
+ malformed id cannot reach here through any command: `RequirementIdSchema`
535
+ rejects it and `render` returns early on a registry that failed to load —
536
+ confirmed by trying it. What was actually wrong is that two functions held two
537
+ beliefs about one input space: `locate.ts`'s `idPrefix` handles a dashless id
538
+ and records why ("this also runs over ids a delta proposed"), and this one
539
+ did not. They agree now.
540
+
541
+ - **A spec file that failed to load was reported as a file full of skipped
542
+ scenarios, and the file itself was named nowhere.** `verify` on a two-file
543
+ project with one bad import produced three ERRORs: `tests-red`, and one
544
+ `declared-not-run` per scenario in the broken file, each saying *"skipped, or
545
+ excluded by an `.only`?"*. That is a guess at a cause and the wrong one — it
546
+ sends the reader to audit a correct file for a `skip` that is not there, while
547
+ the import error explaining everything is named in no finding at all. The gate
548
+ had a diagnosis for one shape of this (`added-id-unmerged`, when the change
549
+ also adds an unmerged id); every other way a file fails to import had none, on
550
+ either command.
551
+ **The obvious repair was the wrong one, and `gate.ts` had already written down
552
+ why** — three lines from where it would have gone. Suppressing
553
+ `declared-not-run` for unloaded files produces a red report with nothing
554
+ pointing at the file: silence in place of a wrong message, and worse than what
555
+ it replaces. So the replacement comes first. `spec-load-failed` (`ATX-62`)
556
+ names the file on both commands that run a suite, and stands down where a more
557
+ specific diagnosis already named it — one file, one finding, and the one
558
+ carrying a fix wins. It deliberately does **not** restate the import error:
559
+ the run output above the report already has it, and a copy is a second place
560
+ for it to be wrong. No `reqId`, because a file that never loaded has no
561
+ established relation to any requirement.
562
+ **Both commands now ask one function**, `notRunIssues`, rather than each
563
+ spelling out the order themselves — the cause before the absences it caused,
564
+ and never the absences alone. That is how the two came to differ at all: the
565
+ gate learned to withdraw a misleading line and `verify` did not, so one run
566
+ produced a different diagnosis depending on which command asked. It is the
567
+ same argument that extracted `declaredNotRunIssues` from those two commands,
568
+ arriving one level up, and it is what stops this particular divergence from
569
+ being re-openable by an edit to either caller.
570
+ **`ATX-50` reaches `verify` now, and its line did not move.** That requirement
571
+ withdraws the absences a load-failure diagnosis replaces, and its recorded
572
+ condition was always *"a correct diagnosis exists for this file"* rather than
573
+ *"the file failed to load"* — it was scoped to the gate because the gate was
574
+ the only place that condition could hold. It now holds on both. The "and no
575
+ further" half is what an over-broad fix would have cost, and is separately
576
+ attested: a scenario that really was skipped, in a file that loaded, is still
577
+ reported.
578
+ **Behaviour change, for a project that has a spec file failing to load.**
579
+ `verify` and `archive` report one `spec-load-failed` per unloaded file instead
580
+ of a `declared-not-run` per scenario in it. Both are ERRORs and both runs were
581
+ already red, so no green becomes red and no red becomes green; what changes is
582
+ which finding is in the report and what it says. Anything branching on
583
+ `declared-not-run` for such a file — no consumer is known to — sees the new
584
+ code instead. Additive to the JSON surface (a new `code` value), so
585
+ `SCHEMA_VERSION` is unchanged.
586
+
587
+ - **`attest render` let the registry it was reading write to the terminal
588
+ reading it.** ATX-37 put every byte the CLI prints through a sanitiser, and
589
+ the scope line of that requirement says "everything the CLI writes to a
590
+ terminal" — but `render` builds a *document* by string concatenation that
591
+ never went past it. The static reader hands back the **cooked** string, so an
592
+ escape written as an escape in a `*.reqs.ts` is a real control byte by the
593
+ time it is concatenated. *Measured on the built CLI*, with no `--eval` and no
594
+ flag at all: a statement carrying `ESC [2K CR` erases the reviewer's line and
595
+ repaints a verdict, and `ESC ]0;` retitles their window. On a fork merge
596
+ request the author is not the reviewer, and a registry — not a test, not a
597
+ script — is the whole payload.
598
+ **The obvious fix was the wrong one, and the loop said so before it was
599
+ written.** Sanitising the terminal write would have left `--out` putting the
600
+ same bytes in a file that is committed, served, and read again later by `cat`,
601
+ by `less -R`, or by a static site generator. The artifact outlives the run, so
602
+ the run is the wrong place to defend: the obligation is now over the document
603
+ (`ATX-58`), which is also what keeps `--check` honest, since both sides of
604
+ that comparison are built from one function. Newlines survive, because a
605
+ rendered rationale is Markdown prose that may legitimately span lines — and
606
+ they are *not* indented the way ATX-37 indents them, because four spaces in
607
+ Markdown is a code block.
608
+ **Behaviour change, for one shape of project.** A registry that actually
609
+ contains a control character renders to different bytes than before, so a
610
+ committed rendering goes `stale-spec-doc` once and is fixed by regenerating.
611
+ A registry without one is byte-identical: this repo's own `SPEC.md` changed by
612
+ insertion only.
613
+
614
+ - **A statement padded with whitespace cost `render` quadratic time, and
615
+ `render --check` is the gate people put in CI.** The table cell collapsed a
616
+ multi-line statement with a pattern whose leading quantifier is followed by a
617
+ required character, so a whitespace run containing no newline was consumed,
618
+ failed, and re-tried one character shorter from every position in it.
619
+ *Measured through the CLI*: 120,000 spaces in one statement cost 8.6 seconds
620
+ of pure CPU, 320,000 cost 58 — under the **static** reader, so a fork merge
621
+ request can burn a runner without executing a line of anything.
622
+ **The first repair measured slower than the defect**, which is the part worth
623
+ keeping: `[^\S\n]*\n[^\S\n]*`, which stops the class matching the newline,
624
+ is just as quadratic, because the backtracking was never about which
625
+ characters the class held — it was about the quantifier having something after
626
+ it. Matching each maximal whitespace run once and deciding in a callback has
627
+ nothing after the quantifier to fail into. Byte-identical to the old pattern,
628
+ asserted over 200,000 randomised inputs and confirmed on the real corpus, and
629
+ now bounded by `ATX-59` at a budget three orders of magnitude looser than a
630
+ linear implementation needs — a defect detector, not a machine-speed detector.
631
+
632
+ - **Grouping scenarios under a `describe` took the whole spec file down, and
633
+ the static half called that file fully covered.** Vitest defers a nested
634
+ collector until after the parent's callback returns — measured: at the end of
635
+ the outer callback the inner one has not run — so the module-level
636
+ `currentReqId` that `requirement()` set and restored around its body was
637
+ already back to `null` when a grouped `scenario()` ran. It threw, and a throw
638
+ during collection is a *file* error: nothing in that file was collected.
639
+ **The blast radius is the file, not the group.** A correct, ungrouped scenario
640
+ belonging to a *different* requirement in the same file came back
641
+ `declared-not-run` too, blamed on "skipped, or excluded by an `.only`?" —
642
+ sending the reader to audit their spec for something that is not there.
643
+ **And the static commands were not merely misdirected, they were wrong.**
644
+ `check` answered `✓ No issues` and `cover` answered `0 uncovered` for a file
645
+ that could not execute a single line. Those are the two commands documented as
646
+ the fast pre-check that is safe to run first on an untrusted fork, so a
647
+ pipeline running only `check` — a use this reference recommends — would pass a
648
+ change whose specs never run. That is a false green, which is the one failure
649
+ class this tool exists to prevent.
650
+ **Fixed at both ends, because it was one seam disagreeing with itself.**
651
+ `parser.ts` has always recursed to any depth while the task-tree walk counted
652
+ only *direct* `test` children of a `[reqId]` suite, so even a runtime that
653
+ stopped throwing would have reported a grouped scenario that ran and passed as
654
+ never run. The module-level variable is **deleted** rather than repaired — it
655
+ was a second answer to a question the suite name already answers — and both
656
+ sides now go through one `req-suite.ts` that owns the `[id]` encoding.
657
+ **The narrower fix was measured and rejected.** Having the parser refuse a
658
+ `scenario()` that is not a direct child looked smaller and would have closed
659
+ the false green too, but a *synchronous* wrapper — `forEach`, or a local
660
+ helper — works correctly today at both ends, and no static rule separates one
661
+ from a deferred callback. It would have rejected working code to describe a
662
+ runtime property.
663
+ *Behaviour change, two of them.* The nesting contract is now enforced when a
664
+ scenario **runs** rather than when it is collected, so a scenario genuinely
665
+ outside any `requirement()` fails as one test instead of taking its file down.
666
+ Nothing that passed before fails now.
667
+ The second is smaller and out of contract, and is stated because it is silent:
668
+ `scenario` used to hand its function to `it` directly, so Vitest called it with
669
+ the test context, and it is now invoked with no arguments. The declared type
670
+ has always been `() => void | Promise<void>` and the design document has always
671
+ shown it that way, so TypeScript rejected any use of that argument; a
672
+ JavaScript consumer who reached for `ctx.skip()` inside a scenario would find
673
+ it gone. Preserved deliberately as-declared rather than threaded through: the
674
+ contract is the zero-argument one, and adding a parameter to keep an
675
+ undeclared behaviour alive is a hook for a need no requirement has. `docs/*/attest-design.md` §3's runtime sample is
676
+ updated, as is the ESM-only note in §11, whose stated hazard was the very
677
+ module-level state this removes.
678
+
679
+ - **Four verdict predicates that were not `hasError`, one of which would have
680
+ reported a failed gate after the merge had already written the files.**
681
+ `types.ts` states the invariant in as many words — `hasError` is "the single
682
+ predicate the pipeline verdict, the JSON `ok`, and the CLI exit code all read
683
+ from, so those three can never disagree" — and four sites spelled it by hand:
684
+ `archive`'s report and its human rendering both asked `blocking.length === 0`,
685
+ and the merge path asked `issues.some(i => i.level === 'ERROR')` twice.
686
+ **The `archive` one had a consequence its own neighbour predicts.**
687
+ `runArchiveApply` deliberately keeps the gate's non-blocking output, with the
688
+ comment that dropping a WARNING "would make `--apply` quieter than the same
689
+ command without it" — so a WARNING reaching `blocking` is not hypothetical, it
690
+ is planned for. The first one to arrive made the report answer `ok: false`, the
691
+ process exit 1, and the terminal print `✗ Gate failed` **after `--apply` had
692
+ written the registry, renamed the specs and moved the change folder**.
693
+ Reporting failure after a destructive step succeeded is the worst shape a
694
+ verdict can take, and the only thing preventing it was that every issue those
695
+ paths construct happens to carry a literal `ERROR`.
696
+ Now `ATX-60`, stated over the predicate rather than over `archive`, because the
697
+ defect is that the question had more than one spelling and any command could
698
+ grow a fifth — and stated to cover the human verdict as well as the machine
699
+ one, since both were wrong here independently and fixing either alone leaves a
700
+ command whose two surfaces disagree about whether it failed. Distinct from
701
+ `ATX-5`, which promises the implication one way only: `ok` false whenever there
702
+ is an ERROR says nothing about `ok` false when there is not, which is the half
703
+ that broke. No behaviour change today, which is the argument for doing it
704
+ before something has been reported wrong rather than after. **No
705
+ `schemaVersion` bump**: the envelope keeps every field it had and the `Issue`
706
+ interface is untouched — what changed is the value `ok` takes in a case
707
+ nothing can currently produce, and a consumer branching on `ok` and
708
+ `issues[].code` as documented reads the same report it always did.
709
+ *Found by reading, and the count was wrong when it was filed:* the note that
710
+ queued this named three sites; the rendering in `cli/index.ts` was the fourth,
711
+ and it is the one a human would actually have seen.
712
+
713
+ - **Sweeping for a second backtracking site found something that is not
714
+ backtracking.** Every regex `src/` executes was measured against 21
715
+ adversarial input shapes at doubling sizes, with the old table-cell pattern
716
+ left in as a positive control — it was the only superlinear hit, which is what
717
+ makes the other eighteen being linear evidence rather than silence. The
718
+ structural reason is worth keeping: that pattern began with a quantifier that
719
+ can match nothing, so **every index** was a valid start; every other regex here
720
+ begins with a literal or a required class, which caps the starts at how often
721
+ that character occurs.
722
+ What the sweep turned up instead was the code-span fence, which measured the
723
+ longest run of backticks with `Math.max(0, ...runs)` — one stack argument per
724
+ run, so a param holding a few hundred thousand of them exhausted it. Same
725
+ reachable path as the quadratic one (`attest render` and `render --check`,
726
+ static reader, no `--eval`), same shape of defect — a registry choosing what
727
+ rendering costs the person reading it — and a different mechanism entirely.
728
+ `ATX-59` now states both and is counted as two obligations, because a
729
+ quadratic pattern and an unbounded spread regress independently.
730
+ A third candidate was measured and **rejected**: a deeply nested literal does
731
+ overflow the stack, but TypeScript's own parser gives out around depth 1,000
732
+ and this project's AST walk not until 2,000, so the limit belongs to the
733
+ compiler Attest is built on. It also lands where the contract already puts it —
734
+ a well-formed `internal-error` envelope under `--json`, and a sanitised stack
735
+ on the side people read.
736
+
16
737
  ## [0.6.0] - 2026-08-03
17
738
 
18
739
  ### Added
@@ -2436,57 +3157,137 @@ feedback reports are triaged into issues or `Unreleased` and deliberately not
2436
3157
  kept in this tree; that rule stands, and holds for anything with a reporter, a
2437
3158
  repro, or a state to track. What is kept here is the narrower thing this file
2438
3159
  already keeps one section of: a **decision with an argument attached**, where
2439
- losing the argument would cost more than losing the task. The one below came out
2440
- of reading another repository against this one — `msw`.
2441
-
2442
- Five entries have left by being built, and the last three left together in
2443
- `[0.6.0]`. `compiler-unsupported` came from building something queued here,
2444
- which is one way an entry arrives: a gate that finds a defect on its first run
2445
- files the defect, and the `typescript` matrix `[0.4.2]` added is what found this
2446
- one.
2447
- `--apply` came from an adoption report (`mine-capablanca`, 0.4.2, 34 requirements
2448
- / 56 scenarios / greenfield) a third way an entry arrives: not a defect, but a
2449
- shape the tool made an adopter work around eight times without ever erroring. It
2450
- took `spec-in-change-dir` out with it, by a fourth way worth naming: designing one
2451
- of `--apply`'s checks surfaced a different one, against a migration this file had
2452
- itself shipped. **What a release costs its adopters is not fully known on the day
2453
- it is cut** and building the entry is what finds out, which is also how
2454
- `--apply` discovered that one clause of its own argument here was wrong. Both are
2455
- recorded under `[0.6.0]`, and two earlier entries from the same report left
2456
- by shipping in `[0.5.0]` a change's specs moving to their merged location, and
2457
- the delta reading as the registry of what it adds their arguments recorded
2458
- there, including the part of the first one that turned out to be wrong.
2459
-
2460
- - **Region-marked samples, so a partial quote can be bound too.** The whole-file
2461
- half of this shipped in `[0.4.3]`: the README's two getting-started blocks
2462
- are now byte-equal to `fixtures/consumer/`. That buys 2 of the 23 fenced `ts`
2463
- blocks in the README and `docs/en/**`, and the ceiling is structural rather
2464
- than lazy the other 21 are elided (`{ /* … */ }`), annotated (`// ✓ the single
2465
- source`, `// a literal that can drift`), or deliberately rejected by the engine
2466
- (`params: { maxMb: MAX_MB }`), and none of those can equal a runnable file.
2467
- **What raises it is marking spans, not a second mechanism.** A
2468
- `// #region readme:registry` and its close inside the fixture, with the
2469
- assertion running block against dedented span, lets one file feed several
2470
- passages and makes a partial quote bindable by construction. Where that
2471
- actually reaches, counted rather than hoped: `troubleshooting.md`'s
2472
- single-source pair is two verbatim lines of `fixtures/consumer/session.spec.ts`
2473
- and its `AUTH-3` block is the registry entry at a different indent so a
2474
- handful more, not the remainder. Anything carrying a `✓`/`✗` is authored
2475
- commentary and stays a fragment; that is what it is for.
2476
- *The blocks are a separate question, and worth not conflating.* A sample
2477
- showing input the engine must reject could be bound to a fixture whose
2478
- *rejection* is asserted which is what `fixtures/eval-invalid-registry`
2479
- already is. Binding those is the more valuable half, because a counterexample
2480
- that stopped being a counterexample is the one kind of stale sample that
2481
- actively teaches the wrong thing. It is also the one that needs a fixture per
2482
- diagnostic, so it is not free the way the regions are.
2483
- *The mirror rule still caps what can be claimed on the Chinese side.*
2484
- `docs/zh/**` matches `docs/en/**` down to the comments inside its code samples,
2485
- so byte-equality holds on one side only and the zh blocks stay translated prose
2486
- around the same code the parity gate in `[0.4.3]` is what covers them now,
2487
- and it covers structure, not meaning. Unchanged from the whole-file half: the residual
2488
- risk recorded with it in `[0.4.3]` that the gate and the run proving the
2489
- fixture still works live in different suites applies here identically.
3160
+ losing the argument would cost more than losing the task.
3161
+
3162
+ **Where an entry went is recorded under the version that shipped it.** This
3163
+ section keeps no ledger of its own departures one was maintained here by hand
3164
+ for several releases, and a count kept in prose with nothing to check it is a
3165
+ claim that goes wrong quietly. What is worth keeping is what the section learned
3166
+ about itself, which is three things.
3167
+
3168
+ **An entry can sit here for releases on the strength of a figure nobody has
3169
+ checked.** The discipline aims at keeping the *argument*, not at re-testing the
3170
+ evidence under it, so the first act of building one is re-measuring what it
3171
+ claims rather than implementing what it proposes. That has paid twice: once
3172
+ killing an entry outright, when the spans it rested on turned out not to be
3173
+ spans; once correcting an entry's headline number before a line of it was
3174
+ written. **What a release costs its adopters is not known on the day it is cut**
3175
+ several entries arrived from one adoption report, about shapes the tool made
3176
+ someone work around without ever erroring. And **the fixes with no observable
3177
+ symptom are worth batching**: individually none justifies a commit, together they
3178
+ cost about an hour, and they are the group no gate could have found, since having
3179
+ no symptom is the definition of what a gate cannot see.
3180
+
3181
+ - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3182
+ showing input the engine must *reject* can be bound the way a runnable one is,
3183
+ to a fixture whose rejection is the thing under test which is what
3184
+ `fixtures/eval-invalid-registry` already is. This is the more valuable half of
3185
+ what was once one entry with the region-marking below, because a counterexample
3186
+ that quietly stopped being a counterexample is the one kind of stale sample
3187
+ that actively teaches the wrong thing: a reader shown "this fails" about input
3188
+ the engine now accepts is worse off than one shown nothing. It is also the
3189
+ expensive half, needing a fixture per diagnostic, which is why it was never the
3190
+ free part of that entry and is now the only part left in it.
3191
+ *The mirror rule caps what can be claimed on the Chinese side.* `docs/zh/**`
3192
+ matches `docs/en/**` down to the comments inside its code samples, so
3193
+ byte-equality holds on one side only and the zh blocks stay translated prose
3194
+ around the same code the parity gate in `[0.4.3]` covers them, and it covers
3195
+ structure, not meaning. The residual risk recorded with the whole-file half in
3196
+ `[0.4.3]` — that the gate and the run proving the fixture still works live in
3197
+ different suites applies here identically.
3198
+
3199
+ - **The child run's stdout is not Attest's, and `--json` promises it is.**
3200
+ `runAndCollect` starts Vitest with the parent's fd 1 inherited, so a spec file
3201
+ in the project under test writes straight to the same stream the report goes
3202
+ to. `quiet: true` does not close it: `silent: true` suppresses Vitest's
3203
+ *console interception*, and a bare `process.stdout.write` in a spec at
3204
+ collection time or inside a scenario body, both measured goes past it
3205
+ untouched. Neither `core/terminal.ts` nor `sanitisedLogger` is on that path at
3206
+ all, because no Attest code is.
3207
+ *The machine surface is the half that is actually new.* `cli/index.ts` opens
3208
+ by stating that under `--json` "the only thing written to stdout is one
3209
+ machine-readable report", and §9.1 records that this path "was never exposed
3210
+ `JSON.stringify` escapes every C0 character". Both hold for `check`, `cover`,
3211
+ `render` and `status`, and neither holds for `verify` or `archive`: measured,
3212
+ a spec writing `{"ok":true,"POLLUTION":"…"}` puts that document **first** on
3213
+ stdout, ahead of the real report, so `JSON.parse` of the whole stream fails
3214
+ (position 46) while anything reading line-wise or taking the first object
3215
+ reads the attacker's verdict. The terminal half is the same hole and the
3216
+ smaller finding — `ESC[2K CR` plus a forged `ERROR` header at column 0 and an
3217
+ `OSC 0` window retitle all arrive intact — but `verify` executes project code
3218
+ by design, so repainting a terminal is not a capability that path lacked.
3219
+ *The fix belongs to `runAction`, not to `runner.ts`.* That function is already
3220
+ the single convergence point for "one output, one exit code", which is the
3221
+ same argument that put `formatCrash` in `cli/report.ts` rather than leaving a
3222
+ copy in each `catch`: `runner.ts` would be made to hold a contract it does not
3223
+ own, while `runAction` is the only place the promise is made.
3224
+
3225
+ - **`render`'s document can carry raw HTML and a forged requirement section.**
3226
+ `sanitised` is the entry §9.1 names and it strips control characters and
3227
+ nothing else, so `statement` and `rationale` reach the Markdown by
3228
+ concatenation exactly as written. Measured: a `rationale` containing newlines
3229
+ and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
3230
+ for a requirement no registry holds, sitting between two real ones and worded
3231
+ identically; `<script>` and `<img onerror>` survive the same way. `check` and
3232
+ `cover` see only the real id, and `render --check` cannot object, because that
3233
+ *is* what the registry renders to now.
3234
+ *Not an accepted risk, which is the part worth pinning.* §9.1 and the
3235
+ reference both scope their guarantee to control characters, and the
3236
+ reference's heading over that paragraph is "Safe to read, and safe to keep",
3237
+ with "a site generator" named in the threat it describes. What *is* a decision
3238
+ is that prose may be marked up — `plain()` escapes a param value precisely
3239
+ because it is data while the statement around it is not — and that decision
3240
+ has never been separated from allowing raw HTML or a heading. So the entry is
3241
+ as much about stating which of the three is intended as about the escaping,
3242
+ and if the answer is that Markdown stays and HTML and headings go, that is a
3243
+ fourth obligation in §9.1 rather than a patch at an emitter.
3244
+
3245
+ - **`attest init` follows a symlink out of the project root.** `runInit` does
3246
+ `mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
3247
+ neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
3248
+ planted in the repository as a link to anywhere the user can write is followed
3249
+ by both: measured on Windows with a junction, a file at the target holding
3250
+ `USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
3251
+ prints the relative path, so nothing on screen says where it went.
3252
+ *`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
3253
+ flag defends the *temporary* path against a planted link and does exactly
3254
+ that; the destination's directory was never in its scope. And `workflowBody()`
3255
+ is a pure function of nothing, so no byte of the checked project reaches the
3256
+ file — the exposure is the path, not the content, which is `targets.ts`'s
3257
+ first rule ("Attest owns the path") being an assumption rather than a check.
3258
+ The check belongs beside `resolveTargets`, which is already all-or-nothing for
3259
+ the same reason: a name known to be unwritable must not leave half a set
3260
+ behind.
3261
+
3262
+ - **A deeply nested literal takes `check` down before either reader sees it.**
3263
+ 40 KB of `[[[…]]]` in a `params` value exhausts the stack inside
3264
+ `ts.createSourceFile` — TypeScript's own recursive-descent parser, so
3265
+ `literalValue`'s recursion is never reached and hardening it would change
3266
+ nothing. `parser.ts` opens with the same call, so `verify` takes it too, and
3267
+ `loadRegistry` reads with `Promise.all`, so one file ends the whole command.
3268
+ *It fails closed, which is why this is a repair and not an emergency.*
3269
+ Measured: `internal-error`, `ok: false`, exit 1, and a well-formed `--json`
3270
+ envelope. What it costs is that the command this project tells people to run
3271
+ first on an untrusted fork MR can be made to say nothing but that, forever.
3272
+ The shape of the fix is already in `locate.ts`: `unreadableFiles` exists to
3273
+ say "this file could not be read" without ending the run, and a `RangeError`
3274
+ out of a single `read` or `parseSpecFile` is that, so a hostile file scraps
3275
+ only itself and `check`'s breadth contract survives it.
3276
+
3277
+ - **`status` prints one line the sanitiser never sees.** `cli/index.ts` writes
3278
+ `Change: ${result.change}` bare, while `formatStatus` puts the same value
3279
+ through `inline` twice, three lines apart in the same output. Measured with a
3280
+ directory named with U+009B — the C1 CSI the class in `control` exists for,
3281
+ and one NTFS permits — the header carried `c2 9b` raw while the closing
3282
+ `Not a verdict:` line carried a space. A change name is a directory name in
3283
+ the repository under test, and the workflow `init` writes tells an agent to
3284
+ run `attest status <name>`, so the value is the project's to choose.
3285
+ *One `inline()` call, and the entry is here for the reason rather than the
3286
+ fix.* §9.1 says a stream has no entry, only writes, so the obligation sits on
3287
+ every write or not at all — and this is the second time a single missed write
3288
+ has been the whole defect, after the two `catch` blocks that printed a raw
3289
+ stack beside sanitised diagnostics. The sweep filed above under `ATX-37` is
3290
+ what would have found it; this is one more measurement in favour of it.
2490
3291
 
2491
3292
  ## Under consideration
2492
3293
 
@@ -2500,6 +3301,41 @@ deletion. What each entry has to carry is the evidence currently blocking the
2500
3301
  decision, because that is what a re-proposal a year from now would otherwise
2501
3302
  have to gather again.
2502
3303
 
3304
+ - **A static sweep for the two properties claimed over the whole of `src/`.**
3305
+ The quantifier table's first strict read left three rows wanting the same
3306
+ thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
3307
+ a sweep over the import graph asserting a cross-cutting property no runtime
3308
+ test can see, written for the same reason — "only the graph shows it".
3309
+ *The two candidates, and they are not equally shippable.* `ATX-15` is the easy
3310
+ one: **no `.sort` outside `byCodeUnit`, and no `localeCompare`.** Measured over
3311
+ `src/` today — 21 `.sort` call sites, every one reaching `byCodeUnit`, but only
3312
+ 19 naming it on the call line: `render.ts` goes through its own `compareIds`,
3313
+ and `merge.ts` through a comparator that spans two lines. The only four
3314
+ occurrences of `localeCompare` are comments explaining why it is not used. A sweep would have **zero false positives on the current corpus**,
3315
+ which is the bar `divergent-param` failed and `compound-requirement` cleared —
3316
+ and those two exceptions are already the warning that this needs the AST rather
3317
+ 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
3318
+ string interpolated into a terminal line goes through `inline`", and that is
3319
+ not a token scan. The shape that would work is a scenario driving every
3320
+ exported `format*` in `src/cli/report.ts` with hostile input — a real roster,
3321
+ since the module's exports are what a new formatter joins — but each takes a
3322
+ different argument type, so it needs a hostile fixture per signature rather
3323
+ than one loop.
3324
+ *What blocks the decision.* Two things, one per candidate. The `ATX-15` sweep
3325
+ is cheap and precise and would be **the first rule in this repository that
3326
+ polices how `src/` is written rather than what it does** — a lint rule wearing
3327
+ a scenario's clothes, and the question is whether that belongs in `self/` (as
3328
+ an obligation `ATX-15` already carries) or in `tests/` beside the import
3329
+ boundary. Filing it under `ATX-15` makes it a permanent obligation with a
3330
+ scenario; filing it in `tests/` makes it deletable in a green diff, which
3331
+ `ATX-57`'s rationale argues against in as many words. The `ATX-37` roster is
3332
+ blocked on cost rather than principle: eight formatters, eight fixtures, and
3333
+ the honest count of what it buys is three currently-correct sites.
3334
+ *Why neither is urgent.* Both properties hold everywhere today, verified by
3335
+ reading. What is missing is not correctness but the thing that keeps it — and
3336
+ the measurement above is what a re-proposal would otherwise have to gather
3337
+ again.
3338
+
2503
3339
  - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
2504
3340
  project with no concurrency limit: `findFiles` recurses through
2505
3341
  `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
@@ -2604,6 +3440,161 @@ have to gather again.
2604
3440
  here rather than moving to `Considered and rejected`: documenting a trap is
2605
3441
  not the same as deciding not to detect it.
2606
3442
 
3443
+ - **Whether `check` and `verify` should refuse a half-loaded registry the way
3444
+ `cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
3445
+ and the argument is recorded at both call sites: a report computed from a
3446
+ half-loaded registry is a lie, and a lie that reads as a pass is the worst
3447
+ shape it can take — `cover` in particular used to answer `ok: true` on a repo
3448
+ whose intent layer had stopped being readable, because an empty registry is
3449
+ fully covered. `check` and `verify` carry on regardless.
3450
+ *What that produces, measured* on a project with one unreadable `*.reqs.ts`:
3451
+ a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
3452
+ that file's requirements, plus `empty-spec`, `tests-red` and a
3453
+ `declared-not-run` per scenario — six derived findings from one cause. Worse,
3454
+ `orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
3455
+ for an id already sitting in the registry that failed to load, so the loudest
3456
+ advice in the report points at work that must not be done. In a multi-file
3457
+ registry it scales with the broken file's scenario count, and the one true
3458
+ finding is at the top where a wall of red pushes it out of view.
3459
+ *Why it is not simply the same call as `cover` and `render`.* Each of those
3460
+ produces exactly **one** artifact, and refusing to produce it is the whole of
3461
+ the decision. `check` is the command a pipeline runs first and its contract is
3462
+ breadth — returning three findings instead of nine because one of the nine was
3463
+ a load failure makes it quieter about problems it can still see perfectly well,
3464
+ which is the opposite of what it is for. `verify` has the same shape with a run
3465
+ attached.
3466
+ *What blocks the decision.* The narrower move — keep reporting everything
3467
+ except the findings **derived from** the missing registry, i.e. suppress
3468
+ `orphan-test` for ids whose prefix belongs to a file that failed to load —
3469
+ needs a prefix→file mapping for a file that never parsed, and that is exactly
3470
+ the file `loadRegistry` has no ids from. Recovering the mapping means reading
3471
+ the prefix out of something other than the registry's own keys (the file name,
3472
+ or a partial AST read that survives the failure), and whether either is
3473
+ trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
3474
+ weaker signal "some registry file failed to load, so suppress all
3475
+ `orphan-test`" is the other candidate and is a strictly worse `check` on a
3476
+ multi-file registry, where the other files are fine and their orphans are real.
3477
+ **The wrong *advice* has been separated out and fixed; the noise has not.**
3478
+ `[0.7.0]` changes `orphan-test`'s fix hint when any registry file failed
3479
+ to load, so the loudest line in that report stops telling the reader to add an
3480
+ id that already exists. That needed no mapping, because it claims nothing about
3481
+ *which* orphans came from the broken file — only that the command cannot tell,
3482
+ and what order to work in. It is worth separating because the two halves were
3483
+ filed as one problem and are not: **being wrong** was fixable today at the cost
3484
+ of a sentence, and **being loud** is the part still blocked on the mapping
3485
+ above. The entry stays open on exactly that, with one blocker retired: the
3486
+ measured six derived findings from one cause are still six, and only one of
3487
+ them has stopped pointing at work that must not be done.
3488
+
3489
+ - **A statement that quantifies over a surface, with scenarios covering only the
3490
+ part that existed when it was written.** Found by reading, and it had already
3491
+ cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
3492
+ a report, and the stack of a crash — MUST replace every control character",
3493
+ and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
3494
+ the two functions that existed when it was written. `render` builds a document
3495
+ by concatenation that never went past the sanitiser, and a registry could drive
3496
+ the reviewer's terminal from `attest render` with no flag at all — shipped
3497
+ under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
3498
+ specific to that requirement.
3499
+ The shape: a universal quantifier in the statement, a finite set of scenarios,
3500
+ and nothing anywhere that holds the two together. It reads as covered — the id
3501
+ has scenarios, `cover` is green, `check` is green — and the quantifier is the
3502
+ part no gate is looking at. Every one of the three mechanisms this project
3503
+ sells operates *below* it: coverage asks whether an id has a scenario,
3504
+ never-red asks whether that scenario discriminated, drift asks whether it reads
3505
+ the params. None asks whether the scenarios span what the sentence claims.
3506
+ *What blocks the decision.* Detecting it means knowing which surface a
3507
+ statement names, which is prose judgement — the same judgement
3508
+ `tests/intent-rule-candidates.spec.ts` records as the reason obligation
3509
+ counting can never be an engine rule ("asserting a ground truth Attest cannot
3510
+ compute for someone else's requirements, which is exactly what §0 refuses").
3511
+ So the shippable form may not exist.
3512
+ **The repo half has shipped, and this entry is now only about the engine
3513
+ half.** `[0.7.0]` adds the `QUANTIFIED` table it described — hand-judged,
3514
+ a row per flagged statement, every named scenario asserted to exist — and it
3515
+ closed the way an entry built from a strict read is supposed to: it found a
3516
+ live hole on `ATX-62`, the one requirement written specifically to avoid this
3517
+ shape, where the archive gate path was claimed by the statement and asserted by
3518
+ nothing. Two things came back that the entry did not predict. The useful one is
3519
+ that "names a surface" splits three ways rather than two — `values`, `roster`,
3520
+ `sites` — and only `sites` carries the risk, because a `roster` scenario
3521
+ iterates the set the code owns and grows with it. That is a sharper statement
3522
+ of the defect than "the scenarios do not span the sentence", and it is a rule
3523
+ an author can act on: **prefer a quantifier a scenario can iterate.** The other
3524
+ is that the remedy — rewriting the statement to name its surfaces, as `ATX-62`
3525
+ did — takes it out of the flagged set, so the table has to allow rows for ids
3526
+ the detector does not flag or it loses the binding exactly when the author does
3527
+ the right thing.
3528
+ *What stays open is the engine half, and nothing above touches it.* The three
3529
+ kinds are prose judgement, the detector misses the article form ("A registry
3530
+ that cannot be read MUST …") which is a universal too, and no rule can tell
3531
+ which surface a sentence claims without being told. The repo half is a
3532
+ counter-pressure, not a detector, and it holds for one registry whose authors
3533
+ agreed to maintain it.
3534
+
3535
+ - **Whether a requirement can ever be retired, and what it costs that none can.**
3536
+ The registry is at 62 requirements and 160 scenarios, roughly 2.6 each, and
3537
+ every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
3538
+ requirement without a scenario cannot exist, and nothing ever removes one.
3539
+ `delta.removed` exists and `applyDelta` applies it, but `--apply` refuses to
3540
+ write it back, deliberately and with the reason recorded — REMOVED "cannot say
3541
+ which comments belonged to the entry it deletes". So retiring one is a manual
3542
+ registry edit, which is fine as a mechanism and says nothing about whether it
3543
+ is ever the right move.
3544
+ *What the growth actually costs, measured on this session rather than
3545
+ estimated.* One small fix took: a requirement, its scenarios, a hand-judged
3546
+ row in the `OBLIGATIONS` table, two inline snapshots, a `[Unreleased]` entry,
3547
+ History rows in two languages, and a regenerated `SPEC.md`. That is
3548
+ proportionate for a spec framework that has to dogfood — the ceremony *is* the
3549
+ product being used on itself — and it is also a number that only goes up.
3550
+ *What blocks the decision, and it is not the mechanism.* It is that "retire"
3551
+ has at least three meanings this file has never had to separate: a requirement
3552
+ that turned out **wrong** (which should leave a record of being wrong, and
3553
+ `Considered and rejected` is where that already goes); one still **true** but
3554
+ no longer worth a permanently-run scenario; and one **subsumed** by a later
3555
+ requirement, where the scenario should move rather than go. Only the second is
3556
+ really about cost, and it is the one where deleting the intent to save the
3557
+ scenario inverts what the whole tool claims — that intent outlives its tests.
3558
+ Nothing here is urgent while the number is 61. It is filed now because the
3559
+ moment to write the argument down is while the reasoning is available, and
3560
+ because a project whose requirement count only rises should have decided in
3561
+ advance what would make one leave.
3562
+
3563
+ - **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
3564
+ several.** `shellArg` asserts the argument holds no `"` and then quotes only
3565
+ when it holds whitespace, so an argument with none goes to the shell bare —
3566
+ and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
3567
+ does not name. The fix is one character class, which is why the entry is not
3568
+ about the fix.
3569
+ *What blocks the decision is that no input reaches it.* Both arguments are
3570
+ either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
3571
+ and CI is Linux, and the one path an attacker could plausibly influence — the
3572
+ checkout directory on a fork MR pipeline — comes from a GitLab project path,
3573
+ whose character set excludes every metacharacter above. So this is an
3574
+ observation with no attack path, and the honest question is not whether to
3575
+ widen the class but whether the comment above it should stop claiming more
3576
+ than the assertion covers: it says "the assertion keeps that true" about
3577
+ arguments generally, where what it checks is one character. Widening the class
3578
+ and leaving the prose is the change that would look like a fix and settle
3579
+ nothing.
3580
+
3581
+ - **A loader that fails to start leaves its scratch directory behind.**
3582
+ `createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
3583
+ and the only `rm` of that directory is inside the `close` of the object
3584
+ `createServer` returns — so a throw from it leaks one `attest-loader-*` per
3585
+ invocation, which on a CI runner is one per build. That is the exact failure
3586
+ shape the comment on `close` records as already fixed, surviving on the other
3587
+ path.
3588
+ *What blocks it is that nothing has been made to throw there.* With
3589
+ `configFile: false` Vite still reads the `package.json` at the root to decide
3590
+ `type`, so a malformed one in the checked project is the obvious candidate and
3591
+ it is the project's to write — but it was not tried, and an entry claiming a
3592
+ trigger it has not seen is the thing this section exists to not accumulate.
3593
+ The repair is three lines (a `try` around the two calls that `rm`s and
3594
+ rethrows) and is not what the decision turns on; measuring whether the throw
3595
+ is reachable at all is, because if it is not, this is a leak with no input
3596
+ that produces it.
3597
+
2607
3598
  ## Considered and rejected
2608
3599
 
2609
3600
  Decisions **not** to build something, kept where they can be found before the
@@ -2614,6 +3605,45 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
2614
3605
  sinking one version deeper each time a release was cut above it, and a rejection
2615
3606
  filed under a version reads as belonging to it.
2616
3607
 
3608
+ ### Region-marked samples, so a partial quote could be bound too
3609
+
3610
+ Decided **yes** and filed under `Planned`, then killed by building the count it
3611
+ rested on. The whole-file half shipped in `[0.4.3]` — the README's two
3612
+ getting-started blocks are byte-equal to `fixtures/consumer/` — and the proposal
3613
+ was to raise that with `// #region readme:registry` markers inside the fixture,
3614
+ asserting a doc block against the dedented span rather than the whole file. The
3615
+ mechanism is sound and cheap. What it has no supply of is spans to bind.
3616
+
3617
+ **The entry named its own two candidates, and both fail on inspection.** It said
3618
+ `troubleshooting.md`'s single-source pair is "two verbatim lines of
3619
+ `fixtures/consumer/session.spec.ts`": they are lines 8 and 11, so no span
3620
+ contains them, and line 8 reads `// single source, typed \`30\` — no cast` in the
3621
+ fixture against `// ✓ the single source` on the page. It said the `AUTH-3` block
3622
+ is "the registry entry at a different indent": the fixture wraps `statement:`
3623
+ onto a second line and the page does not, and the page's comment carries a `✓`
3624
+ the fixture has no reason to. **Both candidates carry a `✓`, which the same entry
3625
+ excludes by rule** — "anything carrying a `✓`/`✗` is authored commentary and
3626
+ stays a fragment". The entry's estimate contradicted the entry's own rule, and
3627
+ neither half was checked against the files until now.
3628
+
3629
+ Swept the rest rather than stopping at the two: of the 23 fenced `ts` blocks in
3630
+ the README and `docs/en/**`, 2 are already bound whole, 3 are `src/` sketches the
3631
+ module-scope check in `[0.7.0]` covers, and the remaining 18 are elisions
3632
+ (`{ /* … */ }`), `✓`/`✗` fragments, input the engine must reject, or teaching
3633
+ sketches of the *reader's* project carrying inline commentary that is the point
3634
+ of them. **Zero spans.** So building it would ship a gate binding nothing, which
3635
+ this project treats as worse than the comment recording it — the argument §11
3636
+ makes and `ATX-43` and `ATX-44` both decline a promise on.
3637
+
3638
+ What that leaves is the honest general form: **the reach of a byte-equality gate
3639
+ over documentation is set by how the documentation is written, not by the
3640
+ granularity the gate can address.** Raising it would mean rewriting the pages to
3641
+ be quotable, which is the tail wagging the dog — those `✓`/`✗` annotations and
3642
+ elisions are what makes a sample teach. The counterexample half was never
3643
+ dependent on this and stays under `Planned` as its own entry. If a passage is
3644
+ ever written that *is* a verbatim span of a fixture, this is three lines of
3645
+ `fencedBlocks` away and the reasoning above is what to re-read first.
3646
+
2617
3647
  ### `typescript` as a peer dependency instead of a bundled one
2618
3648
 
2619
3649
  The open question the matrix entry was queued to settle (see `[0.4.2]`), and
@@ -2889,42 +3919,9 @@ requirement, and human review at propose is still the whole answer. What is
2889
3919
  rejected is grouping as a way to assist it, and this reopens only on a
2890
3920
  contradiction that grouping would have caught.
2891
3921
 
2892
- ### Nested objects in `params`, so a lookup table can be registry-owned
2893
-
2894
- Raised by `mine-capablanca` (0.4.2) and explicitly not as a request, which is
2895
- why it is recorded with its own answer rather than left as a complaint about a
2896
- message. A `params` value is a scalar or a list of scalars; a table — piece kind
2897
- to weight, tier to limit — is refused, and the only way to register one is to
2898
- split it across parallel arrays, which reads worse in the rendered spec than it
2899
- buys. *What rejected it: the two properties a param has, and a table has
2900
- neither.* A param is **rendered into `SPEC.md` as a value a human checks at a
2901
- glance**, and it is **read by a scenario as the one place that value lives**. A
2902
- nested object fails the first by construction — there is no reading of a
2903
- five-row table that a reviewer verifies the way they verify `30`. It fails the
2904
- second more quietly: a table has many readers by definition, so "the one place"
2905
- becomes "the one file", which is what an ordinary module already is. The
2906
- schema's own comment records where the line was drawn and why arrays were let
2907
- in — list constants are the most drift-prone values, and an array still has one
2908
- owner read by one scenario. A table does not clear that bar; the reason arrays
2909
- did is exactly the reason it does not.
2910
- *The reporter's own resolution is the argument's strongest form.* The table went
2911
- to a shared module, is not registry-owned, and **nothing was lost** — because a
2912
- piece-value table is tuning, not a promise. That is the rule design §11 now
2913
- states — *params for promises, plain constants for tuning* — derived
2914
- independently by the same adopter before it was written anywhere. Under it this
2915
- candidate is not a limitation of `params` at all: it is a value that was never a
2916
- requirement's to hold, and §11 carries this rejection's own argument in the same
2917
- paragraph, as the rule stated over the type rather than over the value.
2918
- *What ships instead*, in `[0.5.0]`: the refusal now says what is accepted
2919
- instead of `Invalid input`, and `troubleshooting.md` states the constraint and
2920
- where such a value belongs — the reporter's actual ask, which was for the
2921
- decision to be visible rather than discovered by reading the shipped `.d.ts`.
2922
- The permanent half goes to design §11 with the params rule, not here.
2923
- *This reopens on* a report where the value is genuinely promised — a table an
2924
- adopter owes their users, whose rows a reviewer would want in the rendered spec.
2925
- None has been seen; the one that raised it was tuning.
2926
-
2927
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.6.0...main
3922
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.1...main
3923
+ [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
3924
+ [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
2928
3925
  [0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
2929
3926
  [0.5.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.5.0
2930
3927
  [0.4.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.3