@am_shork/attest 0.9.3 → 0.10.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,759 @@ 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.10.0] - 2026-08-22
17
+
18
+ ### Changed
19
+
20
+ - **The same mutation pass over the verdict and its evidence: 24 mutations of
21
+ `gate.ts` and `red-record.ts`, 21 dead, and two of the three survivors closed.**
22
+ Every step of `evaluateGate` dies when removed, including scoping
23
+ `open-unresolved` back to the added ids — the cut that separates it from
24
+ `never-red`. So do both directions of `keepOutcome`: last-write-wins, where the
25
+ green run at the end erases the red it exists to remember, and first-write-wins,
26
+ where a recorded pass can never be corrected. The 46 lines at the top of
27
+ `red-record.ts` arguing for that asymmetry turn out to be held from both sides.
28
+
29
+ Two gaps were real, and both are cases where an existing test passes with the
30
+ guard removed:
31
+
32
+ - **A record whose version is not 2 was discarded by the *schema*, not by the
33
+ version check.** The case that covers this uses a version-1 record keyed
34
+ `reqId -> name`, which fails the shape as well — so the verdict is identical
35
+ either way, exactly as the guard's own comment predicts. Isolating the
36
+ version needs a record the schema would take: a valid version-2 shape under
37
+ version 3. Without that, "discarded because the format moved" and "discarded
38
+ because the file is corrupt" were the same observation, which the comment
39
+ calls out as how a format change gets made without anyone deciding to.
40
+ - **Nothing noticed if the record invented a `pass` for a scenario that never
41
+ executed.** `ATX-30` says the file records *how each scenario ended on its
42
+ first run*; a skipped scenario has no ending, so a `pass` there is an
43
+ observation no run produced — in a file that is evidence a reviewer reads and
44
+ that cannot be recomputed once the change is green. The gate still blocks
45
+ either way, so no verdict moves; what moves is the advice, since `never-red`
46
+ picks between "rewrite the assertion" and "run the gate before implementing"
47
+ on exactly this distinction.
48
+
49
+ The third survivor is left alone: `open-unresolved` emitting one error per
50
+ requirement rather than per question breaks nothing anybody promised. `ATX-81`
51
+ says the static commands report **each one**, and is pinned per question;
52
+ `ATX-82` says the gate **blocks**, and says nothing about granularity. Two paths
53
+ of one shape, one held and one not, and the difference is the two sentences.
54
+
55
+ *The harness failed twice more, and the second failure is the one worth
56
+ keeping.* Mutating a **writer** rather than a reader damages files outside the
57
+ source it was applied to: `archive` writes `first-run.json` into the e2e
58
+ fixtures, so a mutant that flipped `keepOutcome` left a committed fixture
59
+ saying `pass` where it had said `fail`, and every mutant after it read as
60
+ killed for that reason rather than its own. Restoring tracked fixtures fixed
61
+ the leak that had been found; a blanket check that the working tree is clean
62
+ after every mutant then immediately found the one that had not been — an
63
+ *untracked* record a mutant created, which `git checkout` cannot remove. The
64
+ broad guard earned its place over the narrow fix by catching what the narrow
65
+ fix was not written for.
66
+
67
+ - **Two guards in the registry reader had nothing holding them, found by
68
+ mutating every decision in `static-registry.ts` and watching what stayed
69
+ green.** Twenty mutations of the module's semantically load-bearing branches;
70
+ fourteen died to `tests/static-registry.spec.ts` and `tests/splice.spec.ts`,
71
+ and six survived the whole suite *and* `self/`. Two of the six were worth
72
+ closing, and both make the two readers answer **differently** rather than
73
+ merely reporting differently:
74
+
75
+ - `let reqs = defineRequirements({…}); reqs = …; export default reqs` — with
76
+ the `const` check removed the static reader answers the first registry while
77
+ the evaluator answers the second. A wrong answer rather than a refusal, which
78
+ is worse than any code it could have reported.
79
+ - `export = defineRequirements({…})` — with the `isExportEquals` check removed
80
+ the static reader accepts it, while the evaluator cannot run it at all.
81
+
82
+ Both now have a case in *"a registry that is not a literal"*, and both mutants
83
+ die to it. The other four survivors are left alone deliberately: one guard is
84
+ defence-in-depth behind a refusal that already happened upstream (`__proto__`
85
+ as an entry id, which `readRegistrySource` rejects before a merge can reach the
86
+ layout), one is a deliberate looseness whose comment says why, and two forbid
87
+ shapes that cannot compile in the first place. A test contrived to reach them
88
+ would assert that something upstream is still doing its job.
89
+
90
+ *The harness had to be fixed before any of it could be believed, and that is
91
+ the part worth carrying forward.* Its first run reported all six survivors as
92
+ killed. The cause was the mutation runner inheriting a working directory with
93
+ no test files in it, so `vitest` exited non-zero with "No test files found" and
94
+ every mutant read as covered — a green instrument reporting on nothing, which
95
+ is the exact failure `ATX-15`'s sweep guards against in as many words: *"a walk
96
+ that found no files satisfies 'no site orders by locale' while proving the
97
+ opposite of what it claims."* The runner now pins its working directory and
98
+ refuses to report at all unless the unmutated tree passes first.
99
+
100
+ - **The differential corpus now drives the two registry *writers*, not only the
101
+ two readers.** `tests/static-registry.spec.ts` exists because reading a
102
+ registry from the AST instead of evaluating it needs the two readers held
103
+ against each other — but the literal they walk has a third and a fourth
104
+ consumer, `registryInsertionPoint` and `registryEntryLayouts`, and the table
105
+ was never pointed at them.
106
+
107
+ *Every registry-source defect in this release had its trigger already sitting
108
+ in that table.* `as-const`, `satisfies` and `parenthesised` are the three
109
+ spellings whose literal the writers could not locate; `key-forms` carries the
110
+ duplicate key whose span they located at the wrong offset, under a comment
111
+ that has said *"a duplicate key: last wins"* since the row was written. The
112
+ corpus knew about all four inputs. Nothing asked the writers about them.
113
+ Reverting either fix now turns the table red — measured: the unwrap on all
114
+ three wrapper rows, the layout guard on `key-forms`.
115
+
116
+ **What it asserts is not that every case is writable.** A source the readers
117
+ accept may be one the writers must refuse, and `key-forms` now is. The
118
+ property is that a writer's answer is always *located* or *refused for a
119
+ stated reason*, never the third thing — located, wrongly — and which cases are
120
+ refused is a table someone has to edit rather than a branch that can drift.
121
+
122
+ *Widened while pointing it: the `satisfies` row did not cover the spelling
123
+ that broke.* It applied the keyword to a param value, where the value reader
124
+ sees it, and not to the registry argument, where the walk that finds the
125
+ literal does — so it stayed green through a defect named after it. It now
126
+ carries both positions, as the `as-const` row beside it always did.
127
+
128
+ - **`readArchivedChanges`' fan-out is bounded — the fourth one, and the first
129
+ outside `locate.ts`.** `archive.ts` read every archived first-run record
130
+ through a `Promise.all(names.map(…))`, so a project with N merged changes held
131
+ N reads and N parsed `RedRecord`s at once. It is the same shape as the other
132
+ three against an input nobody here chooses — an `archive/` grows by one folder
133
+ per change merged and is never pruned, so it is the longest-lived of the four
134
+ lists a project's shape decides the length of. Measured in
135
+ `tests/locate-fanout.spec.ts`, which now counts a fourth pool: **64 before, 32
136
+ after**, on 64 archived changes. Unbounded, the figure is the count of the
137
+ folders; bounded, it is the constant.
138
+
139
+ *Why it hid is that the pool's own doc comment names the rule and this caller
140
+ is not in the file that has it.* `forEachBounded` says "anything here that
141
+ reads a list the project's tree decides the length of belongs in this pool,
142
+ whatever the function around it is called" — and `readArchivedChanges` is
143
+ exactly that, one module over. The nearest comment to it reasons carefully
144
+ about concurrency and still says nothing about a bound: `inspectRedEvidence`
145
+ explains why it runs the archive read *after* `inspectProject` rather than
146
+ beside it — "overlapping them buys nothing worth a second fan-out" — which is
147
+ a correct answer about sequencing two reads and not a claim about what happens
148
+ inside either. That is the third time in this catalogue a considered comment
149
+ about concurrency has stood where a bound was missing. `forEachBounded` is now
150
+ exported for it, with a fourth constant on the reason `WALK_CONCURRENCY`
151
+ already gives.
152
+
153
+ **The failure this invites is memory, not descriptors — and that corrects the
154
+ three entries above.** Each of those closed by calling descriptor exhaustion
155
+ the invited failure and leaving it unmeasured. Measured now, on Linux: it is
156
+ not reachable through this shape at all. `fs.promises.readFile` does
157
+ open-read-close inside one libuv threadpool work item, so concurrent
158
+ *descriptors* are bounded by the threadpool and never by the fan-out — 3,000
159
+ unbounded in-flight reads peaked at **53** entries in `/proc/self/fd`, and the
160
+ read completed cleanly under `ulimit -n 256`, and again under `ulimit -n 128`
161
+ with `UV_THREADPOOL_SIZE=1024`. What does track the input is heap: on 2,000
162
+ changes of a 45 KB record, peak `heapUsed` was **352 MB unbounded against 177
163
+ MB bounded**, over interleaved runs — and the unbounded figure wandered from
164
+ 329 to 580 MB across rounds while the bounded one held 177–181. The stable
165
+ number is the point; the retained result is the floor, and what the bound
166
+ removes is the transient pile on top of it. This is the portable-arithmetic
167
+ standard the three earlier halves were accepted on, so it needs no incident
168
+ report — but the *reason* is memory, and future sweeps should stop reaching
169
+ for `ulimit`, which does not govern this on either platform: Windows uses
170
+ Win32 handles, and on Linux the threadpool gets there first.
171
+
172
+ Reached only from `inspectRedEvidence` and so from `@am_shork/attest/inspect`,
173
+ the published subpath — no CLI command reads the archive, which is why the
174
+ static-command roster is untouched by this. No behaviour change and no
175
+ `ATX-n`, on the precedent of the other three: the changes, their order, their
176
+ paths and the merged record are identical, asserted beside the peak. The
177
+ result is built by preallocate-and-index rather than by arrival, which is what
178
+ keeps the sorted folder order true through a pool that completes out of order.
179
+
180
+ - **The publish job runs on protected tags only, and it says so in the file
181
+ rather than in a settings page.** `rules` was `if: $CI_COMMIT_TAG`; it is now
182
+ `if: $CI_COMMIT_TAG && $CI_COMMIT_REF_PROTECTED == "true"`.
183
+
184
+ *The reason it needed to be in the file is that the comment beside it claimed
185
+ a defence that does not exist.* It said the reason to protect release tags had
186
+ changed — from exposing a masked variable to `id_tokens` being "issued to
187
+ protected refs only". That is not how GitLab issues them. Every job gets an ID
188
+ token, and the protection state travels *inside* it as the `ref_protected`
189
+ claim; a design that reports protection as a payload field is one where an
190
+ unprotected ref is handed a token too. Nothing on npm's end narrows it either:
191
+ a GitLab trusted publisher is namespace + project + top-level CI file path +
192
+ optional environment, with no ref condition available to configure.
193
+
194
+ **So the entire ref authorisation was the `rules` line — evaluated from the
195
+ `.gitlab-ci.yml` at the pushed tag, which is a file the pusher had just
196
+ written.** "Can push a tag" was therefore "can publish `@am_shork/attest` with
197
+ a valid provenance attestation", and the `NPM_ID_TOKEN` guard added in the
198
+ entry below cannot narrow it: that guard asks whether a token exists, never
199
+ who caused one to be minted. Protected tags in *Settings -> Repository* were
200
+ the only thing standing there, described by the comment as a belt-and-braces
201
+ extra.
202
+
203
+ This is the shape this repository keeps finding — a defence that reads as
204
+ present and is not, alongside `bin/attest.js`'s static import, the `hasWarned`
205
+ spread and `hmr: false` — arriving this time in the sentence explaining a
206
+ defence rather than in the code implementing one. Keep the protected-tag rule
207
+ in Settings as well: only that side stops the tag being created, and this side
208
+ only declines to publish once it has been. *Found by review, not by a run —
209
+ no pipeline was executed to confirm it, and the two vendor behaviours above
210
+ are read off GitLab's ID-token documentation and npm's trusted-publisher
211
+ documentation rather than measured here.*
212
+
213
+ - **The publish job has no stored credential left: `NPM_TOKEN` and the
214
+ `_authToken` line are gone, and the guard is on `NPM_ID_TOKEN` instead.**
215
+ Completes what `[0.9.4]` set up. The deletion test pointed here from the
216
+ start — what it removes is the only long-lived credential this repository had.
217
+
218
+ *It went in on a measurement, and the measurement is the part worth keeping.*
219
+ The v0.9.4 release published through the trusted publisher, not the token:
220
+ `_npmUser` on that version is
221
+ `{"name":"GitLab CI/CD","trustedPublisher":{"id":"gitlab","oidcConfigId":"oidc:…"}}`,
222
+ by npm 11.19.0. The entry that proposed this recorded that **no account existed
223
+ anywhere of publishing from GitLab under OIDC** — every report was GitHub
224
+ Actions — and stated the inference at the strength the evidence carried. That
225
+ release is the first record. The token was present and unused: `oidc()`
226
+ overwrites the auth key on success, so the `.npmrc` line never applied.
227
+
228
+ **The job's own log could not have told us, and that is why the fallback had
229
+ to go.** Every path in npm's `oidc.js` logs at `verbose` or `silly` while npm's
230
+ default level is `notice`, so a successful exchange and a failed one produce
231
+ byte-identical job output — the v0.9.4 log contains no OIDC line of either
232
+ kind. Combine that with a fallback written never to throw and the failure mode
233
+ is exact: an exchange that stops working keeps publishing, on the credential
234
+ the exchange exists to retire, silently, with the difference visible only in
235
+ registry metadata nobody reads. **The property that made the rollout cheap is
236
+ the wrong one to keep**, and this is the first time in this file that a
237
+ mechanism has been removed for succeeding rather than for failing.
238
+
239
+ So the check moves in front of the irreversible step, which is the rule the
240
+ removed post-publish attestation check failed and this file already argued:
241
+ verification belongs where it can act on the answer. A missing `NPM_ID_TOKEN`
242
+ now fails before anything is published; a failed exchange fails the publish
243
+ outright, loudly and recoverably, because nothing was written.
244
+
245
+ *What is not in this repository, and is not done by this entry.* One act on
246
+ npmjs.com: *Require two-factor authentication and disallow tokens* on the
247
+ package, which is what stops a leaked token publishing it and which npm states
248
+ does not affect trusted publishers. Not a commit and not verifiable from here.
249
+
250
+ *The `NPM_TOKEN` CI variable stays, deliberately, and the reasoning is worth
251
+ recording because the first version of this entry had it wrong.* Deleting it
252
+ was filed beside the npm setting as though the two were equal acts. They are
253
+ not: the setting removes the **capability** — a token can no longer publish this
254
+ package — while deleting the variable removes **one copy** of a credential that
255
+ goes on existing in the npm account either way. Revoking it there is the act
256
+ that destroys it; removing it from GitLab is housekeeping. The variable is
257
+ masked, protected, exposed only to protected refs, and now read by no job, so
258
+ what it costs to keep is nothing. What the setting does not cover is measured
259
+ rather than assumed: it is per-package, and `@am_shork/attest` is the only
260
+ package in the scope, so today it covers the account's entire published
261
+ surface exactly. The gap it leaves is future-conditional — a second package
262
+ would start without the setting — and that is an obligation attached to
263
+ publishing a new package, not a reason to move a variable. *And the next tagged release is the test of this change*, the same way
264
+ v0.9.4 was the test of the last one — except that this time a regression fails
265
+ the release instead of quietly publishing. That is the intended trade.
266
+
267
+ ### Added
268
+
269
+ - **`ATX-83` states the three states of that field.** What was missing was never
270
+ the field; it was a sentence the suite could falsify about *when* the field is
271
+ there. A requirement saying only "the report carries the paths the merge wrote"
272
+ is satisfied completely by an implementation that always emits `[]`, and that
273
+ implementation is wrong in exactly the case the field is reached for — a re-run
274
+ after a completed merge.
275
+
276
+ *Written straight into the registry, not through `changes/`.* That is how
277
+ existing behaviour is described here, and it is the reading `ATX-80`'s
278
+ `unarchived` state exists for: `never-red` asks for a recorded failing run only
279
+ of the ids a change is currently ADDing, so a requirement catching up to
280
+ behaviour that already shipped carries no such obligation and gains nothing
281
+ from a workflow whose point is to make a scenario fail before the code exists.
282
+ The scenario lives beside the other `archiveReport` ones in
283
+ `self/json.spec.ts`, where a reader looking at that function finds all of them.
284
+ Nothing records that it discriminates, which is what `unarchived` costs and is
285
+ worth saying rather than leaving implicit — so it was checked by hand instead:
286
+ with the one line that spreads the field into the report removed, the scenario
287
+ fails, and `verify self` reports `tests-red`.
288
+
289
+ The measurement in `tests/intent-rule-candidates.spec.ts` moved by exactly one
290
+ requirement — corpus 93 → 94, universal quantifiers 40 → 41 — and no verdict
291
+ in it flipped. It also produced the `flag:` divergence key's **fourth**
292
+ re-keying in four releases, every one triggered by an addition that agrees
293
+ with a member already there, and the `field:` key's first *extension* rather
294
+ than re-keying; both are noted where the rule is judged, because which of the
295
+ two an addition causes turns on vocabulary rather than on how related the
296
+ requirements are.
297
+
298
+ - **`archive --apply --json` reports the paths the merge wrote.** The human
299
+ rendering has listed them since `--apply` shipped, on the grounds that this is
300
+ the command that edits files the user cannot regenerate and the reader needs to
301
+ know which ones to look at before committing. The `--json` reader — the one
302
+ most likely to be automating around the merge — got the change name and nothing
303
+ else. `written[]` closes that, and both renderings are now handed the same
304
+ bundle from one place in the shell rather than each being told separately,
305
+ which is the split that let the verdict disagree with itself in `[0.7.0]`.
306
+
307
+ **Absent without `--apply`, `[]` with it.** The two are different facts — no
308
+ merge was attempted, against a merge that ran and had nothing left to write,
309
+ which is the ordinary state of a re-run after a completed one — and a field
310
+ that spelled both `[]` could not say which. Additive, so no `SCHEMA_VERSION`
311
+ bump: a consumer that has never heard of the field reads the report it always
312
+ did. ATX-74's sweep covers it like every other string a report quotes out of
313
+ the checked project.
314
+
315
+ ### Fixed
316
+
317
+ - **`first-run.json` committed different bytes for the same evidence, depending
318
+ on how many runs it took to gather.** `mergeRedRecord` rebuilds the record so
319
+ key order is a function of the data rather than of insertion — and did that at
320
+ two of its three levels. The rebuild sorts the requirement ids the record
321
+ already held; the loop after it *appends* the ids this run is the first to
322
+ observe, in plan order; and the pass at the bottom re-sorted the file and
323
+ scenario levels while walking the ids in whatever order they were in, because
324
+ reassigning `record[id]` does not move a key.
325
+
326
+ So a change that grew its record over several stage-1 runs committed
327
+ `{"Z-9": …, "A-1": …}` where the same observations reached in one run committed
328
+ `{"A-1": …, "Z-9": …}`. Nothing about the outcomes differs; the file does. That
329
+ matters twice over for this particular file: it is evidence a reviewer reads in
330
+ a diff, and the gate has to reach the same verdict on a CI checkout as on the
331
+ author's machine. It also went unnoticed once written — `changed` is decided by
332
+ the outcomes alone, so an unchanged record is never rewritten and a
333
+ wrongly-ordered one stays that way.
334
+
335
+ *ATX-15 already said this and could not catch it.* The requirement quantifies
336
+ over "every ordering Attest commits to disk", and the sweep attesting it is
337
+ deliberately narrower than its own statement: it checks *how* a site orders —
338
+ every `.sort` in `src/` through `byCodeUnit` — and says so, *"a `red-record.ts`
339
+ that stopped sorting at all is still green here, and the scenarios above are
340
+ what speak to the result"*. There was no such scenario for this file. There is
341
+ now, in `self/red-record.spec.ts`: it reaches the same evidence by both routes
342
+ and compares the serialised bytes. `archive.ts`'s `sortRecord` had all three
343
+ levels right and is what this now matches.
344
+
345
+ - **`--apply` refused a registry whose literal is written with `as const`,
346
+ `satisfies`, or a bare pair of parentheses.** The reader takes all three —
347
+ `unwrap` strips the type-level wrappers before `literalValue` reads the object,
348
+ because none of them changes the value — so the gate read such a file, proved
349
+ the change green, and handed it to a merge that asked
350
+ `ts.isObjectLiteralExpression` of the wrapper and got `false`. Both writers
351
+ refused: the whole change stopped with `internal-error`, whose own message says
352
+ the file *"read as a registry for the gate but not for the merge"* and whose
353
+ comment calls the condition unreachable through the command. It was reachable
354
+ by writing `defineRequirements(({ … }))`.
355
+
356
+ *The fix is where the two answers diverged, not where the symptom showed.*
357
+ `authoringCall` is the single function both sides use to find the registry
358
+ literal — the module's opening note says a second walker would be a second
359
+ answer to "is this a literal" — and it handed back the argument node as
360
+ written, leaving each caller to decide whether to unwrap. The reader did and
361
+ the writers did not, so the disagreement the shared function exists to prevent
362
+ was inside it. It now unwraps once, and `literalValue`'s own call becomes the
363
+ no-op it should always have been.
364
+
365
+ The parenthesis is the reachable spelling and the reason this is a fix rather
366
+ than a nicety: `as const` and `satisfies` are opted into, while nothing about
367
+ `(({ … }))` looks like a type annotation anyone chose. No `SCHEMA_VERSION`
368
+ bump and no new code — a change that used to fail now succeeds, which is the
369
+ benign direction for an exit code, and the History row is what tells a reader
370
+ on an older build which half of the page applies to them.
371
+
372
+ - **A registry key written twice made `archive --apply` write bytes outside the
373
+ value it was replacing — and, measured, outside the registry literal
374
+ altogether.** `registryEntryLayouts` keys each value's source span by name in
375
+ a `Map`, and `spliceModifications` walks that map to get its edits in
376
+ ascending offset order, which is what lets the pass at the bottom be a
377
+ `reverse()` rather than a sort. `Map.set` on a key it already holds keeps the
378
+ **first** insertion's position and takes the **last** call's value — so an
379
+ entry writing `params: { limit: 1, other: 'x', limit: 1 }` describes `limit`
380
+ at position one carrying the span of the occurrence *after* `other`, and the
381
+ offsets stop ascending. The earlier edit then moves the bytes under the later
382
+ one. On a registry the gate had just proved green, changing both values landed
383
+ the second one past the file's closing `});`.
384
+
385
+ *Nothing upstream refuses such a file, which is why this reached the one
386
+ command that writes.* A duplicate key is a TypeScript **semantic** error, and
387
+ `parseSource` asks only the syntactic question (`[0.9.0]`, ATX-69) — so both
388
+ registry readers take the file and hand back the last occurrence, `check` is
389
+ silent, and the gate passes. The reachable route is an ordinary copy-paste in
390
+ a hand-written registry, in a project that does not typecheck its
391
+ `*.reqs.ts`.
392
+
393
+ **Refused rather than described, at all three levels.** An entry whose keys
394
+ repeat is left out of the layout with the repeated key's path recorded beside
395
+ it, and `spliceModifications` turns that into an `apply-unsupported-delta`
396
+ refusal — whole, with nothing written — before a single edit is pushed. The
397
+ id level is not the mild one and is refused for the same reason rather than a
398
+ weaker one: two entries under a single id put a *later* entry's spans at an
399
+ *earlier* entry's position, so the disorder is between entries rather than
400
+ inside one. The guard stays exactly as wide as the danger — an unrelated id in
401
+ the same file is still written — because a merge that stopped over something
402
+ it was not asked to touch is the thing `--apply` refuses to do everywhere
403
+ else.
404
+
405
+ *The sibling defect is already in this file and did not generalise on its
406
+ own.* `[0.8.0]` fixed an integer-like `params` key for the identical
407
+ invariant — JavaScript hoists it to the front of every object, so a plain
408
+ object could not carry the file's order — and the repair was to move all three
409
+ levels to `Map`s. That answered the container and left the *keys*: a `Map`
410
+ keeps insertion order for every key type, and the one thing it still cannot
411
+ represent is one key written twice. The test added then, `rewrites two params
412
+ when one of them has an integer-like key`, is the shape of the three added
413
+ here, down to needing two changed values of different lengths — one edit
414
+ cannot be out of order with itself.
415
+
416
+ `verifyWritten` did catch the result and stop the merge, so the failure was
417
+ never silent; the file had already been overwritten by then, which is the
418
+ "destructive on a file the user cannot regenerate" shape the `AGENTS.md` merge
419
+ tool was rejected for and the reason a post-hoc check is not where this
420
+ belongs.
421
+
422
+ **Breaking, and narrowly so — this is the entry that decides the version
423
+ number.** The common case is not: a repeated key usually made the merge write
424
+ outside the value and the re-read then failed it, so the run already exited 1
425
+ and only the `code` moves — `internal-error`, raised after the registry had
426
+ been overwritten, becomes `apply-unsupported-delta`, raised before anything is
427
+ written.
428
+
429
+ But the offsets only go wrong when an earlier edit *changes length*. A
430
+ modification that replaces a value with one the same size shifts nothing, so
431
+ on `[0.9.4]` such a merge completed and completed **correctly**. Measured on a
432
+ registry with a duplicated `statement` and a `budgetMb: 1` → `2` patch: 0.9.4
433
+ merges it and it reads back as `{"budgetMb":2}`; today it refuses. That is
434
+ previously-valid input now rejected, which is this file's own definition of
435
+ breaking, so it takes the **minor** rather than the patch.
436
+
437
+ The refusal is still right — the same file with a length-changing patch is the
438
+ corruption above, and which of the two a merge gets is not something the author
439
+ chose. What the narrowness costs is that an adopter whose registry happens to
440
+ carry a duplicate key sees a working command start refusing, so it belongs in a
441
+ minor with the reason stated rather than in a patch nobody reads.
442
+
443
+ No `SCHEMA_VERSION` bump: the envelope, the `Issue` interface and the roster in
444
+ `core/docs.ts` are all unchanged — the refusal reuses the code `--apply`
445
+ already raises for a delta it will not write.
446
+
447
+ **`ATX-76` is widened to say what it always meant**, through this
448
+ repository's own workflow — `self/archive/2026-08-21-widen-atx-76`, the second
449
+ change to go through it and the first carrying a MODIFIED. Its refusal clause
450
+ named one instance of a class: *"when a span it would overwrite carries a
451
+ comment"*. Refusing a comment inside a span is not a rule about comments, it is
452
+ the rule that a replacement may only ever write the value it was asked to
453
+ write, stated at the one place that value's boundary was known to be in doubt.
454
+ A repeated name puts it in doubt a second way, so the clause now reads
455
+ *"wherever the file leaves that span uncertain"* and lists both. What is
456
+ **not** stated is the refusal as a mechanism: refusing is how *this*
457
+ implementation keeps the first clause, and a rewrite could keep it by ordering
458
+ the edits instead — a requirement pinned to the remedy would forbid that repair
459
+ for no reason.
460
+
461
+ *The defect was never that the rule was absent.* The first clause — the unit of
462
+ the edit is the value — has said this since `[0.9.0]`. What was missing was a
463
+ sentence the suite could falsify in this direction: both existing scenarios
464
+ assert something about a **comment**, and neither can go red when the failure
465
+ is that the span located was the wrong one. `self/merge.spec.ts` now carries a
466
+ third, looping the three sites a name can repeat with the extent pinned beside
467
+ the loop. Measured with the fix reverted, at the `spliceModifications` seam:
468
+ all three wrote without refusing; with it, all three refuse.
469
+
470
+ ### Documentation
471
+
472
+ - **The CLI reference told readers the `--json` guarantee rests on
473
+ `JSON.stringify`, which is the reasoning `[0.8.0]` retired.** *Output is safe
474
+ to read* closed with "`--json` was never exposed: serialising escapes the same
475
+ characters" — true of C0 and of nothing else, so DEL and the C1 range were
476
+ never covered by it. The code has been right since `[0.8.0]`, where the
477
+ replacer in `renderJson` began stripping every string in the document as it
478
+ serialises one; the page kept the retired argument, in both languages, three
479
+ hundred lines above its own History row saying the opposite.
480
+
481
+ *The comment at the crash site in `cli/action.ts` said it too*, and that copy
482
+ is the one worth naming: it sat directly above `renderJson(errorReport(…))`
483
+ and read as a settled argument for why sanitising there was unnecessary. The
484
+ replacer it argues against is what makes that line safe. Nothing was
485
+ exploitable — this is a false rationale standing beside a working defence,
486
+ which is the configuration that gets a defence deleted by someone tidying up.
487
+
488
+ Found by an attack-surface review rather than by a gate, and no gate could
489
+ have found it: the suite checks that a `§n` a comment cites exists and that a
490
+ version a History table names is a heading here, never whether a sentence is
491
+ true. Three copies of one retired claim is what that gap looks like from
492
+ inside. No behaviour changed and no version moved, so there is no new History
493
+ row — the `[0.8.0]` row was correct all along and is what the prose now agrees
494
+ with.
495
+
496
+ - **The method page says which shape a composite param is best at, and the
497
+ sample is a fixture that runs.** `intent-quality.md` already carried both
498
+ hazards a list-valued param has — an expectation recomputed from the param it
499
+ is checking, and a loop whose extent nobody pinned — as the third and fourth
500
+ of its five findings, named and left there with no shape attached. A **decision table**, rules pairing conditions with the
501
+ outputs the requirement promises, is where both arrive at once and where the
502
+ single source pays most, so it is now named. The section states the property
503
+ the shape actually rests on, which is narrower than "use a table": the
504
+ implementation reads only the *condition* columns, which leaves the output
505
+ columns an independent term — and it stops being true the moment the system
506
+ under test is itself driven by the same rules.
507
+
508
+ *The sample is `fixtures/consumer/requirements/cart.reqs.ts` and
509
+ `fixtures/consumer/cart.spec.ts`*, added here and registered in
510
+ `PUBLISHED_SAMPLES`, because every `ts` block this page publishes was already
511
+ a file the packaging test installs from a tarball and runs, and half-real
512
+ samples teach a reader to check which — after which they stop. **Making it
513
+ real is what found the constraint the section now documents**: the statement
514
+ cannot interpolate the table. A `{placeholder}` resolves to a scalar or a list
515
+ of scalars, so `{rules}` is `non-scalar-interpolation`, and the statement has
516
+ to name the behaviour in prose while the rules stay a param the scenario reads.
517
+ A draft written only as prose had it wrong and nothing would have said so.
518
+
519
+ It also landed on an existing row rather than needing a new rule:
520
+ `intent-rule-candidates.spec.ts` classifies it `over: 'roster'`,
521
+ `owner: 'registry'`, which is the branch that owes a `pin` — a registry-owned
522
+ list is data an author can shorten, and every assertion inside the loop still
523
+ passes over the shorter one. The published scenario carries that pin
524
+ (`expect(rules).toHaveLength(6)`), so a reader who copies the block copies the
525
+ counter-pressure with it.
526
+
527
+ *It was written into the README first, and has moved.* The README is the
528
+ pitch, the five-minute start and navigation; a hundred lines on how to write a
529
+ param that pins something is the method page's job, beside the two findings it
530
+ repairs. The README keeps one paragraph pointing at it. **The move is also
531
+ what showed `PUBLISHED_SAMPLES` could not say where a sample is published** —
532
+ it read the README and only the README, which was right while every sample
533
+ lived there and an obstacle the moment one did not. Each entry now carries its
534
+ page, so moving a sample between pages is a roster edit rather than a silent
535
+ loss of the gate that keeps it runnable.
536
+
537
+ ## [0.9.4] - 2026-08-19
538
+
539
+ ### Added
540
+
541
+ - **A requirement can say a value is not decided yet, as `open` (`ATX-81`,
542
+ `ATX-82`).** The registry had two states, schema-valid or an ERROR, and there
543
+ was no third — so an author who could not determine a value had nowhere to put
544
+ that fact. What they do instead is pick one, and a scenario written against an
545
+ invented number is drift no gate can see: the single source is perfectly
546
+ consistent and describes a promise nobody made, arriving through the mechanism
547
+ meant to stop it. `open` is a free-text list of what is still undecided about a
548
+ requirement, one entry per question. The static commands report each as a
549
+ `requirement-open` WARNING and keep their exit code; the archive gate refuses
550
+ with `open-unresolved` while any remains. Promoted from *Under consideration*,
551
+ where the blockers were an unmeasured frequency and the permanent surface a
552
+ schema field costs.
553
+
554
+ *The asymmetry is the design, and it is `never-red`'s.* A proposal may be
555
+ legitimately half-finished while it is being written, and the four static
556
+ commands are where an author works during that; failing there would push them
557
+ back to inventing a value, which is the behaviour being replaced. Archiving is
558
+ where "done" acquires a hard definition, and a registry carrying an unanswered
559
+ question is not done.
560
+
561
+ *Re-measuring the entry before implementing it corrected it in four places,
562
+ per this file's own discipline.* **(1)** The proposed "WARNING from `check`,
563
+ ERROR at the gate" cannot be one diagnostic at two levels: `evaluateGate`'s
564
+ structure step already filters `validateStructure` to ERROR, so every WARNING
565
+ is discarded there. It needs its own gate step, which is what `never-red`
566
+ being the stated precedent actually implies. **(2)** The scope is the whole
567
+ merged registry, not the ids the change ADDs — and that is where it parts
568
+ company with `never-red`, whose narrower scope exists because "was ever
569
+ observed failing" is a fact about history that cannot be demanded
570
+ retroactively. An open question is a claim about the present. **(3)** The
571
+ entry over-counted the static extractor, which reads any literal property and
572
+ needed nothing, and under-counted `render`: without a section there, `SPEC.md`
573
+ — the only human review surface a requirement has — would not show that a
574
+ shipped requirement is open.
575
+
576
+ ***(4)* is the one that had to ship in the same change, and it is the
577
+ question `Planned`'s preamble says to ask.** `splice.ts` carries the comment
578
+ *"Any other field is one the schema does not define, and not this module's to
579
+ rewrite or to remove"*, and that sentence is true **only while `open` is off
580
+ the schema**. Adding the field without adding the two writer branches would
581
+ mean a change that *answers* a question is approved by the gate against an end
582
+ state the registry on disk does not match — the merge commits a file still
583
+ carrying the closed question, with nothing anywhere comparing the two. Not a
584
+ path the entry's own reasoning could reach, because it reasons about the field
585
+ being added and this is about the field being removed.
586
+
587
+ *Cost, stated rather than hidden.* One unresolved question anywhere blocks
588
+ every unrelated change from archiving. That is the intended reading of a
589
+ registry that is not done, it is recoverable by answering the question, and it
590
+ is the same all-or-nothing shape `verify` already has. `SCHEMA_VERSION` is
591
+ unchanged: two new `code` values are additive to the `--json` envelope. The
592
+ type surface **does** move — `Requirement` gains a required `open`, so code
593
+ constructing one by hand rather than through `defineRequirements` needs the
594
+ field. In this repository that was 26 sites, every one of them a test literal
595
+ that already spelled `outOfScope: []`; no adopter reading requirements is
596
+ affected, and `defineRequirements` still takes it as optional.
597
+
598
+ - **This repository ran its own `changes/` → `status` → `archive` workflow for
599
+ the first time, and the change above is what went through it.** Every one of
600
+ the 80 requirements before it was written straight into the registry, so
601
+ `archive/` did not exist and `inspectRedEvidence` — shipped in `[0.9.3]` to
602
+ read it — reported all 252 declared scenarios as `unarchived`. The export had
603
+ no consumer that was not a test of itself for the second release running, and
604
+ this time the reason was not that nothing had been built on it but that the
605
+ repository had never produced the input it reads.
606
+
607
+ **The gate's first run failed on two of the five scenarios, and it was right.**
608
+ Both were the negative half of a pair — "says nothing when the list is empty",
609
+ "lets the gate through once every question is closed" — and both passed on
610
+ their first run, because an engine that has never heard of the field satisfies
611
+ "stays silent" trivially. `never-red` named them individually. The repair is
612
+ one scenario asserting the *discrimination* rather than two asserting each
613
+ side, which fails now and still refuses an implementation that warns
614
+ unconditionally later. This is the mechanism catching the exact defect class
615
+ the tautology entry under *Under consideration* is about, on the first change
616
+ it was ever pointed at, in the author's own work.
617
+
618
+ *A second defect surfaced after `--apply` and is worth recording because
619
+ nothing catches it.* The proposed spec read its params through `delta.added`
620
+ rather than by id. A delta is *also* keyed by the ids it ADDs
621
+ (`withProposedRequirements`), which is what makes the import the only line the
622
+ merge has to change — reaching through `added` loads during stage 1 and throws
623
+ once the import is repointed at the registry. The gate cannot see it, because
624
+ the gate runs before the rename; `check` cannot, because it executes nothing.
625
+ It appeared at the next `verify` as `spec-load-failed`. The workflow document
626
+ already shows the by-id accessor; what it does not say is that the other one
627
+ works until it doesn't.
628
+
629
+ *What the archive now reports*, and it is the shape the design predicts rather
630
+ than a backlog: one archived change, 5 `fail` rows, 228 `unarchived`. The two
631
+ retired scenario names remain in `first-run.json` as `pass` and produce no
632
+ rows at all, which is `inspectRedEvidence`'s documented behaviour for a record
633
+ naming a scenario no spec declares any more — measured here for the first time
634
+ rather than reasoned about.
635
+
636
+ ### Changed
637
+
638
+ - **The publish job publishes through `npm` and requests an npm OIDC token, so
639
+ trusted publishing becomes a setting rather than a rewrite.** The provenance
640
+ work in `[0.7.2]` signs the release but does not change what authorises it: a
641
+ long-lived automation token, masked and protected, that publishes as us to
642
+ anyone holding it. This is the half of removing it that lives in the
643
+ repository. Promoted from `Planned`; what remains there is narrowed to the two
644
+ npmjs.com settings that actually retire the credential.
645
+
646
+ *The one thing the entry was promoted with as unmeasured is now measured, and
647
+ it decides the rollout.* The question was whether a failed OIDC exchange falls
648
+ back to the `_authToken` already in `.npmrc` or fails the publish outright.
649
+ It falls back. Read off npm 11.10.1's `lib/utils/oidc.js` rather than inferred:
650
+ the function documents itself as intended never to throw, every failure path
651
+ logs at verbose and returns, the whole body sits inside a `try/catch` that also
652
+ returns, and `opts[authTokenKey]` is assigned only on success. So this lands
653
+ beside the existing token with nothing registered yet, and a failed exchange is
654
+ a quiet fall back rather than a failed release — which is what makes the first
655
+ tagged release a cheap test instead of an irreversible bet.
656
+
657
+ *`pnpm publish` was never going to work, and that is now an observation rather
658
+ than a strong inference.* The entry reasoned from `pnpm/pnpm#11526` that pnpm
659
+ gets out of the way and npm does the work. The call site confirms it:
660
+ `npm/lib/commands/publish.js` invokes `lib/utils/oidc.js`, so the exchange is
661
+ inside npm's publish command and no flag passed to pnpm reaches it. Hence the
662
+ client swap, which is a cost this file has already priced once in the other
663
+ direction.
664
+
665
+ *Two details the swap turned up that the entry had not counted.* `npm publish`
666
+ has no `--no-git-checks` — it warns *"Unknown cli config"* and does no git
667
+ checks at all — so the flag is dropped rather than translated. And the job now
668
+ runs `corepack enable pnpm` rather than the bare `corepack enable` plus the
669
+ `COREPACK_ENABLE_STRICT` override the entry expected to re-add: naming the shim
670
+ leaves the image's own npm alone for `npm i -g npm@^11.5.1` to upgrade, which
671
+ is the same outcome with one fewer environment variable and no refusal that can
672
+ be switched off by accident. `--provenance` stays explicit even though npm's
673
+ OIDC path can enable it by itself, because it only does so while `provenance`
674
+ is at its default — passing it keeps the setting this job's decision rather
675
+ than a side effect of whether the exchange succeeded.
676
+
677
+ *What is not claimed.* Nothing here has run. The job is exercised once per
678
+ release and cannot be tested any other way, which is why the fallback
679
+ measurement came first and why the token and its guard both stay. `NPM_TOKEN`
680
+ is still required and still what authorises the publish until a release is
681
+ observed going out without it.
682
+
683
+
684
+ - **This repository's own judgement table now reads its scenario names through
685
+ `@am_shork/attest/inspect`, and what that measured is the point of the
686
+ change.** `intent-quality.md` names one read as the rot check every hand-judged
687
+ table needs — that a scenario a row claims is one a spec really declares — and
688
+ tells an adopter to get it from `inspectProject`. This repository's table got
689
+ it from `parseSpecs` and `findFiles` directly, because it was written while
690
+ both were internal. So the export shipped in `[0.9.2]` had **no consumer that
691
+ was not a test of itself**: `self/inspect.spec.ts` attests it, and nothing
692
+ built anything on it. Passing its own tests is not evidence that an export is
693
+ sufficient for the thing it was added for, and the only party who could have
694
+ found out otherwise was an adopter.
695
+
696
+ *Equivalence was measured rather than assumed, in both directions.*
697
+ `scanProject` sorts the same `findFiles` walk with the same `isSpecFile`
698
+ predicate, so the two reads should agree — and they do, compared as sorted
699
+ `(reqId, names)` pairs over the whole repo before the old call was deleted.
700
+ Then mutation-checked, because an assertion that still passes when the read
701
+ returns nothing is not reading anything: with the map stubbed empty the
702
+ rot-binding test fails and the other nine pass, which is the blast radius the
703
+ call actually has.
704
+
705
+ **What would not go through the export is the finding, and it is one thing.**
706
+ `Inspection.registry` is merged across registry files, so an id arrives with no
707
+ record of which file declared it. `corpus()` needs that — `source` is the scope
708
+ a cross-requirement rule sees, `divergentParam` keys on it, and the DOMAINS
709
+ completeness check uses it to hold the fixture registries out — and nothing in
710
+ the public export can reconstruct it. That half still deep-imports
711
+ `readRegistrySource`, with the reason written at the call site rather than left
712
+ as an unexplained inconsistency. It is filed as a measurement and **not** as a
713
+ decision to add a field: one caller wanting per-file attribution is one
714
+ adapter, and the entry under *Under consideration* about an outward-facing
715
+ judgement schema is waiting on exactly the second one.
716
+
717
+ *Cost.* `inspectProject` reads the registry as well, which this caller does not
718
+ need — one extra static read on a tree `corpus()` already walks once per call,
719
+ seven times per run. Paid deliberately: the alternative is deep-importing the
720
+ function whose reachability from outside the package is the thing being
721
+ demonstrated.
722
+
723
+ ### Documentation
724
+
725
+ - **Design §11 now states that evidence outranks testimony, and that a
726
+ disagreement between them is a finding.** The registry and the gate's verdict
727
+ are evidence — machine-checked, recomputable, true of the tree as it stands. An
728
+ archived `proposal.md` is testimony: prose about a decision taken on one past
729
+ date that nothing has re-read since. A reader holding both holds claims in two
730
+ tenses with no stated order, and the order matters most exactly when they
731
+ conflict, because a stale proposal is more fluent than a registry and reads as
732
+ the more authoritative of the two. Decided from *Under consideration*, where it
733
+ had sat behind a blocker that was circular.
734
+
735
+ *The blocker was circular and naming the circle is what dissolved it.* The rule
736
+ was held back for describing a workflow the tool does not have — nothing read
737
+ the prose half — and the prose half was read by nothing **because no rule
738
+ existed for reading it**. Neither side moves on its own. The way out is the
739
+ split `[0.9.3]` already made for the propose-stage questions, where two of five
740
+ went to `CLAUDE.md` rather than into the document `init` writes: **this lands in
741
+ §11 only, and deliberately not in the instruction document.** §11 is a permanent
742
+ property of the design and its cost is paid once, here; the §10 document's cost
743
+ is paid in every adopting project's agent context forever, and that cost is what
744
+ the blocker was actually about. It is also the statement any later retrieval
745
+ surface would have to be built against, so writing it first is cheaper than
746
+ writing it after something depends on it.
747
+
748
+ *Re-measuring it before writing found the entry's own subject had stopped being
749
+ hypothetical, and falsified something this file recorded.* `[0.9.3]` states that
750
+ `proposal.md` in this repository "carries a link" — `CLAUDE.md` makes
751
+ `CHANGELOG.md` the single decision record, so a proposal restating an argument
752
+ would be a second store of it with nothing comparing the two — and concludes
753
+ there is nothing in it for an index to offer but its path. The very next change
754
+ to archive carries a proposal of **sixty-nine lines across five sections**, most
755
+ of its argument also present in the changelog entry. So the second store arrived
756
+ by ordinary means, in the first change that could produce one, written by an
757
+ author who knew the rule. The archived file is left as it is rather than trimmed
758
+ to a pointer: it is what the gate approved, and rewriting an archive to agree
759
+ with a claim about it is the failure this section is about, performed on itself.
760
+ §11 cites it as the measurement instead.
761
+
762
+ *What is deliberately not claimed.* The typed half cannot produce this conflict
763
+ — `first-run.json` is an `Outcome` in a schema-validated envelope, and a
764
+ scenario either is in the record or is not, so `inspectRedEvidence` reports an
765
+ absence rather than a contradiction. Nothing enforces the rule and nothing can:
766
+ reconciling prose against a registry is the ground truth §0 refuses to compute.
767
+ What §11 states is only the direction to reconcile in.
768
+
16
769
  ## [0.9.3] - 2026-08-18
