@am_shork/attest 0.7.3 → 0.8.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,648 @@ 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.8.0] - 2026-08-09
17
+
18
+ ### Security
19
+
20
+ - **The `--json` document escaped every C0 control and no C1, while every other
21
+ surface stripped both.** `control()` (`core/terminal.ts`) covers
22
+ `\x00-\x1f`, DEL and `\x80-\x9f`, the last of those held deliberately for
23
+ 8-bit CSI; the machine path rested on `JSON.stringify`, and the claim written
24
+ above it — that serialising to JSON escapes the same characters — is true of
25
+ C0 and of nothing else. Re-measured before a line was written, as this file's
26
+ discipline asks: `U+009B` and `U+007F` both leave `renderJson` verbatim.
27
+ Reachable from every `--json` command including `check --json`, which executes
28
+ nothing and still quotes scenario names, file paths and reqIds out of the
29
+ repository being checked; under `verify` and `archive` a thrown message joins
30
+ them.
31
+ **Filed at the strength the evidence carries, and that has not changed.**
32
+ Exploiting it needs a terminal that treats UTF-8-decoded C1 as control —
33
+ believed to split VTE from xterm's default, still **not measured on either** —
34
+ and a reader looking at the JSON rather than parsing it. What makes it worth
35
+ closing is not the size of that window but the asymmetry: design §9.1 is
36
+ stated as a property of every artifact Attest writes, and one emitter opting
37
+ out through a true-but-narrower guarantee is exactly how two `catch` blocks
38
+ came to print a raw stack beside sanitised diagnostics once already.
39
+ **Re-measuring moved where the fix goes.** The entry proposed a map over
40
+ `issues`, on the `withDocs` model. Walking the report showed that is the wrong
41
+ half: `change`, `outFile`, `outFiles` and — two levels down — a status row's
42
+ scenario name and file all carry text from the project under test, and none of
43
+ them passes through the envelope. Enumerating them is the arrangement §9.1
44
+ already names as the cause of the defect, so the rule sits at serialisation
45
+ instead, which is the *document* arrangement the section describes — one point
46
+ of assembly, before any of it is seen, covering a field nobody has added yet.
47
+ `outFiles` and `docsUrl` were both added after the envelope and would each
48
+ have needed remembering. `reqId` needed none of this and is worth recording as
49
+ the case that is safe by construction: `RegistryIdSchema` holds every id to
50
+ `^[A-Z]+-\d+$` on both readers, which is the same argument `render` uses for
51
+ not sanitising ids.
52
+ No `SCHEMA_VERSION` bump: the shape is unchanged, and the field values a
53
+ consumer parses differ only where they previously carried a control character.
54
+ Attested as `ATX-74` rather than by widening `ATX-37`, which claimed this
55
+ surface was exempt — the two are separately falsifiable in the direction that
56
+ matters, since `ATX-37` holds at every print site today while this one failed.
57
+ That rationale, and the same claim in `core/terminal.ts`, are corrected in
58
+ place.
59
+
60
+ - **`render --out` wrote through a committed symlink, because the per-segment
61
+ resolution `init` performs had exactly one caller.** `resolveDest` resolves
62
+ every segment of an `init` destination and refuses with `unsafe-target-path`;
63
+ the `--out` path was `resolve(cwd, opts.out)` and went straight to
64
+ `writeAtomic`. Measured before a line was written, as this file's discipline
65
+ asks, and the filed claim held: with `docs` committed as a symlink to a
66
+ sibling directory, `attest render <dir> --out <dir>/docs/SPEC.md` reported
67
+ `✓ Wrote …` and the document appeared outside the root. The victim's `--out`
68
+ is the one their own README or CI documents, and the content redirected is the
69
+ rendered document — the statements and rationales their repository wrote.
70
+ **The leaf was already safe, which is why the gap was exactly the directory
71
+ segments.** `writeAtomic` lands by `rename`, which replaces a symlink instead
72
+ of following it — measured: a `SPEC.md` symlinked at a file outside the root
73
+ left that file's bytes untouched. `wx` does not reach this either and was
74
+ never scoped to: it guards the temporary path, and the temporary path is
75
+ `dirname(dest)`, i.e. already inside whatever directory the link chose. So
76
+ both held perfectly while the whole write happened somewhere it should not.
77
+ **The scope is the part that is not inherited from `init`.** `init` owns its
78
+ destinations, so containment there is unconditional; `--out` is an argument,
79
+ and `--out ../site/SPEC.md` is a destination the caller can see they chose.
80
+ Refusing it would be refusing the flag. The check therefore applies exactly
81
+ where the argument stops being evidence of where the file goes: a path that
82
+ reads as staying inside the project. It is asked against the root in both
83
+ spellings, lexical and resolved, because they differ whenever the project
84
+ itself sits under a link — testing only the resolved one would skip the check
85
+ for every such checkout, failing open on precisely the layout that has links
86
+ in it.
87
+ **`--check` goes through the same resolution**, which the filed entry did not
88
+ cover. It reads the destination rather than writing it, and a freshness gate
89
+ comparing a fresh document against a file outside the project answers about
90
+ the wrong document — quietly, since both verdicts it can reach are ones it
91
+ reports normally.
92
+ So this is `resolveDest` gaining its second caller rather than a new
93
+ indirection; by this repository's own rule it was a hypothetical seam with one
94
+ adapter and is now a real one. `changes/<name>/` remains the candidate third
95
+ and stays unmeasured.
96
+ **The repair moved the write, not just the check, and the reason is the shape
97
+ of the defect rather than tidiness.** `render`'s `writeAtomic` was the only
98
+ call to it outside `core/` — `runInit` writes in the pipeline, `merge.ts`
99
+ writes in the pipeline, and this one write sat in the CLI, which is exactly
100
+ how it reached the filesystem without the resolution `runInit` had performed
101
+ since `[0.7.3]`. Guarding it in the shell would have left that intact: the
102
+ obligation to call the check would still belong to a caller who can forget it,
103
+ which is the situation `pipeline.spec.ts`'s compiler-guard gate already exists
104
+ to catch elsewhere ("delete the line and every scenario stays green"). So
105
+ `runRender` takes the destination and owns the write, `runRenderCheck`
106
+ resolves the path it reads, and the resolution is **private to the module** —
107
+ there is no longer an entry point that writes a document without resolving
108
+ where it goes. The CLI lost its `writeAtomic` import, and with it the last
109
+ policy decision it was making; `hasError` before the write is now what stops a
110
+ broken registry overwriting a good committed document, stated where the write
111
+ is. No existing test changed: `runRenderCheck` keeps its signature, because
112
+ `target` already carried the spelling a diagnostic echoes and asking a caller
113
+ for it twice is a clump waiting to disagree with itself.
114
+ Two things the move surfaced that a guard bolted onto the shell would have
115
+ hidden. **The compiler guard has to stay the first statement**, which
116
+ delegation gave `runRenderCheck` for free and a resolution placed above it
117
+ quietly took away — an unsupported TypeScript would have come back as a path
118
+ refusal. Both entry points call `compilerIssue` first now, and
119
+ `runRenderCheck` leaves the exemption table in `tests/pipeline.spec.ts`, which
120
+ is what that table is for: a list of exemptions exists to be decided about
121
+ rather than to drift. And **"never overwrite a good document with the output
122
+ of a broken registry" was a comment with nothing behind it** — the write lived
123
+ in the CLI, where no test could reach it. It is asserted now, where the write
124
+ is, and is red without the guard: a half-loaded registry renders a document
125
+ that silently omits requirements, so overwriting the committed one destroys
126
+ the only correct copy and the next `--check` passes against the damage. The ATX-73 scenarios drive
127
+ the binary, so they are what proves the move changed no behaviour, and three
128
+ mutants confirm they still bite: writing without resolving, refusing every
129
+ path that leaves the root, and `--check` skipping the resolution are each red.
130
+ Stated as `ATX-73` with three scenarios: the write path and the `--check` path
131
+ both driven through the real CLI against a project carrying a planted link —
132
+ spawned rather than called, because the defect was never in the resolution but
133
+ in the write not going through one, so a scenario calling `resolveOutFile`
134
+ directly would have passed against the broken build — and a third holding the
135
+ scope, that a destination spelled outside the project is still written where
136
+ it says. All three are red against the previous build.
137
+ One further behaviour change falls out of resolving rather than checking, and
138
+ it is a repair rather than a cost: a link *inside* the project is now
139
+ **followed** instead of replaced. `rename` used to overwrite `docs/SPEC.md ->
140
+ build/SPEC.md` with a regular file while `--check` read straight through it,
141
+ so the two halves of the gate disagreed about which file the document was;
142
+ measured, the link now survives and `build/SPEC.md` receives the bytes.
143
+ **This turns a passing run red for anyone whose `--out` currently resolves
144
+ through a link out of the project**, which is the behaviour change that
145
+ matters and is the point of it. No
146
+ `SCHEMA_VERSION` bump: `unsafe-target-path` is an existing code reported by an
147
+ additional command, `src/cli/json.ts` is untouched, and no field moves.
148
+
149
+ - **`--apply` wrote the registry's own file name into a spec's import as text
150
+ rather than as a string, so a registry whose name carried a quote closed the
151
+ literal and everything after it became code.** `splice.ts` states that every
152
+ generated byte is escaped — `tsString` for statement, rationale and
153
+ `outOfScope`, `keySource` for param keys, a `throw` rather than a guess for
154
+ `__proto__` — and `repointImport` was the one site that interpolated instead.
155
+ What it interpolated is the value the *checked repository* names: `to` is the
156
+ registry file `--apply` decided the ids belong in, and `isReqsFile` tests only
157
+ the suffix.
158
+ **The deletion test says what this was worth, and it is not execution.**
159
+ `archive --apply` runs the suite in the same invocation, so the attacker
160
+ already executes there. What the injection added is **persistence**: the
161
+ payload lands in a `*.spec.ts` that the merge then commits, and runs on every
162
+ later clone and CI job, inside a diff `--apply` advertises as a rename plus one
163
+ specifier. **Not platform-scoped** — Windows rejects `\ / : * ? " < > |` in a
164
+ file name and neither `'` nor `;` is among them.
165
+ Re-measured before a line was written, as this file's discipline asks, and the
166
+ filed claim held with one correction it did not have: the payload must carry
167
+ **the file's own quote character**, so the double-quoted spec survives a `'`
168
+ and vice versa. That is why the repair is not "escape `'`". `tsString` splits
169
+ into a `tsStringBody(value, quote)` that is *told* which quote it writes
170
+ between, and the specifier edit passes the quote already in the file — the
171
+ file's quote style stays exactly as it was, which is the property that ruled
172
+ out `JSON.stringify` here in the first place. No refusal path was needed:
173
+ escaping has an answer for every name, since a specifier is a string and
174
+ `tsStringBody` can spell any string as one, so `repointImport` still cannot
175
+ throw and `merge.ts` keeps the one `UnwritableValue` site it had.
176
+ The regression is asserted as a **round-trip** rather than against escaped
177
+ bytes — the file still parses, carries its original four statements, and the
178
+ specifier reads back as the path — under both quote styles, because escaping
179
+ the wrong quote is as wrong as escaping neither. Both cases are red against
180
+ the previous emitter.
181
+ Stated as `ATX-72` with a scenario per quote style, driving the real
182
+ `--apply` over a project whose registry file is named with the payload. Its
183
+ first draft is worth recording because a gate caught it and not a reviewer:
184
+ written as a quantifier over every value the emitter writes, the `sites` table
185
+ in `tests/intent-rule-candidates.spec.ts` demanded a scenario per site and
186
+ found three the sentence claimed which no `self/` scenario reaches. The hole
187
+ was in the *statement*, not the coverage — the other three sites are attested
188
+ in `tests/splice.spec.ts` and were never part of this change — so the repair
189
+ was to narrow the sentence to the one value the checked repository names.
190
+ §7 gains the general form, since it already carried the emitter's rule ("it
191
+ may emit no text whose evaluation differs from the value it was handed") and
192
+ carried it as a rule about the *registry*: `--apply` writes a second file, and
193
+ a file name is not something a tool is in a position to restrict, so it is
194
+ something the writer is obliged to escape.
195
+ No `SCHEMA_VERSION` bump: `src/cli/json.ts` is untouched, no code or field
196
+ moves, and no valid input changes behaviour — for any registry not named to
197
+ attack, the bytes `--apply` writes are the bytes it wrote before.
198
+
199
+ - **A registry file that swaps its prototype was read as the requirements the
200
+ prototype carried, under the reader that evaluates.** `{ '__proto__': { … } }`
201
+ written as a literal is a swap, not a key: the object has **no own keys at
202
+ all**, so what the loader reported never depended on the swap but on what
203
+ happened to be behind it. The static reader refuses such a source outright as
204
+ `registry-not-static` and always did — the two readers agreeing is a security
205
+ property, not a convenience — so this was the two of them disagreeing about
206
+ whether a file is a registry at all.
207
+ **A fixture for exactly this existed and was passing for the wrong reason.**
208
+ `tests/static-registry.spec.ts` put *malformed* ids on the prototype, and zod
209
+ v3's record enumerates with `for…in`, which walks the chain — so those
210
+ inherited keys were tested against the id grammar and failed it. The red was
211
+ incidental to the swap, and the whole guarantee rested on it.
212
+ Put a **well-formed** id on the prototype instead and the same shape is
213
+ **accepted**, with the entire registry built out of a prototype the source
214
+ does not declare. That is measured on the zod this release ships with, not
215
+ inferred: the new differential case is red without the guard and the old
216
+ fixture stays green beside it, which is the defect and its camouflage in one
217
+ run. Found while evaluating the move to zod v4 — which enumerates own keys
218
+ only, so the swapped object is simply empty and even the malformed fixture
219
+ goes green — but the acceptance is reachable today and is fixed here, ahead of
220
+ that port and independently of it.
221
+ Refused now by the `isPlainObject` guard `params` has carried since a param
222
+ became any JSON value, applied one level up to the registry itself — **stated
223
+ over the shape rather than over the name `__proto__`**, because a blocklist of
224
+ names is a second answer to "is this data" and the guard is already the first.
225
+ A class instance and a `Date` fail it for the same reason. `registry-invalid`
226
+ rather than a code of its own: the obligation is identical to every other way
227
+ the schema refuses a registry, and so is the reader's situation.
228
+ Stated as `ATX-70` with three scenarios, and the fixture the suite was missing
229
+ — the well-formed-id swap — is now the one the differential suite runs.
230
+
231
+ ### Changed
232
+
233
+ - **The guidance now says which quantifier to write, where before it only said
234
+ what a scenario must read.** `[0.7.0]` added the `QUANTIFIED` table after
235
+ `ATX-37` — "**everything** the CLI writes to a terminal MUST replace every
236
+ control character" — was found to have scenarios for the two formatters that
237
+ existed the day it was written, while `render` built a document by
238
+ concatenation that never went past the sanitiser. What the table taught was
239
+ never written down anywhere an author would meet it, and it is the more useful
240
+ half of that work: **which surface a quantifier ranges over decides whether
241
+ scenarios can keep up with it**, and there are three, not the two the original
242
+ proposal assumed. Over *values* one site processes, representative inputs
243
+ settle it. Over a *roster* the code enumerates, a scenario looping that roster
244
+ covers a new member by construction. Over *sites* in the implementation, each
245
+ needing a hand-written scenario, a new site is a new obligation and nothing
246
+ enumerates them — the only one of the three that can silently stop being true,
247
+ and what `ATX-37` was. So the rule is *prefer a quantifier a scenario can
248
+ iterate*.
249
+ *Where it landed.* Design §11 as a residual weakness, beside the two `params`
250
+ bullets it completes — those say what an assertion must do with a value it
251
+ read; this says what the statement above them should quantify over in the first
252
+ place. The `init` skill gets the adopter-facing form in the same model section
253
+ as those two rules, so a project adopting Attest is told before it writes the
254
+ statement rather than after the gap is found by reading. Both languages, one
255
+ commit, per the reason `[0.5.0]` records for doing this shape of change in one
256
+ pass.
257
+ *No engine change, and the entry stays open for the one that is still not
258
+ proposed.* Which surface a sentence names is prose judgement — the ground truth
259
+ §0 refuses to compute for someone else's requirements — and the detector that
260
+ exists flags the quantifier only, missing the article form ("A registry that
261
+ cannot be read MUST …"). What ships here is counter-pressure, which is what §11
262
+ now says: a hand-judged table for this registry, plus a rule an author can
263
+ follow. The carve-out is carried with it, because it is the failure mode of any
264
+ such table: rewriting a statement to name its surfaces takes it back out of the
265
+ flagged set, so the table admits rows for ids nothing flags.
266
+
267
+ - **Zod moves from v3 to v4 (`zod@^3.23.0` → `^4.4.3`).** The reason is supply,
268
+ not speed: v3 is in maintenance, so fixes land on v4. The port of the schema as
269
+ it stood was the **one API change** the plan predicted — `errorMap` → `error` —
270
+ with `z.custom().pipe()`, `z.record`, `z.lazy` and the recursive `paramValue`
271
+ all carrying over unedited, and `red-record.ts`'s `z.ZodType` untouched. A
272
+ `as z.ZodType<ParamValue>` on `paramValue` became unnecessary and is gone. The
273
+ count is one *for the schema the plan measured*: the prototype guard added in
274
+ the commit before this one brought a second options bag with it, so the port
275
+ actually touched two — which is the ordinary way a plan's cost estimate goes
276
+ stale, by the code moving under it rather than by the estimate being wrong.
277
+ **The plan's "no `package.json` change" framing was about the evaluation, and
278
+ taking it as the destination would have shipped a broken package.** It rested
279
+ on `zod@3.25.76` exporting a `./v4` subpath — true of the *installed* version
280
+ and not of the *declared range*: `zod@3.23.8` is inside `^3.23.0` and exports
281
+ no `./v4` at all, so importing `zod/v4` while keeping that range yields a
282
+ package that fails to resolve on a legitimate install of its own dependency.
283
+ It is a real bump or it is nothing.
284
+ *Re-measured here, and the entry's central type claim does not survive.* The
285
+ plan recorded instantiations as **unchanged** (27,681 → 27,671). On this
286
+ codebase they go **36,211 → 89,831**, about 2.5×, with build check time
287
+ 0.58 s → 0.71 s. Controlled for the prototype guard, which accounts for 292 of
288
+ that — the growth is the port. The consumer-side `.d.ts` footprint is worse
289
+ than predicted too: **14 files / 68 KiB → 80 files / 217 KiB** (the plan said
290
+ 66 / 181 KiB), and a consumer's own instantiations 71,935 → 131,044. Runtime
291
+ goes the other way and by less than advertised: parsing this registry is
292
+ **0.873 ms → 0.143 ms**, 6.1× rather than 13×, and worth ~0.7 ms against the
293
+ hundreds `attest check self` takes. None of it blocks a migration whose reason
294
+ is supply, and all of it is recorded because "v4 is also faster to typecheck"
295
+ is the sentence that would otherwise reach the commit message — and on this
296
+ schema it is false in a way the plan did not have: the plan called the type
297
+ side neutral, and it is a cost.
298
+ *One diagnosis regression, found and repaired.* A v4 record reports a bad key
299
+ as "Invalid key in record" and does not surface the key schema's own message,
300
+ so a mistyped requirement id — the commonest way to fail this schema — stopped
301
+ being told the grammar it had missed. The record now carries an explicit
302
+ `error`, which restores it.
303
+
304
+ - **`RegistryValidationError.issues` has a shape of this project's own, and no
305
+ longer exposes `z.ZodIssue`.** Zod was otherwise fully absorbed — `Requirement`
306
+ and `Registry` are `z.infer` aliases, so a consumer resolves them to plain
307
+ object types and never names the library. This field was the single exception,
308
+ and it was the load-bearing one: it is reached through the `./define` subpath
309
+ that `defineRequirements` is imported from, and `ZodIssue` is a discriminated
310
+ union zod reshaped between v3 and v4. So a dependency bump this project should
311
+ have been free to make was a **breaking change for anyone who had destructured
312
+ the field**. The element type is now `RegistryValidationIssue` — `path:
313
+ PropertyKey[]` and `message: string`, exported from the same subpath — which is
314
+ the same boundary `Issue.message` already holds, with the difference that
315
+ `Issue` was designed with the boundary and this was not.
316
+ **Breaking, for the narrow surface that read past those two fields**, which is
317
+ why it is a minor under 0.x. Nothing in this repository was in that surface:
318
+ re-measured before a line was written, the only two readers are `formatIssues`
319
+ in `registry.ts` and `firstMessage` in `apply.ts`, and **nothing anywhere reads
320
+ `code`, `expected`, `received` or `fatal`** — which is what made this a rename
321
+ rather than a redesign. A consumer that only formats what it is given, as both
322
+ of those do, sees no change at all.
323
+ *`path` is `PropertyKey[]` rather than `(string | number)[]` deliberately.* It
324
+ is the wider of the two spellings zod has used, so a validator failure assigns
325
+ to it under either — which is the whole point, since the release that moves
326
+ that type is the one this change exists for.
327
+ *One thing the plan glossed, found by doing it.* The two formatters were said
328
+ to read the same two fields, and they did — but not the same way: `apply.ts`
329
+ spelled the path `.map(String).join('.')` and `registry.ts` spelled it
330
+ `.join('.')`. Under `(string | number)[]` those are identical; under
331
+ `PropertyKey[]` the second **throws** on a symbol key, because `join` cannot
332
+ convert one. The shared formatter uses the safe spelling. `ATX-44` is what made
333
+ the rest checkable rather than hopeful: the `a.b.c` path spelling is a stated
334
+ requirement with a scenario on it, so the property that had to survive already
335
+ had something asserting it.
336
+
337
+ - **`changes/<name>/first-run.json` moves to `version: 2`, and a version-1 file
338
+ is discarded whole.** The record now nests the scenario name under the spec
339
+ file, so the old shape cannot be read as the new one. Discarding is the safe
340
+ direction — an empty record makes every obligation "never observed", which the
341
+ gate blocks on — and the cost is one stage-1 run to re-observe, for a change
342
+ in flight across this upgrade only. The version is *checked* rather than
343
+ inferred from the old shape failing the schema: it would fail it anyway, but
344
+ "discarded because the format moved" and "discarded because the file is
345
+ corrupt" are different facts, and reading one as the other is how a format
346
+ change happens without anyone deciding to make it. Unrelated to the report
347
+ `schemaVersion`, which does not move — `src/cli/json.ts` is untouched, and no
348
+ envelope, field or `code` changed.
349
+
350
+ ### Fixed
351
+
352
+ - **A scenario that never ran was reported as attested, when another spec file
353
+ declared a scenario of the same name under the same `requirement()`.** Runtime
354
+ coverage was keyed `(reqId, scenario name)` with no file, so two spec files
355
+ declaring `scenario('x')` under `requirement('APP-1')` were one entry, and
356
+ whichever of them executed vouched for both. `declared-not-run` — the check
357
+ whose whole job is to catch a declared scenario that did not execute — was
358
+ answering about the wrong scenario, and answering "fine".
359
+ Reproduced on 2026-08-09 with no flag, no `--eval` and default configuration:
360
+ a `real.spec.ts` whose `scenario('x')` runs beside a `ghost.spec.ts` whose
361
+ `scenario('x')` sits in a branch that never registers gives
362
+ `✓ No issues. — 1 requirement / 2 scenarios`. Renaming the ghost to `'y'`
363
+ produces the `declared-not-run` naming it, so the collision was the whole of
364
+ the difference — the check was present and was looking at the wrong key.
365
+ **This is the failure this tool exists to refuse**, which is what put it ahead
366
+ of everything else in flight: a report that says a scenario attested a
367
+ requirement when the scenario did not execute.
368
+ **The same key reached the first-run record, and there it is worse.** The
369
+ record is monotonic toward `fail`, so two scenarios of one added requirement
370
+ sharing a name meant one going red satisfied the other's `never-red`
371
+ obligation — mechanism 2 certifying a discrimination that never happened.
372
+ That half was inferred from the shared key when this was filed and is measured
373
+ here: both halves go red independently under control, which is why `ATX-71` is
374
+ two obligations rather than one.
375
+ Fixed by carrying the file into the key rather than by adding a check. `walk`
376
+ already descends from the file task, which is the only level holding a path,
377
+ and `ParsedScenario` has carried `file` since it existed — so both sides of
378
+ design §5.4's one cross-process channel had the field and only the maps
379
+ forgot it. It goes through `relativePath` for the reason `unloadedFiles` does:
380
+ the key is compared against a plan built on POSIX paths, and a native
381
+ separator on Windows would turn this false green into a false red.
382
+ Nested (`reqId -> file -> name`) rather than a composite key string, at both
383
+ ends: no separator has to be chosen that a path or a scenario name could
384
+ contain, and `first-run.json` stays a file a reviewer reads.
385
+ **What the fix cost, and the alternative that was rejected.** Keyed by the
386
+ name on disk, the record broke `--apply`'s resume: step 2 renames
387
+ `x.proposed.spec.ts` to `x.spec.ts`, so a resume after that rename looked up a
388
+ key written under the proposed name, found nothing, and blocked a change whose
389
+ scenarios *had* been observed red — the gate refusing its own evidence, caught
390
+ by `ATX-53`'s two resume scenarios. Rewriting the record beside the rename was
391
+ the obvious repair and is worse: two files that must move together, in a
392
+ design whose entire resume story is that each step is separately idempotent.
393
+ So the record keys by the **merged** spelling from the start, which the rename
394
+ cannot move. It still separates two spec files, which is the collapse the key
395
+ exists to prevent; what it deliberately does not separate is a file from its
396
+ own proposed name, because those are the same file.
397
+ Not claimed: that a scenario name is unique within one file. It need not be,
398
+ and this key does not make two same-named scenarios in one file
399
+ distinguishable. What it closes is the cross-*file* case, which is the one
400
+ reachable by ordinary means — two authors adding a scenario to the same
401
+ requirement from two files is how a requirement grows, while a duplicate name
402
+ inside one file is visible in the file being read.
403
+ Measured on Linux; **Windows is unmeasured**, and the path-shaped half of the
404
+ fix is exactly what differs there.
405
+
406
+ - **An error class was being used as a formatter, at two sites that never threw
407
+ it.** `locate.ts` and `static-registry.ts` each constructed a
408
+ `RegistryValidationError` purely to borrow its `.message`, because both report
409
+ the same `registry-invalid` as the throwing path and the message has to be
410
+ identical rather than merely similar. The formatter is what those sites wanted
411
+ and it is now the thing they can ask for — `registryValidationMessage` in the
412
+ new `core/registry-issues.ts`, which all three sites share. That sharing is
413
+ what makes the messages structurally identical instead of a coincidence
414
+ maintained by hand.
415
+ The module is separate from `registry.ts` rather than added to it because
416
+ `./define` maps to the whole of `registry.ts`, so everything that file exports
417
+ is a public contract. The issue *type* has to be public; the formatter does
418
+ not, and both of its callers are internal.
419
+
420
+ ### Added
421
+
422
+ - **`tests/registry-error-types.spec-d.ts` pins the shape that was just
423
+ narrowed.** The property is entirely type-level — it has no runtime footprint,
424
+ and the engine reads registries from the AST — so a `.spec-d.ts` is the only
425
+ thing that can catch a regression in it, and a green suite would otherwise
426
+ carry one through to users untouched. It pins both halves: that the element
427
+ type is exactly the two fields (`toEqualTypeOf`, so a future edit widening it
428
+ back to a union fails), and that a real parse failure still assigns to it with
429
+ no cast. Confirmed to go red on the exact regression it exists to catch by
430
+ reverting the field to `z.ZodIssue[]` and re-running it.
431
+
432
+ ## [0.7.4] - 2026-08-08
433
+
434
+ ### Added
435
+
436
+ - **`ATX-37`'s three unattested sites are closed, and the sweep that was going
437
+ to close them is rejected instead.** The requirement says *everything* the CLI
438
+ writes to a terminal replaces the control characters in text it quotes from
439
+ the project under test, and the `QUANTIFIED` table had carried four `null`
440
+ rows against it since `[0.7.0]`: the status rows, the coverage table, the
441
+ archive verdict, and the path `--out` echoes. Three of those are formatters
442
+ interpolating a reqId, a change name or a written path — correct by reading,
443
+ attested by nothing, which is the state a `sites` quantifier is the only kind
444
+ able to reach in silence. Three scenarios now drive them with hostile input,
445
+ covering six interpolations, and each was **proven able to go red by injection
446
+ before being believed**: `inline` removed from the status row's id, from the
447
+ merged path, from the archive verdict's change name, and from the coverage
448
+ row's id — four defects, each failing exactly one scenario and no other. The
449
+ fourth `null` stays: `--out` needs a filename holding a control character,
450
+ which NTFS refuses and Linux permits, and an unmeasured half is what a `null`
451
+ records.
452
+ **A fifth injection is why the archive verdict is asserted three times.**
453
+ Reading the diff caught what the site list does not resolve to: `change` is
454
+ interpolated by three separate expressions in that one function — merged,
455
+ archivable, and gate-failed — and the first draft covered two of them, leaving
456
+ `inline` deletable from the third with the whole suite green. A `sites` row
457
+ names sites at the granularity a sentence uses, and a branch is finer than
458
+ that; the injection is what tells the two apart.
459
+ **Re-measuring the proposal before building it is what split it in two**, and
460
+ the figures moved in both directions. `src/cli/report.ts` has 8 exports of
461
+ which **7** are `format*` — the eighth is `summarize`, so the entry's "eight
462
+ formatters" was already one off its own wording. The "eight fixtures, one per
463
+ signature" was worse: there are 7 distinct parameter types, `Issue[]` is
464
+ shared by three functions, and the honest cost of the fix turned out to be two
465
+ fixtures extending ones `self/report.spec.ts` already builds. And the entry
466
+ **undercounted its own benefit** — "three currently-correct sites" is three
467
+ *formatters* carrying six interpolations.
468
+
469
+ - **The `✗` samples are bound to a fixture whose rejection the suite asserts.**
470
+ A page showing input the engine must *reject* was the one kind of sample
471
+ nothing could see going stale: it carries no version, names no `src/` symbol,
472
+ and keeps its page's structure perfectly, so every gate in
473
+ `tests/docs-consistency.spec.ts` passed while the claim underneath it aged.
474
+ The failure that mattered is asymmetric — a stale runnable sample stops
475
+ working when a reader pastes it, while a stale counterexample is a reader
476
+ taught a rule that is no longer a rule, which is worse than teaching nothing.
477
+ A `✗` annotated with a code the engine can emit is now bound to a fixture,
478
+ byte-equal the way `fixtures/consumer/` is bound to the README, and the
479
+ fixture is **run through the reader the documented command uses** — the code
480
+ and the line it produces are compared with the code and the line the page
481
+ shows. The line is the half that catches the shape a code-only assertion would
482
+ not: a fixture still rejected, for a different reason, having quietly stopped
483
+ demonstrating what it is quoted for.
484
+ **Re-measuring the entry before building it moved its cost by a factor.** It
485
+ was filed as the expensive half, "a fixture per diagnostic". Of the 33 fenced
486
+ `ts` blocks in the README and `docs/en/**`, 8 carry a `✗`, 3 name a code the
487
+ engine can emit, and 1 of those 3 is elided (`duplicate-prefix`, taught as two
488
+ one-line registries whose bodies are `{ /* … */ }`) — leaving one sample,
489
+ quoted on two pages, bound to one fixture. The count was never the reason it
490
+ sat: what it was really waiting for is the thing that makes the roster hold,
491
+ which is that the binding is a **sweep** rather than a list. A new `✗ code`
492
+ block on any English page is bound to a fixture or fails as `unbound`, so the
493
+ next diagnostic pays a fixture because it is obliged to, not because someone
494
+ remembered the roster existed.
495
+ **Proven able to go red before being believed**, since a gate over samples
496
+ that are all currently correct has never failed and cannot be trusted until it
497
+ does: four mutations, one per way the binding can rot — the page edited away
498
+ from the fixture, the fixture made static so the engine accepts it, a `✗`
499
+ sample added bound to nothing, and the defect moved to a line the annotation
500
+ does not mark. Each was reported naming the page or the fixture.
501
+ *Two limits, both structural.* **English only**: `docs/zh/**` matches
502
+ `docs/en/**` down to the comments inside its samples, so byte-equality is a
503
+ claim only one side can carry, and what covers the Chinese side is the parity
504
+ gate from `[0.4.3]` holding the code-sample sequences equal — structure, not
505
+ meaning. And an **elided block is excluded by rule**, not by omission from the
506
+ roster: `{ /* … */ }` is what makes that sample readable and also what makes it
507
+ unrunnable. The residual risk recorded in `[0.4.3]` does *not* apply here —
508
+ the byte gate and the run proving the fixture still fails are in the same
509
+ file, so a fixture that stopped being rejected cannot pass by living in a
510
+ suite nobody connected to the page.
511
+
512
+ - **`ATX-15` is swept over `src/` from the AST, rather than attested one site at
513
+ a time.** The requirement quantifies over *sites* — every ordering committed to
514
+ disk or compared for equality — and `sites` is the one shape the quantifier
515
+ table records as able to stop being true in silence: a new site is a new
516
+ obligation and nothing enumerates them. Two of its five sites were `null`:
517
+ `red-record.ts` sorts ids and scenario names into `first-run.json`, and
518
+ `splice.ts` sorts ids and param keys into the registry file `--apply` writes.
519
+ Both are committed to disk, both were correct, and neither had a scenario. A
520
+ scenario per site would have closed those two and left the shape untouched,
521
+ because site six would have been unattested the day it was added. The sweep
522
+ instead makes the roster one the **code** owns — every `.sort`/`.toSorted` in
523
+ `src/` is a member — so a new ordering site is covered by construction. The
524
+ sanctioned comparator, the sort methods and the banned APIs are `ATX-15`
525
+ params, read by the scenario, pinned against literals for the reason the
526
+ domain table states: shrinking a list a scenario iterates removes a case and
527
+ everything left still passes.
528
+ **Stated narrowly enough to be true: it constrains how a site orders, not that
529
+ it orders.** A `red-record.ts` that stopped sorting altogether is still green
530
+ here, and the three hand-written scenarios are what speak to the result. That
531
+ is the honest limit of what a sweep can prove, and the table records it rather
532
+ than letting the row read as fully discharged.
533
+ **It was proven able to go red against the real corpus before being believed** —
534
+ three injected defects, one per shape the rule admits: a `localeCompare`
535
+ comparator in `red-record.ts`, a bare `.sort()` in `splice.ts`, and a
536
+ hand-rolled comparator in `merge.ts` that never reaches `byCodeUnit`. Each was
537
+ reported with its file, line and offending source. A gate asserting a property
538
+ that already holds everywhere has never been red and cannot become so until it
539
+ fires, so a negative control is not optional for it; a second scenario keeps
540
+ one in the suite permanently, including the case a line-wise scan gets wrong.
541
+ **Re-measuring the proposal's figures before building it changed two of
542
+ them**, which is the section's own rule and the reason it is worth restating:
543
+ the entry claimed 21 `.sort` call sites (there are **20** — the 21st was a
544
+ comment, which a regex counts and an AST does not), and two exceptions to
545
+ "names `byCodeUnit` on the call line" (there are **six**: one delegating to a
546
+ local `compareIds`, five inline arrows). The exception count being 3× what was
547
+ written is what made following local delegation part of the rule rather than a
548
+ footnote. The `localeCompare` figure moved the same way and in the direction
549
+ that matters most: **both occurrences in `src/` are comments explaining why it
550
+ is not used**, so a line-wise regex would have opened at 100% false positives.
551
+ Reading from the AST was the entry's own recommendation; it turns out to have
552
+ been the difference between a gate and a nuisance.
553
+ One shape the rule admits was not in the proposal and is worth naming:
554
+ `report.ts` sorts by `b.count - a.count || byCodeUnit(…)`. A numeric term is
555
+ legitimate, so the rule is "every comparator **reaches** the sanctioned one",
556
+ never "sorts only by code unit" — a stricter rule would have been red on
557
+ correct code the day it was written.
558
+
559
+ ### Fixed
560
+
561
+ - **`attest check` answered `✓ No issues.` for a `*.reqs.ts` that does not
562
+ compile.** TypeScript's parser is error-tolerant by design: handed a file
563
+ truncated before its closing `});` it recovers the object literal and returns a
564
+ tree anyway, so the static reader read it as a *complete* registry and reported
565
+ nothing at all — while `--eval` on the same bytes said `registry-invalid`. Two
566
+ readers of one file disagreeing about whether it is even a file is the
567
+ condition the differential suite exists to prevent, and that suite could not
568
+ have caught it: it runs over registries that all compile. Nothing was looking
569
+ at `parseDiagnostics`, which is the one field that says so.
570
+ **The reach is three readers, not one**, because the tolerance belongs to the
571
+ compiler rather than to any reader: `parser.ts`, `static-registry.ts` and
572
+ `splice.ts` all open with the same `createSourceFile` call. The same half-saved
573
+ file was also a spec whose scenarios above the break still counted as coverage,
574
+ a delta whose ids `status` still reported, and — the site that writes rather
575
+ than reports — an insertion offset `attest apply` would have spliced a new
576
+ requirement into. All four now refuse it: the three readers as
577
+ `unreadable-file`, and the insertion point by returning the `undefined` it
578
+ already documents as its refusal to guess, because a write path that throws
579
+ mid-merge is worse than one that declines.
580
+ The diagnostic carries the **line** the syntax error is on, through both
581
+ shapes of refusal — the readers return it, and the spec parser carries it
582
+ through the throw its guard already catches. Without that second half the same
583
+ code would have had a line for a registry and none for a spec, for one
584
+ condition.
585
+ `unreadable-file` rather than a new code: `ATX-65` already states this
586
+ obligation for a file the parser cannot read, and the reader's situation is
587
+ identical — nothing in the file was checked, everything else in the run still
588
+ was. What `ATX-65` did not state is the *detection*, which is the whole of what
589
+ was missing; it was written for a parser that throws, and a parser that quietly
590
+ succeeds needs a different question asked of it.
591
+ **Syntactic only, and that is what makes it safe rather than merely narrow.**
592
+ `parseDiagnostics` is the parser's own list, so a type error, an unresolved
593
+ import and a name that does not exist are all absent from it — Attest does not
594
+ typecheck an adopter's project and this does not start. Measured over the 129
595
+ TypeScript files in this repository with every fixture included: zero carry a
596
+ parse diagnostic, so nothing valid today starts failing. The one reader left
597
+ deliberately tolerant is the id recovery added above, which runs only on files
598
+ a reader has already refused.
599
+
600
+ - **Two scenarios named by a template literal were invisible to the static
601
+ plan.** `self/half-loaded.spec.ts` generated one scenario per command, and the
602
+ parser reads names from the AST — so `cover` counted one scenario where two
603
+ ran, and `archive`'s never-red gate could never have seen the second. Found
604
+ while binding the new requirement's roster to a scenario the plan declares.
605
+ Both are now one scenario looping the roster, which is the shape `ATX-64`
606
+ already used; the label on each `expect` is what the generated name was buying,
607
+ and it survives a failure. **The engine is not changed**: a name a static
608
+ reader cannot resolve is not a defect in the reader, and the failure it
609
+ produces on a requirement covered *only* that way is `uncovered-requirement`,
610
+ which is loud.
611
+
612
+ ### Changed
613
+
614
+ - **One unreadable `*.reqs.ts` reported a finding per scenario that attested it,
615
+ and the finding was about the file.** The ids that file declares are missing
616
+ from the run, so every scenario covering one of them was an `orphan-test` —
617
+ measured on a registry of three requirements: six ERRORs, of which one was the
618
+ cause and five were its shadow, with the true finding at the top where the wall
619
+ of red pushes it out of view. Those scenarios are now reported once per
620
+ registry file as `orphan-from-failed-registry` (WARNING), naming the file, the
621
+ count and the ids; the same case is now two ERRORs and one WARNING. A WARNING
622
+ because the load failure is already an ERROR and is present whenever this is,
623
+ so no verdict moves and this cannot make a broken registry pass.
624
+ **What unblocked it was that the question was framed wrongly**, and the entry
625
+ under `Under consideration` had carried that framing for two releases: it asked
626
+ for a prefix→file mapping, which is exactly what a file that never parsed
627
+ cannot give — and could only have been recovered from the file *name*, which
628
+ this repository is its own counterexample to (`attest.reqs.ts` holds `ATX-*`,
629
+ the evidence that killed the prefix-matches-filename rule). The **ids** are
630
+ written in the source, and reading them back needs no naming convention that
631
+ nothing enforces. It is also strictly more precise than the prefix would have
632
+ been: a genuine typo sharing a prefix with the broken file stays reported.
633
+ Recall is partial and the direction of the miss is why that is acceptable — an
634
+ id not recovered keeps its `orphan-test`, so the report stays noisy and never
635
+ wrong. Measured over the ways a registry fails: a truncated file, a missing
636
+ default export, a non-literal value, a schema-invalid entry, a module that
637
+ throws at import and a spread from another module all yield their ids; only a
638
+ registry the file *computes* yields none, and that source contains no id to
639
+ find. The recovery is a source read that returns ids and nothing else — it
640
+ builds no registry, so it is not a second reader and cannot become the
641
+ evaluation fallback `registry-not-static` refuses.
642
+ Stated as a replacement rather than a suppression for the reason `ATX-62`
643
+ records about `spec-load-failed`: suppressing with nothing in its place trades
644
+ a wrong message for silence, and a wrong message beats silence.
645
+
646
+ - **The hedged `orphan-test` advice added in `[0.7.0]` was applied to every
647
+ orphan in the report, including the ones it does not describe.** Any registry
648
+ file failing to load switched *all* of them to "a registry file failed to load
649
+ — fix that first, and add this id only if it is still unknown afterwards",
650
+ so a genuine typo was told to wait for work that has nothing to do with it.
651
+ The hedge now fires only where the uncertainty is real: when a failed file's
652
+ ids could not be recovered. When they all could, an orphan none of them claims
653
+ is genuinely unknown and gets the plain advice back.
654
+
655
+ `SCHEMA_VERSION` is unchanged: a new `code` value is additive, and no envelope
656
+ field moved.
657
+
16
658
  ## [0.7.3] - 2026-08-07
17
659
 
18
660
  ### Fixed
@@ -3647,33 +4289,116 @@ about itself, which is three things.
3647
4289
  **An entry can sit here for releases on the strength of a figure nobody has
3648
4290
  checked.** The discipline aims at keeping the *argument*, not at re-testing the
3649
4291
  evidence under it, so the first act of building one is re-measuring what it
3650
- claims rather than implementing what it proposes. That has paid twice: once
3651
- killing an entry outright, when the spans it rested on turned out not to be
3652
- spans; once correcting an entry's headline number before a line of it was
3653
- written. **What a release costs its adopters is not known on the day it is cut**
4292
+ claims rather than implementing what it proposes. That has paid three times:
4293
+ once killing an entry outright, when the spans it rested on turned out not to be
4294
+ spans; four times correcting an entry's own numbers or scope before a line of it
4295
+ was written a call-site count that a regex had inflated, a cost filed as "a
4296
+ fixture per diagnostic" that the corpus put at one fixture, an injection whose
4297
+ payload turned out to need the target file's *own* quote character, which is the
4298
+ difference between escaping one quote and escaping the right one, and a fix
4299
+ filed as a map over `issues` that the report's own shape moved to the
4300
+ serialiser, four fields and one nesting level further out. **What a release costs its adopters is not known on the day it is cut**
3654
4301
  — several entries arrived from one adoption report, about shapes the tool made
3655
4302
  someone work around without ever erroring. And **the fixes with no observable
3656
4303
  symptom are worth batching**: individually none justifies a commit, together they
3657
4304
  cost about an hour, and they are the group no gate could have found, since having
3658
4305
  no symptom is the definition of what a gate cannot see.
3659
4306
 
3660
- - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3661
- showing input the engine must *reject* can be bound the way a runnable one is,
3662
- to a fixture whose rejection is the thing under test which is what
3663
- `fixtures/eval-invalid-registry` already is. This is the more valuable half of
3664
- what was once one entry with the region-marking below, because a counterexample
3665
- that quietly stopped being a counterexample is the one kind of stale sample
3666
- that actively teaches the wrong thing: a reader shown "this fails" about input
3667
- the engine now accepts is worse off than one shown nothing. It is also the
3668
- expensive half, needing a fixture per diagnostic, which is why it was never the
3669
- free part of that entry and is now the only part left in it.
3670
- *The mirror rule caps what can be claimed on the Chinese side.* `docs/zh/**`
3671
- matches `docs/en/**` down to the comments inside its code samples, so
3672
- byte-equality holds on one side only and the zh blocks stay translated prose
3673
- around the same code the parity gate in `[0.4.3]` covers them, and it covers
3674
- structure, not meaning. The residual risk recorded with the whole-file half in
3675
- `[0.4.3]` that the gate and the run proving the fixture still works live in
3676
- different suites applies here identically.
4307
+ *The five entries this section carried before the one below* were built and are
4308
+ recorded under `[Unreleased]`. The second is where this preamble's own
4309
+ discipline paid again: it went in on an entry whose headline type
4310
+ measurement instantiations unchanged was false on re-measure, and the
4311
+ re-measure is the only reason the release notes do not repeat it. The fourth is
4312
+ the other outcome the discipline has, and worth a line because the first three
4313
+ were all corrections: the `render --out` symlink measurement reproduced exactly
4314
+ as filed, down to the leaf being safe for the reason given. What re-measuring
4315
+ added was not a correction but a **surface the entry had not counted**
4316
+ `--check` reads the same destination, so the same link makes a freshness gate
4317
+ date a document outside the project. Re-measuring an entry is therefore not only
4318
+ a test of the number it quotes; walking the code the claim is about is what
4319
+ shows the claim's own edges. The fifth is that lesson arriving where an entry
4320
+ had already named its own fix: the C1 measurement reproduced exactly, and the
4321
+ repair it proposed a map over `issues` — was still the wrong half, because
4322
+ four more fields and a nested scenario name carry the same untrusted text and
4323
+ never pass the envelope. An entry that ends with a fix is the one least likely
4324
+ to have its fix re-examined, which is the case worth naming.
4325
+
4326
+ - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
4327
+ The provenance work in `[0.7.2]` signs the release but does not change
4328
+ what authorises it: a long-lived automation token, masked and protected, that
4329
+ publishes as us to anyone holding it. npm's trusted publishing exchanges the
4330
+ same OIDC identity the attestation already uses for a short-lived credential,
4331
+ which would remove the stored secret entirely and grant provenance as a
4332
+ property of the flow rather than a flag on the command. The deletion test
4333
+ points the right way for once: what it removes is the only long-lived
4334
+ credential this repository has.
4335
+ **The blocker this entry was filed with is retired, and it was the wrong
4336
+ question** — checked against npm's documentation on 2026-08-08. It asked
4337
+ whether npm's trusted publishers accept a **gitlab.com-hosted** project on the
4338
+ plan this package publishes under, and both halves come back clean: GitLab
4339
+ CI/CD is a first-class provider alongside GitHub Actions, and no plan
4340
+ requirement is stated anywhere in the documentation. The four fields it wants
4341
+ are `Pseudorca`, `attest`, the top-level CI file path (`.gitlab-ci.yml`, and it
4342
+ must carry the extension) and the allowed actions; environment name is optional
4343
+ and this project uses no GitLab environments. Shared runners are the only ones
4344
+ supported and the publish job carries no `tags:`, so it is already on them. The
4345
+ `repository.url` cross-check needs no separate verification — provenance
4346
+ already passes it with the same identity.
4347
+ Two things the entry did not anticipate, both of which change the shape of the
4348
+ change rather than blocking it. **`SIGSTORE_ID_TOKEN` is not replaced**: the
4349
+ documented GitLab snippet declares `NPM_ID_TOKEN` with
4350
+ `aud: "npm:registry.npmjs.org"` *beside* it, so this is an addition to the
4351
+ `id_tokens` block, not a swap. And **the YAML is not what deletes the
4352
+ credential** — enabling a trusted publisher leaves the automation token just as
4353
+ able to publish; what retires it is `Require two-factor authentication and
4354
+ disallow tokens` on the package, which npm states does not affect trusted
4355
+ publishers. That setting is the one that closes the loop this job's own comment
4356
+ opens, where an automation token is used *because* a granular token fails
4357
+ against 2FA on writes: trusted publishing does not bypass the prompt, it stops
4358
+ being a token.
4359
+ *What blocks the decision now is `pnpm`, and it is not a version to wait for.*
4360
+ **pnpm does not implement the OIDC exchange at all.** `pnpm/pnpm#11526` — the
4361
+ fix for the pnpm 11 regression that made this look like a supported path —
4362
+ shows what its "OIDC support" is: `actions/setup-node` writes
4363
+ `_authToken=${NODE_AUTH_TOKEN}` into `.npmrc`, pnpm used to pass the unresolved
4364
+ placeholder through verbatim, and the fix treats it as empty so that **npm's**
4365
+ credential exchange can be the sole auth source. pnpm gets out of the way; npm
4366
+ does the work. That matches npm's own wording, which requires publishing
4367
+ through npm directly or indirectly, and it matches the reports: every account
4368
+ of pnpm publishing under OIDC is GitHub Actions, and there is **no record of
4369
+ pnpm + GitLab + OIDC succeeding**. Stated at the strength the evidence carries:
4370
+ this is a strong negative inference, not a documented refusal.
4371
+ So the real cost is a tooling swap — `pnpm publish` back to `npm publish` in
4372
+ this one job — and it is a swap this file has already priced once in the other
4373
+ direction. The comment above records that moving to pnpm took
4374
+ `COREPACK_ENABLE_STRICT` with it, that override having existed only so a bare
4375
+ `npm` could run under a `packageManager` pinning pnpm; going back re-adds it.
4376
+ It also needs npm ≥ 11.5.1 where `node:22-slim` ships 10.x, so the job gains an
4377
+ npm upgrade step. None of that is prohibitive. It is simply a different
4378
+ decision from the one this entry was filed as, which is why it stays here.
4379
+ *One correction to the entry's own risk framing.* "It cannot be tested except
4380
+ by publishing" is half wrong: registering a trusted publisher on npmjs.com is
4381
+ free and reversible, and `NPM_TOKEN` can stay in place while it is tried. What
4382
+ decides whether this can be rolled out incrementally is whether a failed OIDC
4383
+ exchange falls back to the `_authToken` already in `.npmrc` or fails the
4384
+ publish outright — unmeasured, and the thing to establish before touching the
4385
+ job.
4386
+ *Method note, because it is the reusable part.* The pinned pnpm bundle was the
4387
+ obvious place to look and was the wrong one — `pnpm publish --help` on 10.28.0
4388
+ omits the flags it accepts, exactly as this job's comment already records for
4389
+ `--provenance`, so neither the help text nor the shipped bundle would have
4390
+ answered it. The answer was in the upstream pull request.
4391
+ **Promoted from `Under consideration` on 2026-08-08**, and what moved it is
4392
+ that the question it was filed as is answered. It asked whether npm's trusted
4393
+ publishers would accept this project at all; they will. What is left is a
4394
+ known tooling swap with a priced cost, which is a decision to build rather
4395
+ than a question — the preamble's own line, *a decision with an argument
4396
+ attached*. One thing stays unmeasured and is the first act of building it, per
4397
+ the discipline three paragraphs up: whether a failed OIDC exchange falls back
4398
+ to the `_authToken` already in `.npmrc` or fails the publish outright. That
4399
+ decides whether this rolls out incrementally beside the existing token or in
4400
+ one cut, and it is cheap to establish because registering a trusted publisher
4401
+ is free and reversible.
3677
4402
 
3678
4403
  ## Under consideration
3679
4404
 
@@ -3687,60 +4412,6 @@ deletion. What each entry has to carry is the evidence currently blocking the
3687
4412
  decision, because that is what a re-proposal a year from now would otherwise
3688
4413
  have to gather again.
3689
4414
 
3690
- - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
3691
- The provenance work in `[0.7.2]` signs the release but does not change
3692
- what authorises it: a long-lived automation token, masked and protected, that
3693
- publishes as us to anyone holding it. npm's trusted publishing exchanges the
3694
- same OIDC identity the attestation already uses for a short-lived credential,
3695
- which would remove the stored secret entirely and grant provenance as a
3696
- property of the flow rather than a flag on the command. The deletion test
3697
- points the right way for once: what it removes is the only long-lived
3698
- credential this repository has.
3699
- *What blocks the decision, and it is evidence rather than principle.* Whether
3700
- npm's trusted publishers accept a **gitlab.com-hosted** project on the plan
3701
- this package publishes under has not been verified against this project — it
3702
- was not checked when the provenance job was written, and an entry claiming it
3703
- works would be exactly the unverified confidence the rest of this file avoids.
3704
- It also cannot be tested except by publishing: as with provenance, there is no
3705
- dry run, so a failed experiment is a release. The cheap next step is reading
3706
- npm's current trusted-publisher documentation for GitLab support before
3707
- writing any YAML.
3708
-
3709
- - **A static sweep for the two properties claimed over the whole of `src/`.**
3710
- The quantifier table's first strict read left three rows wanting the same
3711
- thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
3712
- a sweep over the import graph asserting a cross-cutting property no runtime
3713
- test can see, written for the same reason — "only the graph shows it".
3714
- *The two candidates, and they are not equally shippable.* `ATX-15` is the easy
3715
- one: **no `.sort` outside `byCodeUnit`, and no `localeCompare`.** Measured over
3716
- `src/` today — 21 `.sort` call sites, every one reaching `byCodeUnit`, but only
3717
- 19 naming it on the call line: `render.ts` goes through its own `compareIds`,
3718
- and `merge.ts` through a comparator that spans two lines. The only four
3719
- occurrences of `localeCompare` are comments explaining why it is not used. A sweep would have **zero false positives on the current corpus**,
3720
- which is the bar `divergent-param` failed and `compound-requirement` cleared —
3721
- and those two exceptions are already the warning that this needs the AST rather
3722
- 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
3723
- string interpolated into a terminal line goes through `inline`", and that is
3724
- not a token scan. The shape that would work is a scenario driving every
3725
- exported `format*` in `src/cli/report.ts` with hostile input — a real roster,
3726
- since the module's exports are what a new formatter joins — but each takes a
3727
- different argument type, so it needs a hostile fixture per signature rather
3728
- than one loop.
3729
- *What blocks the decision.* Two things, one per candidate. The `ATX-15` sweep
3730
- is cheap and precise and would be **the first rule in this repository that
3731
- polices how `src/` is written rather than what it does** — a lint rule wearing
3732
- a scenario's clothes, and the question is whether that belongs in `self/` (as
3733
- an obligation `ATX-15` already carries) or in `tests/` beside the import
3734
- boundary. Filing it under `ATX-15` makes it a permanent obligation with a
3735
- scenario; filing it in `tests/` makes it deletable in a green diff, which
3736
- `ATX-57`'s rationale argues against in as many words. The `ATX-37` roster is
3737
- blocked on cost rather than principle: eight formatters, eight fixtures, and
3738
- the honest count of what it buys is three currently-correct sites.
3739
- *Why neither is urgent.* Both properties hold everywhere today, verified by
3740
- reading. What is missing is not correctness but the thing that keeps it — and
3741
- the measurement above is what a re-proposal would otherwise have to gather
3742
- again.
3743
-
3744
4415
  - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