17
770
 
18
771
  ### Added
@@ -5194,7 +5947,7 @@ time**.
5194
5947
 
5195
5948
  *The first time that question was asked in advance, it paid, and how it paid is
5196
5949
  worth a line because it is not what the two cases above would predict.* The
5197
- `check` `empty-spec` entry — `[Unreleased]`, the third entry to reproduce exactly
5950
+ `check` `empty-spec` entry — `[0.9.0]`, the third entry to reproduce exactly
5198
5951
  on its reported half — was not wrong about its own repair. Asking the question
5199
5952
  anyway found the defect one step to the side: the fix it proposed was correct,
5200
5953
  and the *obvious implementation of it* — make `check` agree with `verify` —
@@ -5205,83 +5958,6 @@ path the repair makes reachable; it is about **everything the repair would make
5205
5958
  `check` agree with**, and an entry that names the right fix can still be
5206
5959
  implemented wrongly by copying the half nobody has audited.
5207
5960
 
5208
- - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
5209
- The provenance work in `[0.7.2]` signs the release but does not change
5210
- what authorises it: a long-lived automation token, masked and protected, that
5211
- publishes as us to anyone holding it. npm's trusted publishing exchanges the
5212
- same OIDC identity the attestation already uses for a short-lived credential,
5213
- which would remove the stored secret entirely and grant provenance as a
5214
- property of the flow rather than a flag on the command. The deletion test
5215
- points the right way for once: what it removes is the only long-lived
5216
- credential this repository has.
5217
- **The blocker this entry was filed with is retired, and it was the wrong
5218
- question** — checked against npm's documentation on 2026-08-08. It asked
5219
- whether npm's trusted publishers accept a **gitlab.com-hosted** project on the
5220
- plan this package publishes under, and both halves come back clean: GitLab
5221
- CI/CD is a first-class provider alongside GitHub Actions, and no plan
5222
- requirement is stated anywhere in the documentation. The four fields it wants
5223
- are `Pseudorca`, `attest`, the top-level CI file path (`.gitlab-ci.yml`, and it
5224
- must carry the extension) and the allowed actions; environment name is optional
5225
- and this project uses no GitLab environments. Shared runners are the only ones
5226
- supported and the publish job carries no `tags:`, so it is already on them. The
5227
- `repository.url` cross-check needs no separate verification — provenance
5228
- already passes it with the same identity.
5229
- Two things the entry did not anticipate, both of which change the shape of the
5230
- change rather than blocking it. **`SIGSTORE_ID_TOKEN` is not replaced**: the
5231
- documented GitLab snippet declares `NPM_ID_TOKEN` with
5232
- `aud: "npm:registry.npmjs.org"` *beside* it, so this is an addition to the
5233
- `id_tokens` block, not a swap. And **the YAML is not what deletes the
5234
- credential** — enabling a trusted publisher leaves the automation token just as
5235
- able to publish; what retires it is `Require two-factor authentication and
5236
- disallow tokens` on the package, which npm states does not affect trusted
5237
- publishers. That setting is the one that closes the loop this job's own comment
5238
- opens, where an automation token is used *because* a granular token fails
5239
- against 2FA on writes: trusted publishing does not bypass the prompt, it stops
5240
- being a token.
5241
- *What blocks the decision now is `pnpm`, and it is not a version to wait for.*
5242
- **pnpm does not implement the OIDC exchange at all.** `pnpm/pnpm#11526` — the
5243
- fix for the pnpm 11 regression that made this look like a supported path —
5244
- shows what its "OIDC support" is: `actions/setup-node` writes
5245
- `_authToken=${NODE_AUTH_TOKEN}` into `.npmrc`, pnpm used to pass the unresolved
5246
- placeholder through verbatim, and the fix treats it as empty so that **npm's**
5247
- credential exchange can be the sole auth source. pnpm gets out of the way; npm
5248
- does the work. That matches npm's own wording, which requires publishing
5249
- through npm directly or indirectly, and it matches the reports: every account
5250
- of pnpm publishing under OIDC is GitHub Actions, and there is **no record of
5251
- pnpm + GitLab + OIDC succeeding**. Stated at the strength the evidence carries:
5252
- this is a strong negative inference, not a documented refusal.
5253
- So the real cost is a tooling swap — `pnpm publish` back to `npm publish` in
5254
- this one job — and it is a swap this file has already priced once in the other
5255
- direction. The comment above records that moving to pnpm took
5256
- `COREPACK_ENABLE_STRICT` with it, that override having existed only so a bare
5257
- `npm` could run under a `packageManager` pinning pnpm; going back re-adds it.
5258
- It also needs npm ≥ 11.5.1 where `node:22-slim` ships 10.x, so the job gains an
5259
- npm upgrade step. None of that is prohibitive. It is simply a different
5260
- decision from the one this entry was filed as, which is why it stays here.
5261
- *One correction to the entry's own risk framing.* "It cannot be tested except
5262
- by publishing" is half wrong: registering a trusted publisher on npmjs.com is
5263
- free and reversible, and `NPM_TOKEN` can stay in place while it is tried. What
5264
- decides whether this can be rolled out incrementally is whether a failed OIDC
5265
- exchange falls back to the `_authToken` already in `.npmrc` or fails the
5266
- publish outright — unmeasured, and the thing to establish before touching the
5267
- job.
5268
- *Method note, because it is the reusable part.* The pinned pnpm bundle was the
5269
- obvious place to look and was the wrong one — `pnpm publish --help` on 10.28.0
5270
- omits the flags it accepts, exactly as this job's comment already records for
5271
- `--provenance`, so neither the help text nor the shipped bundle would have
5272
- answered it. The answer was in the upstream pull request.
5273
- **Promoted from `Under consideration` on 2026-08-08**, and what moved it is
5274
- that the question it was filed as is answered. It asked whether npm's trusted
5275
- publishers would accept this project at all; they will. What is left is a
5276
- known tooling swap with a priced cost, which is a decision to build rather
5277
- than a question — the preamble's own line, *a decision with an argument
5278
- attached*. One thing stays unmeasured and is the first act of building it, per
5279
- the discipline three paragraphs up: whether a failed OIDC exchange falls back
5280
- to the `_authToken` already in `.npmrc` or fails the publish outright. That
5281
- decides whether this rolls out incrementally beside the existing token or in
5282
- one cut, and it is cheap to establish because registering a trusted publisher
5283
- is free and reversible.
5284
-
5285
5961
  ## Under consideration
5286
5962
 
5287
5963
  Candidates with **no decision yet**, between the two sections either side of it
@@ -5770,55 +6446,45 @@ have to gather again.
5770
6446
  design it. If it comes back the same shape, the seam is real and the schema
5771
6447
  follows; if it comes back different, `inspect` was the whole answer and the
5772
6448
  schema never needs to exist.
5773
-
5774
- - **A registry cannot say a value is deliberately undecided, and an agent that
5775
- cannot determine one will invent it.** From a read of GitHub's spec-kit
5776
- against this design the first time another SDD toolkit has been compared
5777
- here rather than OpenSpec, and the source of this entry and the three that
5778
- followed it of which two have since been decided: the audit of this
5779
- repository's own rules is rejected below, and the propose-stage coverage scan
5780
- shipped under `[0.9.3]`, as three questions rather than nine categories.
5781
- spec-kit marks an unresolved decision inline as `[NEEDS CLARIFICATION: …]`,
5782
- greppable, with a command whose whole job is converging them. Attest's
5783
- registry has two states: schema-valid, or an ERROR. There is no third.
5784
- *Why that matters here specifically, rather than as a general nicety.* The
5785
- workflow is written for an agent to follow — that is what `init` writes — and
5786
- an agent drafting a delta that cannot determine a timeout does not leave the
5787
- field out, because the schema will not let it. It picks a number. A fabricated
5788
- `params` value, then pinned by a scenario written to match it, is exactly the
5789
- drift this project exists to prevent, arriving through the mechanism meant to
5790
- stop it: the single source is perfectly consistent and describes a promise
5791
- nobody made.
5792
- *A precedent that is not one, recorded because it reads like one.*
5793
- `rationale-placeholder` already warns about authoring sloppiness in a prose
5794
- field, and the `init` document already teaches it but it is about
5795
- interpolation syntax written into the wrong field, not about a value nobody
5796
- has decided. The engine's willingness to warn on prose is the precedent; the
5797
- diagnostic is not the feature.
5798
- *The gap is narrower than "the registry cannot express doubt".* `changes/` is
5799
- already the place where a whole proposal is undecided, and the archive gate is
5800
- what settles it. What has no expression is **one field** of an otherwise
5801
- well-formed requirement.
5802
- *Shape, if it is built:* an additive `open?: string[]`, naming the fields or
5803
- questions still unresolved; a WARNING from `check`, so a proposal may be
5804
- legitimately half-finished, and an ERROR at the archive gate, so "done" is not
5805
- diluted. The same asymmetry `never-red` already runs on.
5806
- *What blocks the decision — three things.* **(1)** The frequency is entirely
5807
- unmeasured. No adoption report has recorded an agent inventing a param; the
5808
- argument above is structural, and this project has twice been wrong about a
5809
- hazard it reasoned to rather than measured. **(2)** The cheap half already
5810
- works: writing the uncertainty into `rationale` as prose costs nothing, and
5811
- the propose stage is human-reviewed regardless. What that does not buy is a
5812
- gate, which is the whole question. **(3)** It is permanent surface — a schema
5813
- field, a code, two troubleshooting sections, a History row, an `ATX-n` and its
5814
- scenarios forever — for a hazard nobody has yet reported.
6449
+ **Nothing currently makes that table arrive, which is a separate problem from
6450
+ the three blockers and the one that gates all of them.** Raised in a product
6451
+ review of `[0.9.1]`–`[0.9.3]` on 2026-08-19. The deciding evidence is an
6452
+ artifact only an adopter can produce, and the path to producing it is: a row in
6453
+ the README's document table, then a 214-line page, then a hand pass over four
6454
+ questions, then writing the two mechanical properties from a prose
6455
+ description, then choosing to report back. Five voluntary steps with no
6456
+ scaffold at any of them, against a decision this entry has been waiting on
6457
+ since `[0.9.2]`. An entry blocked on evidence that no shipped surface asks for
6458
+ is blocked indefinitely, and the wait is not itself evidence of anything.
6459
+ *The proposal is scaffolding the mechanism while still withholding the answer,*
6460
+ which is the same split `[0.9.2]` already made and not a retreat from it: an
6461
+ `examples/` directory holding the completeness and rot-binding assertions with
6462
+ **no columns** the enumeration that forces a row, and the check that a named
6463
+ scenario is one `inspectProject` really declares and the judgement columns
6464
+ left for the adopter to add. What the page withholds is this repository's four
6465
+ columns, and that stays withheld; what it currently also withholds is the two
6466
+ properties it names as *mechanical*, and those are the half where a copy is not
6467
+ an echo, because their content is fixed and the fourth finding does not apply
6468
+ to them.
6469
+ *What blocks it.* Whether a skeleton with no columns is still a shape that
6470
+ comes back as a copy — the page's argument is that a template makes the answer
6471
+ return its own question, and it is untested whether an adopter reads an empty
6472
+ frame as an invitation or as a form to fill in. And it is a second artifact
6473
+ claiming to be runnable consumer usage of the public API, which `pre-commit-check`
6474
+ §6 already records as the category no gate reaches: the page's three samples
6475
+ are unasserted today, and a fourth in a directory that looks executable would
6476
+ be worse than a fenced block, not better, unless something runs it.
5815
6477
 