3745
4416
  project with no concurrency limit: `findFiles` recurses through
3746
4417
  `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
@@ -3942,100 +4613,8 @@ have to gather again.
3942
4613
  out below* — the entry is kept whole here because the two were measured in one
3943
4614
  pass and separating them is the finding.
3944
4615
 
3945
- - **Whether `check` and `verify` should refuse a half-loaded registry the way
3946
- `cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
3947
- and the argument is recorded at both call sites: a report computed from a
3948
- half-loaded registry is a lie, and a lie that reads as a pass is the worst
3949
- shape it can take — `cover` in particular used to answer `ok: true` on a repo
3950
- whose intent layer had stopped being readable, because an empty registry is
3951
- fully covered. `check` and `verify` carry on regardless.
3952
- *What that produces, measured* on a project with one unreadable `*.reqs.ts`:
3953
- a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
3954
- that file's requirements, plus `empty-spec`, `tests-red` and a
3955
- `declared-not-run` per scenario — six derived findings from one cause. Worse,
3956
- `orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
3957
- for an id already sitting in the registry that failed to load, so the loudest
3958
- advice in the report points at work that must not be done. In a multi-file
3959
- registry it scales with the broken file's scenario count, and the one true
3960
- finding is at the top where a wall of red pushes it out of view.
3961
- *Why it is not simply the same call as `cover` and `render`.* Each of those
3962
- produces exactly **one** artifact, and refusing to produce it is the whole of
3963
- the decision. `check` is the command a pipeline runs first and its contract is
3964
- breadth — returning three findings instead of nine because one of the nine was
3965
- a load failure makes it quieter about problems it can still see perfectly well,
3966
- which is the opposite of what it is for. `verify` has the same shape with a run
3967
- attached.
3968
- *What blocks the decision.* The narrower move — keep reporting everything
3969
- except the findings **derived from** the missing registry, i.e. suppress
3970
- `orphan-test` for ids whose prefix belongs to a file that failed to load —
3971
- needs a prefix→file mapping for a file that never parsed, and that is exactly
3972
- the file `loadRegistry` has no ids from. Recovering the mapping means reading
3973
- the prefix out of something other than the registry's own keys (the file name,
3974
- or a partial AST read that survives the failure), and whether either is
3975
- trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
3976
- weaker signal "some registry file failed to load, so suppress all
3977
- `orphan-test`" is the other candidate and is a strictly worse `check` on a
3978
- multi-file registry, where the other files are fine and their orphans are real.
3979
- **The wrong *advice* has been separated out and fixed; the noise has not.**
3980
- `[0.7.0]` changes `orphan-test`'s fix hint when any registry file failed
3981
- to load, so the loudest line in that report stops telling the reader to add an
3982
- id that already exists. That needed no mapping, because it claims nothing about
3983
- *which* orphans came from the broken file — only that the command cannot tell,
3984
- and what order to work in. It is worth separating because the two halves were
3985
- filed as one problem and are not: **being wrong** was fixable today at the cost
3986
- of a sentence, and **being loud** is the part still blocked on the mapping
3987
- above. The entry stays open on exactly that, with one blocker retired: the
3988
- measured six derived findings from one cause are still six, and only one of
3989
- them has stopped pointing at work that must not be done.
3990
-
3991
- - **A statement that quantifies over a surface, with scenarios covering only the
3992
- part that existed when it was written.** Found by reading, and it had already
3993
- cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
3994
- a report, and the stack of a crash — MUST replace every control character",
3995
- and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
3996
- the two functions that existed when it was written. `render` builds a document
3997
- by concatenation that never went past the sanitiser, and a registry could drive
3998
- the reviewer's terminal from `attest render` with no flag at all — shipped
3999
- under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
4000
- specific to that requirement.
4001
- The shape: a universal quantifier in the statement, a finite set of scenarios,
4002
- and nothing anywhere that holds the two together. It reads as covered — the id
4003
- has scenarios, `cover` is green, `check` is green — and the quantifier is the
4004
- part no gate is looking at. Every one of the three mechanisms this project
4005
- sells operates *below* it: coverage asks whether an id has a scenario,
4006
- never-red asks whether that scenario discriminated, drift asks whether it reads
4007
- the params. None asks whether the scenarios span what the sentence claims.
4008
- *What blocks the decision.* Detecting it means knowing which surface a
4009
- statement names, which is prose judgement — the same judgement
4010
- `tests/intent-rule-candidates.spec.ts` records as the reason obligation
4011
- counting can never be an engine rule ("asserting a ground truth Attest cannot
4012
- compute for someone else's requirements, which is exactly what §0 refuses").
4013
- So the shippable form may not exist.
4014
- **The repo half has shipped, and this entry is now only about the engine
4015
- half.** `[0.7.0]` adds the `QUANTIFIED` table it described — hand-judged,
4016
- a row per flagged statement, every named scenario asserted to exist — and it
4017
- closed the way an entry built from a strict read is supposed to: it found a
4018
- live hole on `ATX-62`, the one requirement written specifically to avoid this
4019
- shape, where the archive gate path was claimed by the statement and asserted by
4020
- nothing. Two things came back that the entry did not predict. The useful one is
4021
- that "names a surface" splits three ways rather than two — `values`, `roster`,
4022
- `sites` — and only `sites` carries the risk, because a `roster` scenario
4023
- iterates the set the code owns and grows with it. That is a sharper statement
4024
- of the defect than "the scenarios do not span the sentence", and it is a rule
4025
- an author can act on: **prefer a quantifier a scenario can iterate.** The other
4026
- is that the remedy — rewriting the statement to name its surfaces, as `ATX-62`
4027
- did — takes it out of the flagged set, so the table has to allow rows for ids
4028
- the detector does not flag or it loses the binding exactly when the author does
4029
- the right thing.
4030
- *What stays open is the engine half, and nothing above touches it.* The three
4031
- kinds are prose judgement, the detector misses the article form ("A registry
4032
- that cannot be read MUST …") which is a universal too, and no rule can tell
4033
- which surface a sentence claims without being told. The repo half is a
4034
- counter-pressure, not a detector, and it holds for one registry whose authors
4035
- agreed to maintain it.
4036
-
4037
4616
  - **Whether a requirement can ever be retired, and what it costs that none can.**
4038
- The registry is at 62 requirements and 160 scenarios, roughly 2.6 each, and
4617
+ The registry is at **69 requirements and 192 scenarios, 2.78 each**, and
4039
4618
  every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
4040
4619
  requirement without a scenario cannot exist, and nothing ever removes one.
4041
4620
  `delta.removed` exists and `applyDelta` applies it, but `--apply` refuses to
@@ -4057,10 +4636,53 @@ have to gather again.
4057
4636
  requirement, where the scenario should move rather than go. Only the second is
4058
4637
  really about cost, and it is the one where deleting the intent to save the
4059
4638
  scenario inverts what the whole tool claims — that intent outlives its tests.
4060
- Nothing here is urgent while the number is 61. It is filed now because the
4061
- moment to write the argument down is while the reasoning is available, and
4062
- because a project whose requirement count only rises should have decided in
4063
- advance what would make one leave.
4639
+ Nothing here is urgent at 69. It is filed now because the moment to write the
4640
+ argument down is while the reasoning is available, and because a project whose
4641
+ requirement count only rises should have decided in advance what would make
4642
+ one leave.
4643
+ *The figures above were restated on 2026-08-08, and how they were wrong is
4644
+ itself the section's own warning happening.* As filed on 2026-08-03 this entry
4645
+ said "62 requirements and 160 scenarios, roughly 2.6 each" while closing on
4646
+ "the number is 61" — an inconsistency inside one entry, and the closing half
4647
+ was the right one. Re-measured at the entry's own commit with the current
4648
+ parser: **61 requirements, 156 scenarios**, so both opening figures were wrong
4649
+ on the day they were written, and only the ratio survived. Measured the same
4650
+ way today it is 69 and 192, and the method is the same one `verify self`
4651
+ prints — the control is that today's `self/` counted this way returns exactly
4652
+ what the runner does, which is what makes the 2026-08-03 number trustworthy. The
4653
+ cheap way — `grep -c 'scenario('` — answers 211 against today's true 192, and
4654
+ is what would have been reached for: comments and prose count, which is the
4655
+ `ATX-15` error again.
4656
+ **What moved in five days sharpens the argument rather than dating it.** +8
4657
+ requirements and +36 scenarios, concentrated in three sessions rather than
4658
+ accrued at a rate, so nothing here is a trend line. The part worth keeping is
4659
+ the ratio: 2.56 → 2.78. The cost of a requirement is not flat and it is not
4660
+ falling, which is the premise the whole entry rests on, and it is the first
4661
+ time this file has had two measurements of it to compare.
4662
+
4663
+ - **Whether `isSafeChangeName` is wide enough on Windows, where the filesystem
4664
+ strips trailing dots and spaces from a path component.** The guard's stated
4665
+ test is path safety rather than a character whitelist, and it refuses `''`,
4666
+ `'.'`, `'..'`, both separators and NUL. Win32 normalises `'.. '` to `'..'`
4667
+ below the API, so a name that passes every one of those clauses could still
4668
+ name the parent — and under `--eval` or `archive` the name reaches a module
4669
+ path that is then executed.
4670
+ **What is not known is whether that reproduces at all**, and it is the reason
4671
+ this is here rather than in `Planned`. `path.win32.join` was checked on Linux
4672
+ on 2026-08-09 and does *not* normalise it — `join('C:\\r\\changes', '.. ')`
4673
+ keeps the space — so the whole question is what the Win32 layer does with the
4674
+ resulting path, which no Linux measurement can answer. The repository's own
4675
+ rule applies: say which half is unmeasured rather than describing it as
4676
+ covered.
4677
+ **Even granting it, the reach is one level.** `changes/<name>/…` with the name
4678
+ eaten gives the project root, and going above it needs a second `..`, which
4679
+ needs a separator the guard already refuses. So the outcome is executing a
4680
+ file at another path *inside* the project, not an escape from it — which is
4681
+ what keeps this an open question about the guard's completeness rather than a
4682
+ filed defect. If it reproduces, the fix is one more clause of the same kind:
4683
+ refuse a name that is empty, `.` or `..` after trailing dots and spaces are
4684
+ trimmed. If it does not, the finding to keep is that the guard's clauses are
4685
+ the POSIX spelling of path safety and the file should say so.
4064
4686
 
4065
4687
  ## Considered and rejected
4066
4688
 
@@ -4072,6 +4694,223 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
4072
4694
  sinking one version deeper each time a release was cut above it, and a rejection
4073
4695
  filed under a version reads as belonging to it.
4074
4696
 
4697
+ ### Refusing a half-loaded registry in `check` and `verify`, the way `cover` and `render` do
4698
+
4699
+ `cover` and `render` return early on `hasError(loadIssues)`,
4700
+ and the argument is recorded at both call sites: a report computed from a
4701
+ half-loaded registry is a lie, and a lie that reads as a pass is the worst
4702
+ shape it can take — `cover` in particular used to answer `ok: true` on a repo
4703
+ whose intent layer had stopped being readable, because an empty registry is
4704
+ fully covered. `check` and `verify` carry on regardless.
4705
+ *What that produces, measured* on a project with one unreadable `*.reqs.ts`:
4706
+ a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
4707
+ that file's requirements, plus `empty-spec`, `tests-red` and a
4708
+ `declared-not-run` per scenario — six derived findings from one cause. Worse,
4709
+ `orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
4710
+ for an id already sitting in the registry that failed to load, so the loudest
4711
+ advice in the report points at work that must not be done. In a multi-file
4712
+ registry it scales with the broken file's scenario count, and the one true
4713
+ finding is at the top where a wall of red pushes it out of view.
4714
+ *Why it is not simply the same call as `cover` and `render`.* Each of those
4715
+ produces exactly **one** artifact, and refusing to produce it is the whole of
4716
+ the decision. `check` is the command a pipeline runs first and its contract is
4717
+ breadth — returning three findings instead of nine because one of the nine was
4718
+ a load failure makes it quieter about problems it can still see perfectly well,
4719
+ which is the opposite of what it is for. `verify` has the same shape with a run
4720
+ attached.
4721
+ *What blocks the decision.* The narrower move — keep reporting everything
4722
+ except the findings **derived from** the missing registry, i.e. suppress
4723
+ `orphan-test` for ids whose prefix belongs to a file that failed to load —
4724
+ needs a prefix→file mapping for a file that never parsed, and that is exactly
4725
+ the file `loadRegistry` has no ids from. Recovering the mapping means reading
4726
+ the prefix out of something other than the registry's own keys (the file name,
4727
+ or a partial AST read that survives the failure), and whether either is
4728
+ trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
4729
+ weaker signal "some registry file failed to load, so suppress all
4730
+ `orphan-test`" is the other candidate and is a strictly worse `check` on a
4731
+ multi-file registry, where the other files are fine and their orphans are real.
4732
+ **The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
4733
+ fixed under `[Unreleased]`, and the blocker above was the wrong question.**
4734
+ The mapping this entry called for could not be recovered, and did not have to
4735
+ be: what a suppression needs is not the broken file's *prefix* but the ids it
4736
+ declares, and those are written in its source. Reading them back is a source
4737
+ read that returns ids and builds nothing, so the failure modes that matter —
4738
+ a truncated file, a missing default export, a non-literal value, a
4739
+ schema-invalid entry, a module that throws at import — all give them up. The
4740
+ six derived findings from one cause are now two ERRORs and one WARNING, and
4741
+ the collapse is per id rather than per prefix, which is strictly more precise
4742
+ than what was blocked: a genuine typo sharing a prefix with the broken file
4743
+ stays reported. **The lesson is the entry, not the fix.** It sat here for two
4744
+ releases behind a blocker stated as "the mapping is unavailable", which was
4745
+ true and was about a mapping nothing needed; the evidence it carried was about
4746
+ the right problem and the mechanism it proposed was not, and nothing rereads a
4747
+ blocker that has already been written down. That is the same failure the
4748
+ section's own preamble records about a figure nobody re-checks, one level up:
4749
+ the *shape* of the proposal goes stale as quietly as its numbers do.
4750
+ *What is left of this entry is only the original question* — whether `check`
4751
+ and `verify` should refuse a half-loaded registry outright, the way `cover`
4752
+ and `render` do — **and the evidence that motivated it has been spent.** The
4753
+ argument for refusing was never the load failure itself; it was the wall of
4754
+ derived findings burying it, and that wall is gone. What remains is the
4755
+ breadth argument recorded above, which points the other way for both commands.
4756
+ *What rejected it is that the argument for it was entirely the noise, and the
4757
+ noise is gone.* Refusing was never justified by the load failure itself — it was
4758
+ justified by the wall of derived findings burying it, and the collapse under
4759
+ `[Unreleased]` removed the wall without touching either command's breadth. What
4760
+ is left on the table is the breadth argument alone, and it points the other way
4761
+ for both: `check` is the command a pipeline runs first, its contract is seeing
4762
+ everything it can still see, and answering with three findings instead of nine
4763
+ because one of the nine was a load failure makes it quieter about problems it
4764
+ diagnoses perfectly well. `verify` is the same shape with a run attached.
4765
+
4766
+ *What would reopen it*, stated because a rejection whose condition is unwritten
4767
+ gets re-proposed on a hunch: another finding found to scale with a **broken
4768
+ registry** rather than with the number of real problems. That is the shape that
4769
+ justified this once and could again; a merely noisy report is not.
4770
+
4771
+ ### An engine rule for a statement whose scenarios do not span its quantifier
4772
+
4773
+ The shape was found by reading, and it had already
4774
+ cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
4775
+ a report, and the stack of a crash — MUST replace every control character",
4776
+ and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
4777
+ the two functions that existed when it was written. `render` builds a document
4778
+ by concatenation that never went past the sanitiser, and a registry could drive
4779
+ the reviewer's terminal from `attest render` with no flag at all — shipped
4780
+ under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
4781
+ specific to that requirement.
4782
+ The shape: a universal quantifier in the statement, a finite set of scenarios,
4783
+ and nothing anywhere that holds the two together. It reads as covered — the id
4784
+ has scenarios, `cover` is green, `check` is green — and the quantifier is the
4785
+ part no gate is looking at. Every one of the three mechanisms this project
4786
+ sells operates *below* it: coverage asks whether an id has a scenario,
4787
+ never-red asks whether that scenario discriminated, drift asks whether it reads
4788
+ the params. None asks whether the scenarios span what the sentence claims.
4789
+ **Everything this entry established about the shape has left it, and the two
4790
+ halves left by different doors.** The repo half shipped in `[0.7.0]` as the
4791
+ `QUANTIFIED` table it described — hand-judged, a row per flagged statement,
4792
+ every named scenario asserted to exist — and it closed the way an entry built
4793
+ from a strict read is supposed to: it found a live hole on `ATX-62`, the one
4794
+ requirement written specifically to avoid this shape, where the archive gate
4795
+ path was claimed by the statement and asserted by nothing. What that table
4796
+ taught is now **design §11**, both languages, because it is a permanent
4797
+ property of the design rather than a candidate: which surface a quantifier
4798
+ ranges over — `values`, `roster`, `sites` — decides whether scenarios can keep
4799
+ up with it, only `sites` can silently stop being true, and the rule an author
4800
+ can act on is *prefer a quantifier a scenario can iterate*. The adopter-facing
4801
+ half of that rule is in the `init` skill beside the two `params` rules it
4802
+ completes. Neither was predicted here: the entry proposed a detector and
4803
+ expected a two-way split, and what came back was a three-way one and an
4804
+ authoring rule, which is the more useful object and needed no engine at all.
4805
+ The table's own trap is recorded with it — the remedy of rewriting a statement
4806
+ to name its surfaces takes it out of the flagged set, so the table must admit
4807
+ rows for ids nothing flags, or it loses the binding exactly when the author
4808
+ does the right thing.
4809
+ *What stays here is the engine half alone, and it is undecided rather than
4810
+ blocked-with-evidence.* Detecting this means knowing which surface a statement
4811
+ names, which is prose judgement — the same judgement
4812
+ `tests/intent-rule-candidates.spec.ts` records as the reason obligation
4813
+ counting can never be an engine rule ("asserting a ground truth Attest cannot
4814
+ compute for someone else's requirements, which is exactly what §0 refuses").
4815
+ The detector that exists flags the quantifier and nothing more, and it misses
4816
+ the article form ("A registry that cannot be read MUST …") which is a universal
4817
+ too. So the shippable form may not exist, and what §11 ships instead is a
4818
+ counter-pressure that holds for one registry whose authors agreed to maintain
4819
+ it. *What rejected it is a measurement that was already in the repository when this
4820
+ entry was trimmed, and went unread.* The inline snapshot in
4821
+ `tests/intent-rule-candidates.spec.ts` scores the detector against the real
4822
+ corpus: it flags **30 of 70** requirements, and of those 30, **21 are `values`
4823
+ or `roster`** — correct by construction, nothing for an author to do. A shipped
4824
+ WARNING would therefore fire on 43% of this registry and be wrong about 70% of
4825
+ what it fired on, with no way for the author to discharge it except by rewording
4826
+ the statement.
4827
+
4828
+ *And rewording is both the evasion and the legitimate fix*, which is what makes
4829
+ the precision figure fatal rather than merely bad. `ATX-62` was improved by
4830
+ being rewritten to name its surfaces, and that rewrite takes it out of the
4831
+ flagged set by exactly the same mechanism a lazy reword would. The detector
4832
+ cannot tell them apart, so it grades the wording rather than the coverage. That
4833
+ is the failure design §11 records for every intent-layer detector tried — each
4834
+ "either saw almost nothing, or was cleared by deleting the word that triggered
4835
+ it" — and this one is squarely the second: `compound-requirement` sits
4836
+ at the other pole, never wrong and nearly blind, and the two together are the
4837
+ measured range of what a token detector over prose can do here.
4838
+
4839
+ *What survives is not this proposal.* The three-way classification and the
4840
+ authoring rule are design §11, the adopter-facing form is in the `init` skill,
4841
+ and the hand-judged table is the counter-pressure that did survive measurement.
4842
+ Reopening this needs a signal that is not the quantifier token — the reach limit
4843
+ is the detector's input, not its threshold, so tuning it is the measurement
4844
+ bending to the corpus.
4845
+
4846
+ ### A precomputed analysis written to disk for the child run to read
4847
+
4848
+ `@ark/attest` analyses the whole project once, writes it to
4849
+ `.attest/assertions/*.json`, and has the test process do position lookups
4850
+ against that file instead of a compiler — because its assertions need a full
4851
+ `ts.Program`, which cannot be afforded per test. The shape maps onto `verify`
4852
+ exactly: the parent parses registries and specs, then `runAndCollect` spawns a
4853
+ Vitest run that currently shares nothing with that work.
4854
+ *What rejected it is that the case for it was never made and the case against it
4855
+ is structural.* There is **no
4856
+ measurement** saying parsing costs anything here — this reader is
4857
+ `createSourceFile` per file with no type checking, which is the cheap half of
4858
+ what arktype pays for, and the fan-out entry above measured `attest check`
4859
+ over 6000 spec files at 1.99 s total. Worse, a cache is a correctness hazard
4860
+ of precisely the kind this tool exists to detect: a stale analysis makes a
4861
+ **drift detector** report drift that has already been fixed, or miss drift
4862
+ that has just appeared, and it would do so silently. Any version of this needs
4863
+ an invalidation story stronger than a mtime before it is worth the seconds it
4864
+ saves, and the repo already has one cautionary precedent in the same
4865
+ direction — `bin/attest.js` loading `dist/` rather than `src/` is a cache with
4866
+ no invalidation at all, and it is in `CLAUDE.md`'s Gotchas because of how
4867
+ quietly it misleads. Note also what arktype gets wrong in the same file and
4868
+ what not to copy with it: the assertion cache and the queue of pending
4869
+ snapshot writes share one JSON document, so two things with unrelated
4870
+ lifetimes are invalidated together.
4871
+
4872
+ *What would reopen it* is a measurement, and the entry names which one: a
4873
+ parse cost large enough to be worth a cache, on a real project rather than a
4874
+ synthetic tree. Until that exists this is a correctness hazard bought with
4875
+ seconds nobody has shown are being spent, and the invalidation story would have
4876
+ to be stronger than an mtime before the trade is even worth pricing.
4877
+
4878
+ ### Zod Mini (`zod/v4-mini`) for the registry schema
4879
+
4880
+ Evaluated alongside v4 (which shipped under `[Unreleased]`), on the same
4881
+ schema and the same 69-requirement registry, and it wins both numbers it is
4882
+ supposed to win:
4883
+ **0.249 ms** per parse against v3's 3.47 ms, and — the one v4 does not deliver —
4884
+ type instantiations down from **27,681 to 6,581**, check time 0.25 s → 0.14 s.
4885
+
4886
+ *What rejected it is that both winners are collected by the wrong party.* Mini
4887
+ exists for tree-shaken bundle size in a browser. This ships as a Node CLI loaded
4888
+ from `dist/`, where bundle size is not a constraint on anything — apply the
4889
+ deletion test to mini's benefit and nothing in this repository changes. The
4890
+ instantiation saving is real but it is paid to **this project's own
4891
+ `pnpm typecheck`**, not to any adopter, and it is 0.11 s off a build already
4892
+ measured in seconds. The runtime saving is 3.2 ms against a 390 ms command.
4893
+
4894
+ *And the cost lands on the file least able to afford it.* Mini's API is
4895
+ functional rather than chained:
4896
+
4897
+ ```ts
4898
+ params: z.record(z.string(), paramValue).default({}) // v3 / v4
4899
+ params: z._default(z.record(z.string(), paramValue), {}) // mini
4900
+ ```
4901
+
4902
+ `src/core/schema.ts` describes itself as the single source of truth for the
4903
+ shape of the intent layer, and it is written as one — the comments on
4904
+ `isPlainObject` and on the `params` union are longer than the code they sit
4905
+ over, because the file is read as a document by anyone deciding what a
4906
+ requirement may contain. Trading its legibility, and accepting an
4907
+ underscore-prefixed `_default` in it, buys 3 ms on a command nobody is waiting
4908
+ for.
4909
+
4910
+ This reopens only if something makes bundle size a constraint here — a browser
4911
+ or edge build of the registry reader, which nothing currently proposes — and not
4912
+ on a faster benchmark, since the benchmark was never what was in doubt.
4913
+
4075
4914
  ### Region-marked samples, so a partial quote could be bound too
4076
4915
 
4077
4916
  Decided **yes** and filed under `Planned`, then killed by building the count it
@@ -4302,6 +5141,46 @@ skill file. A pointer is cheap enough to be resident, it is their edit in their
4302
5141
  file, and it cannot go stale — the file it names is the one `init` keeps
4303
5142
  current.
4304
5143
 
5144
+ ### A sweep driving every exported `format*` with hostile input
5145
+
5146
+ The mechanism `ATX-37` sat under `Under consideration` waiting for, kept there
5147
+ on cost, and killed by measuring that cost rather than by paying it. The three
5148
+ sites it was aimed at are closed under `[Unreleased]` by hand-written scenarios;
5149
+ what is rejected is the roster, and the argument is that **`src/cli/report.ts`'s
5150
+ exports are the wrong roster in both directions at once.**
5151
+
5152
+ *Too wide.* Three of the eight exports cannot carry project-derived text at all:
5153
+ `summarize` and `formatScope` take `VerifyCounts`, which is four numbers, and
5154
+ `formatRepeats` prints a count, a `Level` and an `IssueCode` — both fixed
5155
+ rosters. A hostile fixture for those has nothing hostile to put in it, so a loop
5156
+ over the exports either forces three members that **cannot go red**, which is
5157
+ the defect this project ships a gate against, or carries an exclusion list —
5158
+ and an exclusion list is precisely what `ATX-15`'s sweep avoided by making the
5159
+ roster one the code owns.
5160
+
5161
+ *Too narrow.* The statement is over everything the CLI writes to a terminal, and
5162
+ every site that has escaped it so far was outside this module: the `status`
5163
+ header was built and printed in `cli/index.ts`, the loader's writes are in
5164
+ `core/loader.ts`, and the `render` document that `ATX-58` was carved out for is
5165
+ in `core/render.ts`. A roster of formatters is neither necessary nor sufficient
5166
+ for the sentence, and the reading that made it look sufficient — that the
5167
+ formatters are the whole surface — is the exact assumption the `status` header
5168
+ falsified when it was found.
5169
+
5170
+ *And the general form is the §0 wall.* "Every project-derived string
5171
+ interpolated into a terminal line goes through `inline`" needs to know which
5172
+ values are project-derived, which is prose judgement over a data flow. That is
5173
+ the ground truth `tests/intent-rule-candidates.spec.ts` records as the reason
5174
+ none of this can ever be an engine rule, and it does not become computable by
5175
+ being asked about this repository instead of someone else's.
5176
+
5177
+ *What keeps the property instead* is the `QUANTIFIED` row itself — hand-judged,
5178
+ one entry per site, every named scenario asserted to exist. That is a
5179
+ counter-pressure rather than a detector, it holds for one registry whose authors
5180
+ agreed to maintain it, and after this change exactly one of its `ATX-37` sites
5181
+ is open. This reopens only if a site escapes that a roster of formatters would
5182
+ have caught, which none of the four so far would have been.
5183
+
4305
5184
  ### A Codex target for `attest init`
4306
5185
 
4307
5186
  Evaluated when `init` grew `--target` and shipped for Claude Code, Cursor and
@@ -4386,6 +5265,8 @@ requirement, and human review at propose is still the whole answer. What is
4386
5265
  rejected is grouping as a way to assist it, and this reopens only on a
4387
5266
  contradiction that grouping would have caught.
4388
5267
 
5268
+ [0.8.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.8.0
5269
+ [0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
4389
5270
  [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
4390
5271
  [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
4391
5272
  [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1