5816
6478
  - **Prefix ownership is inferred rather than declared, and the cost arrives only
5817
6479
  when adoption succeeds.** Design §11 records that the id grammar has no level
5818
6480
  above the requirement: the prefix is a convention, nothing allocates it, and
5819
6481
  `duplicate-prefix` can only report a collision that already happened.
5820
6482
  spec-kit's answer is a roadmap table — a stable id, an intent, a scope
5821
- boundary, dependencies — authored above the specs it decomposes into.
6483
+ boundary, dependencies — authored above the specs it decomposes into. That
6484
+ comparison is stated here rather than referred to, because the entry that
6485
+ introduced the spec-kit reading has since been built and now sits under
6486
+ `[0.9.4]` as the `open` field: an entry whose evidence rests on a
6487
+ neighbour is one a promotion silently empties.
5822
6488
  *Two things have changed since that reading, and they point opposite ways.*
5823
6489
  The proposal is **smaller** than it looked: the mechanism partly exists.
5824
6490
  `loadRegistry` returns `prefixOwners` — first claim in sorted file order —
@@ -5857,53 +6523,26 @@ have to gather again.
5857
6523
  rename can move is a second source with nothing comparing it against the
5858
6524
  first. A derived index has no such copy to go stale: it is recomputed from the
5859
6525
  archived deltas every time it is read.
5860
- *What blocks the decision:* the empty archive the subject of the entry that
5861
- shipped under `[0.9.3]`, whose promotion this one does **not** inherit,
5862
- because the two changes that produced that corpus were ADDED-only and
5863
- exercised no rename at allwhich makes a backlink index testable only
5864
- against fixtures; and one question the OpenViking reading
5865
- does not answer, which is whether the chain is wanted at all. §7 applies
6526
+ *What blocks the decision, restated on 2026-08-19 because half of what was
6527
+ filed here is no longer true.* This entry was blocked on the empty archive and
6528
+ on the corpus being rename-free, and only the second still holds. `archive/`
6529
+ now has a real change in it the `open` field, put through the workflow the
6530
+ same day so the store this would index exists. Its delta carries `added` and
6531
+ nothing else, measured rather than assumed, so a backlink index is still
6532
+ testable only against fixtures for the operation it is actually about. Note
6533
+ what that says about the odds of the blocker clearing on its own: `--apply`
6534
+ refuses RENAMED outright, so no change this repository archives can ever
6535
+ exercise a rename until that refusal is lifted, and lifting it is a separate
6536
+ decision with its own reason (`splice.ts` — deleting an entry has no smaller
6537
+ span to fall back to). The blocker is therefore not "waiting for a rename to
6538
+ happen"; it is downstream of a refusal nobody has proposed removing. And one
6539
+ question the OpenViking reading does not answer, which is whether the chain is
6540
+ wanted at all. §7 applies
5866
6541
  `RENAMED` to a `Map<id, Requirement>` and the old id is then simply gone —
5867
6542
  deliberately, as far as anything written says. The case for recovering it is an
5868
6543
  external reference that outlives the rename, a ticket or a commit message
5869
6544
  naming a retired id, and nobody has reported one.
5870
6545
 
5871
- - **The instruction document says how to write intent and nothing about how to
5872
- read the record back.** §10's one-document-many-targets file is entirely about
5873
- authoring, and the moment anything reads `archive/` — the entry above, and the
5874
- one that shipped under `[0.9.3]` — an
5875
- agent is holding prose from a proposal agreed on some past date against a
5876
- registry that is current, with no stated order between them. OpenViking states
5877
- one for its own store in the skill it ships to coding agents
5878
- (`agent-plugins/skills/openviking-memory/SKILL.md`): retrieved memory is
5879
- advisory, and the order is system and developer instructions, then the current
5880
- request, then current environment and tool evidence, then memory — with "prior
5881
- success never authorizes a destructive action now", and "store conclusions,
5882
- not scrollback".
5883
- *Here the same rule has a harder form, because the authoritative half is
5884
- machine-checked.* The registry and the gate's verdict are evidence; an
5885
- archived proposal is testimony about a decision taken once. Where they
5886
- disagree, the disagreement is a finding — the exact thing this tool exists to
5887
- report — and not something for a reader to reconcile quietly in favour of
5888
- whichever it read last. That is a property of the design rather than of a
5889
- file, so if it is taken it lands in design §11 first and in the instruction
5890
- document second.
5891
- *What blocks the decision:* it is a rule for reading a store nothing reads yet,
5892
- so on its own it would document a hazard that cannot currently arise. It is the
5893
- cheapest of the three and could ship first, at the price of being the only
5894
- paragraph in that file describing a workflow the tool does not have — and §10's
5895
- economics make that price the same in every adopter's agent instructions at
5896
- once.
5897
- **That blocker is retired, and what replaced it is narrower than the entry
5898
- assumed.** Something reads `archive/` as of `[0.9.3]`, so the hazard can
5899
- now arise. But what shipped reads the **typed** half — `first-run.json`, whose
5900
- every value is an `Outcome` in a schema-validated envelope — and evidence that
5901
- parses cannot disagree with the registry in the way testimony can: a scenario
5902
- either is in the record or is not. The rule this entry wants is about the prose
5903
- half, and the prose half is still read by nothing. So the store is no longer
5904
- write-only and the entry's own subject is still hypothetical, which is a
5905
- different blocker from the one it was filed with rather than none.
5906
-
5907
6546
  - **Changing the value of an existing param passes no gate, and for seven of
5908
6547
  them there is no record either.** Split out of the tautology entry at the
5909
6548
  head of this section on 2026-08-18, where it was one sentence inside the
@@ -5956,6 +6595,189 @@ have to gather again.
5956
6595
  looking at already being that page's subject, and this being a fifth of the
5957
6596
  same kind.
5958
6597
 
6598
+ - **`/inspect` returns five exported shapes and the page telling people to build
6599
+ on them says nothing about whether they move.** Filed 2026-08-19 from a product
6600
+ review of `[0.9.1]`–`[0.9.3]`. `inspect.ts` exports `Inspection`,
6601
+ `ScenarioEvidence`, `EvidenceRow`, `ArchivedChangeRef` and
6602
+ `RedEvidenceInspection`, and `intent-quality.md` carries three samples
6603
+ destructuring them. `SCHEMA_VERSION` governs the `--json` envelope and nothing
6604
+ governs these.
6605
+ *The policy is not missing — its statement is.* The Versioning section at the
6606
+ head of this file already binds them: removing or renaming a public API bumps
6607
+ the minor, and under 0.x that is the breaking bump. So a reader who finds that
6608
+ section is already told what they need. The gap is that the reader this export
6609
+ was written for arrives through `intent-quality.md`, which is a method page
6610
+ reached from the README's document table, and nothing on the path between the
6611
+ two states it.
6612
+ *Why it is worth a decision rather than a one-line edit.* The two candidate
6613
+ sentences say opposite things to the person this needs. Restating the existing
6614
+ promise reads as a stability guarantee and is the honest summary of current
6615
+ policy; saying the shape may move before 1.0 is the more useful warning and
6616
+ discourages exactly the adopter whose table would decide the judgement-schema
6617
+ entry above. Which one is right depends on the 1.0 entry below, which is why
6618
+ this is filed rather than fixed.
6619
+ *One thing already measured, in the other direction.* `pre-commit-check` §6
6620
+ records that changing what `/inspect` returns breaks three usage samples that
6621
+ no gate can see. The internal awareness of this surface's fragility therefore
6622
+ exists and is written down; the outward-facing half is what does not.
6623
+
6624
+ - **The store worth retrieving is this file, not `archive/`, and no shape has
6625
+ been proposed for either.** Filed 2026-08-19 from a product review of
6626
+ `[0.9.1]`–`[0.9.3]`, and it is the OpenViking reading turned back on the
6627
+ repository that did the reading. Everything that came out of it is about
6628
+ `archive/`, and the entries are no longer counted here — the number was wrong
6629
+ on the day it was written, was corrected on 2026-08-19, and was wrong again by
6630
+ the next commit, when the precedence rule left for design §11. Three strikes on
6631
+ one figure is the section's own preamble arriving in the first person: a count
6632
+ kept in prose with nothing to check it goes wrong quietly, and the repair is to
6633
+ stop keeping one rather than to keep fixing it. But `archive/` holds `first-run.json` — typed,
6634
+ low-information, and now read — beside a `proposal.md` that in this repository
6635
+ carries a link, for the reason `[0.9.3]` gives: `CLAUDE.md` makes this file the
6636
+ single decision record. **So the store that actually holds decisions with their
6637
+ evidence attached is `CHANGELOG.md`, at 7,100 lines with the three foot
6638
+ sections past 1,700 of them,** and the property that makes it valuable — a
6639
+ design evaluated, priced and killed, with the reasoning that killed it — is
6640
+ exactly the property that makes it unreadable in one pass. It is append-only
6641
+ with no retrieval, which is the shape the OpenViking reading was about.
6642
+ *What an agent would actually consume is a composition, not a new store.*
6643
+ Given an `ATX-n`: its statement and params, the scenarios covering it, the
6644
+ first-run evidence for each, and the paths to the change that introduced it.
6645
+ Four of the five are already computable — `inspectProject` answers two,
6646
+ `inspectRedEvidence` the third and the archived refs the fourth — and the fifth,
6647
+ the reasoning, is the one with no index and no key. That asymmetry is the
6648
+ finding: the retrieval problem is not in the half that was just made readable.
6649
+ *One thing to unmerge before deciding.* `[0.9.3]` settled a question against
6650
+ the fifth static command, and it settled it correctly and on the right
6651
+ grounds — `first-run.json` is JSON, and `ATX-16`'s subject is reading
6652
+ TypeScript without evaluating it, so joining that roster would turn a security
6653
+ promise into a list of commands that do not run the suite. But that is a
6654
+ ruling about **roster membership**, and it is being carried as though it also
6655
+ ruled on whether such a command should exist. Those are separate, and only the
6656
+ first was argued.
6657
+ *What blocks the decision, and what would cheaply unblock it.* No measurement
6658
+ exists of whether a bundle like this changes what an agent does — the one datum
6659
+ quoted in `[0.9.3]` measured demand to read the archive **as prose**, which is
6660
+ a third question again. The proposal is therefore a probe and not a build,
6661
+ per this section's own discipline of re-measuring before implementing: assemble
6662
+ the bundle by hand for one requirement, run one real change against it, and
6663
+ record whether it was used, ignored, or used wrongly. One session, and it
6664
+ decides between a command, a skill surface, and nothing. Building any of the
6665
+ three before that measurement is the order `divergent-param` was designed in
6666
+ and the order this file has already paid for once.
6667
+
6668
+ - **A stage-1 spec can read its params through `delta.added` instead of by id,
6669
+ and the merge that repoints its import cannot repoint that.** Filed 2026-08-19
6670
+ from the first change this repository put through its own `changes/` workflow,
6671
+ where it was written by the author of the workflow's own tooling — which is
6672
+ most of why it is worth an entry rather than a fix.
6673
+ `delta()` returns the delta *and* the registry of what it adds
6674
+ (`withProposedRequirements`), so `reqs['ATX-81'].params.field` reads a proposed
6675
+ requirement before the merge and a merged one after it. That is the whole
6676
+ reason merging a spec changes its import and nothing else. Reaching through
6677
+ `added` — `d.added['ATX-81'].params.field` — is equally correct during stage 1
6678
+ and throws the moment `--apply` repoints the specifier at the registry, because
6679
+ a registry has no `added`.
6680
+ **Nothing catches it, and the reason is structural rather than an oversight.**
6681
+ The archive gate runs *before* the rename, so the spec it graded is not the
6682
+ spec the merge commits; `check` executes nothing, so it never evaluates the
6683
+ accessor; and the type checker is satisfied on both sides of the merge only
6684
+ because it is run against whichever half is currently on disk. It surfaced at
6685
+ the next `verify`, as `spec-load-failed` — a correct diagnosis of the symptom
6686
+ that names the file and not the cause. The window between `--apply` and the
6687
+ next `verify` is the whole exposure, and in a workflow that ends at `--apply`
6688
+ it is unbounded.
6689
+ **The guidance already exists and did not prevent it, which is the finding.**
6690
+ `registry.ts`'s doc comment on `delta` states the intended expression and the
6691
+ rejected one, in those terms, on the function itself; the document `init`
6692
+ writes shows the by-id form in its worked example. So this is not a gap in what
6693
+ is written. It is a case of the correct form and an incorrect one being
6694
+ *indistinguishable at the moment of writing* — both compile, both pass, both go
6695
+ red for the right reason — with the difference appearing one command later.
6696
+ *Two shapes, and they point opposite ways, which is why this is filed rather
6697
+ than fixed.* **(1)** A `check` diagnostic: `parser.ts` already walks property
6698
+ accesses inside spec bodies to find `.params`, so finding `.added` on a binding
6699
+ imported from a `requirements.delta` path is the same walk and the same file.
6700
+ It has to be a WARNING, because enumerating what a change adds is a legitimate
6701
+ thing for a spec to do, and a WARNING is exactly the strength that did not stop
6702
+ the author here. **(2)** A type-level refusal: have `delta()` return a type
6703
+ without `added`, so `typecheck:all` refuses it with no new diagnostic, no
6704
+ roster entry and no runtime cost. `DefinedDelta<T> = T & ProposedRequirements<T>`
6705
+ carries `added` because `T` does, so this means `Omit<T, 'added'>` — and that
6706
+ breaks the evaluating loader, which hands the same default export to
6707
+ `applyDelta` typed as `RegistryDelta`. Whether that break is real or just needs
6708
+ the internal path to name a different type is **unmeasured, and is the first
6709
+ thing to establish**; it decides between the two shapes rather than being a
6710
+ detail of one.
6711
+ *What blocks the decision:* frequency, and it is the same blocker the `open`
6712
+ entry carried before it — one occurrence, in one repository, on the first
6713
+ change ever run through the workflow. That is a report rather than a
6714
+ hypothetical, which is more than most entries here start with, and it is still
6715
+ n=1. What would settle it cheaply is the next change: if the by-id form is
6716
+ written without prompting, the guidance is sufficient and this is an anecdote;
6717
+ if the reach-through appears again, the two shapes above are worth pricing.
6718
+
6719
+ - **Nothing is frozen, and the evidence this repository is waiting for requires
6720
+ somebody to build on something.** Filed 2026-08-19 from a product review of
6721
+ `[0.9.1]`–`[0.9.3]`. Three releases in five days each added public surface, and
6722
+ as of `[0.9.3]` the CLI, `./define`, `./inspect`, the archive layout, the
6723
+ judgement schema and any retrieval surface are all simultaneously open. Every
6724
+ entry in this section that is blocked on an adopter — the judgement table above
6725
+ being the sharpest — is asking someone to invest in a surface that is
6726
+ documented as movable. That is a coherent thing to ask under 0.x and it is also
6727
+ the reason the ask may never be answered.
6728
+ *The proposal is a line rather than a milestone:* 1.0 freezes the CLI, `./define`
6729
+ and `./inspect`, and everything currently blocked on an adopter's evidence —
6730
+ the judgement schema, the backlink index, any archive retrieval — is explicitly
6731
+ 1.x. What that buys is not a version number; it is the only condition under
6732
+ which "build your own counter-pressure on our export" is a reasonable thing to
6733
+ ask of somebody.
6734
+ *What blocks it.* Freezing `./inspect` five days after shipping it is freezing
6735
+ on one adapter, which is the objection this repository raises against every
6736
+ other premature interface and does not get to waive for its own. And the
6737
+ release-notes discipline cuts both ways here: a 1.0 is a claim about what the
6738
+ cost of adoption will be, and `Planned`'s own preamble records that **what a
6739
+ release costs its adopters is not known on the day it is cut**. There is no
6740
+ measurement to date of anyone adopting either export.
6741
+
6742
+ - **When one requirement carries several scenarios, some of those sets are
6743
+ differential evidence and nothing in the model says which.** Prompted by an
6744
+ external write-up (`columns.chicken-house.net`, 2025-11-10, "AI-First Testing
6745
+ Workflow"), which ran one test case against a system's REST API and against its
6746
+ web UI and found the empty-basket check implemented in the UI and absent from
6747
+ the API. Neither run failed against its own expectation; the finding was that
6748
+ two runs of the *same* case disagreed.
6749
+
6750
+ Attest already permits the arrangement — a `reqId` takes any number of
6751
+ attesting scenarios, and `cover` asks only for ≥1. What it cannot see is the
6752
+ difference between three scenarios covering three edges of one requirement and
6753
+ three scenarios crossing three interfaces at the *same* edge. The second set
6754
+ can be asserted to agree; the first cannot, and there is nothing to compare.
6755
+ This repository runs the pattern on itself — `tests/static-registry.spec.ts`
6756
+ asserts the two registry readers agree — so the structure is known to work
6757
+ where the two adapters are known to be two adapters.
6758
+
6759
+ *What blocks it, in the order that decides it.* **The seam is unmeasured.** One
6760
+ adapter is a hypothetical seam and two is a real one; nothing has been walked
6761
+ to establish whether any adopted registry has a `reqId` whose scenarios
6762
+ genuinely cross interfaces rather than covering one surface repeatedly. Until
6763
+ that is counted this is a pattern observed in somebody else's codebase, and it
6764
+ is cheap to count. **The fact is not in the model.** Which interface a scenario
6765
+ crosses is recorded nowhere the engine reads, so supplying it means new
6766
+ registry or spec surface — a per-scenario axis — paid for by every adopter
6767
+ including all of the ones with a single interface, in the shape of a thing they
6768
+ must learn not to write. Whatever carried it would also have to be a literal in
6769
+ the AST, because `cover` executes nothing and that is not negotiable for it.
6770
+ **There is no verdict to emit.** The engine cannot perform the comparison;
6771
+ agreement between two runs is an assertion and assertions belong to the test.
6772
+ The most it could report is that a requirement has scenarios on more than one
6773
+ axis, which is information with no pass or fail attached — a new output
6774
+ category rather than a new check. **And the prose alternative is untried.**
6775
+ Apply the deletion test before the field: if the workflow document `init`
6776
+ writes (§9) simply says that two scenarios crossing one requirement at
6777
+ different interfaces should assert the same expectations, does the practice
6778
+ happen anyway? If it does, the engine gained nothing and charged everyone a
6779
+ field for it.
6780
+
5959
6781
  ## Considered and rejected
5960
6782
 
5961
6783
  Decisions **not** to build something, kept where they can be found before the
@@ -5966,6 +6788,41 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
5966
6788
  sinking one version deeper each time a release was cut above it, and a rejection
5967
6789
  filed under a version reads as belonging to it.
5968
6790
 
6791
+ ### Rewriting the placeholder scan in `prose` to drop its tail slice
6792
+
6793
+ Priced on 2026-08-21, during a standing sweep for more of the ATX-59 shapes, and
6794
+ rejected by the measurement that was supposed to confirm it.
6795
+
6796
+ `prose` (`core/render.ts`) walks a statement character by character, and at every
6797
+ `{` it runs `/^\{\w+\}/.exec(text.slice(i))`. Read as source that is the §2
6798
+ quadratic-scan shape exactly: one fresh copy of the remaining string per `{`, so
6799
+ a statement of n braces copies n²/2 bytes — and a statement is prose a fork
6800
+ contributor writes and `render --check` reads under the static reader, which is
6801
+ the class that has actually shipped defects here. The fix was to be a sticky
6802
+ `/\{\w+\}/y` with `lastIndex = i`, which allocates nothing.
6803
+
6804
+ **It is not quadratic, because V8 does not copy.** `String.prototype.slice` on a
6805
+ string past a small threshold returns a *sliced string* — a view onto the parent
6806
+ with an offset, made in constant time — and an anchored regex over it reads only
6807
+ the few characters it needs, so the copy the source implies never happens.
6808
+ Measured through `renderMarkdown` on the built output, three adversarial shapes
6809
+ (all braces; alternating `{a`; a brace run followed by a word run), at 100k
6810
+ through 800k characters: every one of them scaled at a ratio of **1.77–1.89 per
6811
+ doubling**, i.e. linear, and 800,000 braces rendered in **106 ms** against
6812
+ ATX-59's 1,000 ms budget. A quadratic implementation at that size would be tens
6813
+ of seconds; this is the same order as the linear cases beside it.
6814
+
6815
+ So there is no defect and no change. What the sticky rewrite would buy is a
6816
+ constant factor nobody has shown a need for, against §5's rule that
6817
+ micro-optimisation with no named input is out of scope — the input *is* named
6818
+ here, and it is already absorbed. Filed rather than dropped because the site
6819
+ reads like the defect on inspection and will be proposed again by the next sweep
6820
+ that greps for `.slice(` near a loop: the answer is that V8's representation is
6821
+ load-bearing, the measurement is the evidence, and the thing to re-run is the
6822
+ ratio rather than the reasoning. Note the limit of that claim — it rests on a V8
6823
+ representation rather than on anything the language promises, which is safe for a
6824
+ Node CLI and would not transfer to a runtime that flattens eagerly.
6825
+
5969
6826
  ### Deferring the `typescript` import so `init` and `--version` do not pay it
5970
6827
 
5971
6828
  Priced on 2026-08-13, in the session that enabled the compile cache above, and
@@ -6138,7 +6995,7 @@ weaker signal "some registry file failed to load, so suppress all
6138
6995
  `orphan-test`" is the other candidate and is a strictly worse `check` on a
6139
6996
  multi-file registry, where the other files are fine and their orphans are real.
6140
6997
  **The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
6141
- fixed under `[Unreleased]`, and the blocker above was the wrong question.**
6998
+ fixed under `[0.7.4]`, and the blocker above was the wrong question.**
6142
6999
  The mapping this entry called for could not be recovered, and did not have to
6143
7000
  be: what a suppression needs is not the broken file's *prefix* but the ids it
6144
7001
  declares, and those are written in its source. Reading them back is a source
@@ -6164,7 +7021,7 @@ breadth argument recorded above, which points the other way for both commands.
6164
7021
  *What rejected it is that the argument for it was entirely the noise, and the
6165
7022
  noise is gone.* Refusing was never justified by the load failure itself — it was
6166
7023
  justified by the wall of derived findings burying it, and the collapse under
6167
- `[Unreleased]` removed the wall without touching either command's breadth. What
7024
+ `[0.7.4]` removed the wall without touching either command's breadth. What
6168
7025
  is left on the table is the breadth argument alone, and it points the other way
6169
7026
  for both: `check` is the command a pipeline runs first, its contract is seeing
6170
7027
  everything it can still see, and answering with three findings instead of nine
@@ -6265,7 +7122,7 @@ measurement** saying parsing costs anything here — this reader is
6265
7122
  `createSourceFile` per file with no type checking, which is the cheap half of
6266
7123
  what arktype pays for, and the fan-out measurement — filed as an entry in
6267
7124
  `Under consideration`, and now shipped as the two concurrency bounds in
6268
- `[0.7.0]` and `[Unreleased]` — clocked `attest check` over 6000 spec files at
7125
+ `[0.7.0]` and `[0.9.0]` — clocked `attest check` over 6000 spec files at
6269
7126
  1.99 s total. Worse, a cache is a correctness hazard
6270
7127
  of precisely the kind this tool exists to detect: a stale analysis makes a
6271
7128
  **drift detector** report drift that has already been fixed, or miss drift
@@ -6287,7 +7144,7 @@ to be stronger than an mtime before the trade is even worth pricing.
6287
7144
 
6288
7145
  ### Zod Mini (`zod/v4-mini`) for the registry schema
6289
7146
 
6290
- Evaluated alongside v4 (which shipped under `[Unreleased]`), on the same
7147
+ Evaluated alongside v4 (which shipped under `[0.8.0]`), on the same
6291
7148
  schema and the same 69-requirement registry, and it wins both numbers it is
6292
7149
  supposed to win:
6293
7150
  **0.249 ms** per parse against v3's 3.47 ms, and — the one v4 does not deliver —
@@ -6569,7 +7426,7 @@ current.
6569
7426
 
6570
7427
  The mechanism `ATX-37` sat under `Under consideration` waiting for, kept there
6571
7428
  on cost, and killed by measuring that cost rather than by paying it. The three
6572
- sites it was aimed at are closed under `[Unreleased]` by hand-written scenarios;
7429
+ sites it was aimed at are closed under `[0.7.4]` by hand-written scenarios;
6573
7430
  what is rejected is the roster, and the argument is that **`src/cli/report.ts`'s
6574
7431
  exports are the wrong roster in both directions at once.**
6575
7432
 
@@ -6860,6 +7717,8 @@ rather than a scorer, which ranks.
6860
7717
 
6861
7718
  ## Releases
6862
7719
 
7720
+ [0.10.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.10.0
7721
+ [0.9.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.4
6863
7722
  [0.9.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.3
6864
7723
  [0.9.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.2
6865
7724
  [0.9.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.1