@am_shork/attest 0.4.0 → 0.4.2

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +945 -225
  2. package/README.md +48 -292
  3. package/dist/cli/index.js +9 -5
  4. package/dist/cli/json.js +25 -1
  5. package/dist/cli/report.d.ts +20 -0
  6. package/dist/cli/report.js +32 -44
  7. package/dist/core/apply.js +49 -1
  8. package/dist/core/docs.d.ts +23 -0
  9. package/dist/core/docs.js +92 -0
  10. package/dist/core/loader.d.ts +22 -0
  11. package/dist/core/loader.js +35 -1
  12. package/dist/core/pipeline.js +2 -3
  13. package/dist/core/red-record.d.ts +9 -1
  14. package/dist/core/red-record.js +67 -9
  15. package/dist/core/runner.d.ts +11 -0
  16. package/dist/core/runner.js +12 -1
  17. package/dist/core/schema.d.ts +12 -0
  18. package/dist/core/schema.js +15 -1
  19. package/dist/core/skill.js +13 -2
  20. package/dist/core/terminal.d.ts +24 -0
  21. package/dist/core/terminal.js +59 -0
  22. package/dist/core/types.d.ts +22 -1
  23. package/dist/{cli → core}/version.js +5 -1
  24. package/dist/core/write.js +37 -5
  25. package/package.json +5 -3
  26. package/dist/cli/index.d.ts.map +0 -1
  27. package/dist/cli/index.js.map +0 -1
  28. package/dist/cli/json.d.ts.map +0 -1
  29. package/dist/cli/json.js.map +0 -1
  30. package/dist/cli/report.d.ts.map +0 -1
  31. package/dist/cli/report.js.map +0 -1
  32. package/dist/cli/version.d.ts.map +0 -1
  33. package/dist/cli/version.js.map +0 -1
  34. package/dist/core/apply.d.ts.map +0 -1
  35. package/dist/core/apply.js.map +0 -1
  36. package/dist/core/gate.d.ts.map +0 -1
  37. package/dist/core/gate.js.map +0 -1
  38. package/dist/core/loader.d.ts.map +0 -1
  39. package/dist/core/loader.js.map +0 -1
  40. package/dist/core/locate.d.ts.map +0 -1
  41. package/dist/core/locate.js.map +0 -1
  42. package/dist/core/order.d.ts.map +0 -1
  43. package/dist/core/order.js.map +0 -1
  44. package/dist/core/parser.d.ts.map +0 -1
  45. package/dist/core/parser.js.map +0 -1
  46. package/dist/core/paths.d.ts.map +0 -1
  47. package/dist/core/paths.js.map +0 -1
  48. package/dist/core/pipeline.d.ts.map +0 -1
  49. package/dist/core/pipeline.js.map +0 -1
  50. package/dist/core/red-record.d.ts.map +0 -1
  51. package/dist/core/red-record.js.map +0 -1
  52. package/dist/core/registry.d.ts.map +0 -1
  53. package/dist/core/registry.js.map +0 -1
  54. package/dist/core/render.d.ts.map +0 -1
  55. package/dist/core/render.js.map +0 -1
  56. package/dist/core/runner.d.ts.map +0 -1
  57. package/dist/core/runner.js.map +0 -1
  58. package/dist/core/schema.d.ts.map +0 -1
  59. package/dist/core/schema.js.map +0 -1
  60. package/dist/core/skill.d.ts.map +0 -1
  61. package/dist/core/skill.js.map +0 -1
  62. package/dist/core/static-registry.d.ts.map +0 -1
  63. package/dist/core/static-registry.js.map +0 -1
  64. package/dist/core/status.d.ts.map +0 -1
  65. package/dist/core/status.js.map +0 -1
  66. package/dist/core/targets.d.ts.map +0 -1
  67. package/dist/core/targets.js.map +0 -1
  68. package/dist/core/types.d.ts.map +0 -1
  69. package/dist/core/types.js.map +0 -1
  70. package/dist/core/validator.d.ts.map +0 -1
  71. package/dist/core/validator.js.map +0 -1
  72. package/dist/core/write.d.ts.map +0 -1
  73. package/dist/core/write.js.map +0 -1
  74. package/dist/index.d.ts.map +0 -1
  75. package/dist/index.js.map +0 -1
  76. package/dist/runtime.d.ts.map +0 -1
  77. package/dist/runtime.js.map +0 -1
  78. /package/dist/{cli → core}/version.d.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -13,6 +13,550 @@ 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.4.2] - 2026-07-30
17
+
18
+ ### Added
19
+
20
+ - **The engine ↔ runner boundary is now a gate over the import graph, and it
21
+ had already been crossed.** Design §11 records the widest limit this design
22
+ has: `typescript` is a runtime dependency and `vite`/`vitest` are hard peers,
23
+ so the addressable set of repositories is a subset of one ecosystem's. The
24
+ answer that escapes it is a package split — a core that does not know a runner
25
+ exists plus one thin adapter per runner — and **that split is deliberately not
26
+ done**: a monorepo's build matrix, version coupling and CI fan-out are not
27
+ earned by one package at 0.4.x, and the dogfooding loop is what would pay,
28
+ since `bin/attest.js` loads `dist/` and `self/` is verified through it.
29
+ What ships is the cheap half. `tests/import-boundary.spec.ts` asserts that
30
+ exactly three modules may name `vite`/`vitest` — `src/runtime.ts` (the shipped
31
+ runtime, which is Vitest's `describe`/`it` under Attest's names and executes
32
+ *inside* the child run), `src/core/loader.ts` (the Vite loader) and
33
+ `src/core/runner.ts` (the only module that starts Vitest). It reads the
34
+ specifiers from the AST rather than matching text, so a comment naming
35
+ `vitest/node` is not an import and a dynamic `import()` is one.
36
+ **The boundary was not intact when the gate was written**, which is the whole
37
+ argument for having it: `pipeline.ts` imported `configDefaults` from
38
+ `vitest/config` for one exclude list, putting the peer dependency on the module
39
+ every static command goes through — `check`, `cover` and `render` included.
40
+ Nothing was wrong with the behaviour and no behavioural test could have been
41
+ wrong about it. Only the graph shows it, and a boundary is lost exactly this
42
+ way: one convenient import, in a module nobody thought of as being on the
43
+ runner's side of the line.
44
+ *What it cannot do:* gate **reachability** from the static commands, which is
45
+ not assertable today — `pipeline.ts` holds both halves of the engine, so it
46
+ reaches `runner.ts` unconditionally. That is the same fact the deferred split
47
+ would fix. A second test narrows the reachability claim to where it is both
48
+ meaningful and true: no path from `static-registry.ts` or `parser.ts` — the two
49
+ modules that must read a file without executing it — reaches a runner through
50
+ any module.
51
+
52
+ - **A positive control on the non-execution tests, which were passing without
53
+ one.** The tests asserting that `check`, `cover`, `render` and `status` never
54
+ execute the project work by writing a sentinel file at a registry's module
55
+ scope and expecting it *absent* afterwards. Every one of those assertions is
56
+ equally satisfied by an instrument that never worked: a `writeFileSync` to a
57
+ path that could not be written, or a registry the loader never reached, reads as
58
+ "absent" — indistinguishable from the guarantee being kept. `runVerify` now
59
+ runs last against the same project and the same sentinel, and the sentinel must
60
+ be *present*, which is what turns four absences into evidence. Added in the
61
+ default suite (`tests/pipeline.spec.ts`) and dogfooded under `ATX-16`
62
+ (`self/registry-reader.spec.ts`), since `vitest run` does not run `self/`.
63
+ *Not a new requirement:* this states nothing new about behaviour, it makes
64
+ `ATX-16`'s existing claim falsifiable — so it belongs to that scenario as its
65
+ control rather than to an `ATX-n` of its own.
66
+ *What is still not covered:* `archive` has no positive control of its own. It
67
+ evaluates the delta through the same `loader.load` the `--eval` escape hatch
68
+ uses, and that path is controlled under `ATX-32`, but nothing asserts that
69
+ `archive` specifically would notice if it stopped evaluating.
70
+
71
+ - **The `typescript` range is now run at both ends.** `[0.4.0]` collapsed the
72
+ peer range to `vite ^8` / `vitest ^4` on the argument that advertising a
73
+ version no job runs end to end moves the failure into the adopter's
74
+ repository. That test had never been applied to the third dependency it fits:
75
+ `typescript` is a runtime `dependencies`, and the range was a claim about every
76
+ 5.x while CI resolved exactly one of them — 5.9.3, four minors above the floor
77
+ the manifest advertised, which had therefore never been run at all.
78
+ It matters more here than for the peers, because a caret on a direct
79
+ dependency is not this repo's to resolve: a package manager may dedupe it
80
+ against the adopter's own `typescript`, so `parser.ts` and `static-registry.ts`
81
+ — the only two import sites, and both on the path that must read an AST and
82
+ never evaluate it — execute on whichever compiler in the range that project
83
+ already had. TypeScript is also not distributed under SemVer but as
84
+ `{marketing}.{major}.{minor}`, so a "minor" inside a major is free to change
85
+ parse behaviour.
86
+ The new `typescript-matrix` job installs each end and re-runs the differential
87
+ suite (`tests/static-registry.spec.ts`, the one extractor changes were already
88
+ required to go through) plus `tests/parser.spec.ts`. With the lockfile at 5.9.3
89
+ that is three points: 5.5.4, 5.9.3, 6.0.3. **Measured green at all three**, and
90
+ the floor is the one that had never been checked.
91
+ *What it cannot do:* prove the extractor against syntax this repo's corpus does
92
+ not contain — the same limit the History gate carries.
93
+ *No scheduled `typescript@rc` job*, which the queued entry had asked for. It is
94
+ rejected on measurement rather than skipped: `rc` and `latest` are 7.x, which
95
+ exposes no compiler API (see `Planned`), so the job would be red forever — the
96
+ decoration the `audit` job's comment refuses.
97
+
98
+ - **The README's dependency versions are now asserted against the manifest.**
99
+ Found by running the pre-commit list over this change rather than by reading the
100
+ diff: the Prerequisites section makes version claims about all three
101
+ dependencies, the manifest is the only place any of them is true, and nothing
102
+ bound the two. The existing gates in `tests/docs-consistency.spec.ts` pin
103
+ Attest's *own* version into samples and pinned docs URLs; the peer range had
104
+ been unbound prose since `[0.4.0]` collapsed it, which means the sentence
105
+ claiming the range is "exactly the one CI runs" was the one line in it nothing
106
+ ran. This change would have widened that hole by adding a second such claim, so
107
+ it closes it instead.
108
+ The coupling to wording is the cost, and it is narrower than it looks: the
109
+ string matched **is** the claim, unlike an `Issue.message`, which is prose
110
+ precisely so it can be reworded. Rephrasing the sentence is free as long as the
111
+ version stays in it, which is what the failure says.
112
+
113
+ ### Changed
114
+
115
+ - **`typescript` widened to `^5.5.0 || ^6.0.0`, and stays a `dependency`.** The
116
+ open question the queued entry raised was whether it should be a **peer**
117
+ instead: a runtime dependency is what buys determinism in principle, dedupe is
118
+ what removes it in practice, so the manifest stated a guarantee the resolver
119
+ does not honour. Measuring it answered the question in the opposite direction
120
+ from the way it was posed.
121
+ **A peer is not available, because the API is not.** TypeScript's `latest` is
122
+ now 7.x, whose npm package is the native port: its main export is
123
+ `{ version, versionMajorMinor }` and the AST moved behind
124
+ `typescript/unstable/ast*` — explicitly unstable. A peer range would therefore
125
+ either exclude 7 and so warn every adopter on the current release into
126
+ installing an older compiler explicitly, or admit one that cannot parse
127
+ anything. Bundling is the only shape that guarantees the API exists at all, so
128
+ what looked like a hedge is the load-bearing decision. Recorded under
129
+ `Considered and rejected` with the measurement.
130
+ The ceiling moved because 6.0.3 measures green on the full API surface the two
131
+ readers use and on both typecheck projects, and because the ceiling had become
132
+ a claim about a major that has since shipped and works. A TS-6 adopter now
133
+ dedupes to one copy running the compiler that matches their own syntax instead
134
+ of installing a second 5.9.
135
+ *Not a bump under this file's Versioning rule, and the reason is worth being
136
+ exact about.* No input that was valid is rejected and no exit code moves. An
137
+ already-installed adopter is untouched, because their lockfile pins what they
138
+ resolved. A **fresh** install genuinely can differ — in a repository already on
139
+ TypeScript 6 the resolver may now dedupe there where it previously fetched a
140
+ 5.x of its own, so the compiler behind the AST readers changes. That is a real
141
+ change and not a hidden one: it is the entire purpose of widening the range,
142
+ both ends are gated by the job above, and the widening only ever admits a
143
+ compiler this repo runs the differential suite against. What would make it a
144
+ breaking change is a range whose ends are *asserted* rather than run, which is
145
+ the state this entry ends. This repo's own resolution did not move: the lockfile
146
+ still holds 5.9.3.
147
+
148
+ - **`pipeline.ts` no longer imports `vitest/config`.** `runArchive` needed
149
+ Vitest's default exclusions to compose its own exclude set; the list is now
150
+ re-exported as `BASE_EXCLUDE` from `runner.ts`, which already had the import
151
+ and is one of the three modules allowed to. *Not a behaviour change* — the
152
+ composed exclude set is identical, and `BASE_EXCLUDE` is internal, not part of
153
+ the package's exports.
154
+
155
+ ### Documentation
156
+
157
+ - **§11's language-binding bullet now records an answer deferred rather than no
158
+ answer.** It stated the limit — one ecosystem's repositories, no more — and
159
+ said nothing about what holds the line in the meantime. It now says that the
160
+ dependency is confined to three modules and that the confinement is gated on
161
+ the import graph rather than left to review, that the confinement had already
162
+ failed once, and that the package split which actually escapes the limit is
163
+ deferred on cost grounds rather than unconsidered.
164
+ It also states the part of the binding that is **narrower** than "an
165
+ ecosystem", and which has already moved: the AST layer needs the TypeScript
166
+ *compiler API*, and TypeScript 7 does not ship one. So what Attest installs
167
+ into is a repository whose compiler is of a particular generation — which is
168
+ why the range has a ceiling and why the compiler is bundled rather than a peer.
169
+ §0 bought the parse layer with a dependency on one vendor's internal API, and
170
+ "never regex" is what makes that worth paying; the point now written down is
171
+ that the bill arrives again every time the API moves, on a schedule nobody here
172
+ controls. Both languages.
173
+
174
+ - **The README says which TypeScript versions are supported, and that yours is
175
+ not involved.** Prerequisites already explained the peer range and why it is
176
+ exactly what CI runs; the bundled compiler was not mentioned at all, so an
177
+ adopter on TypeScript 7 seeing a second `typescript` in their tree had nothing
178
+ to read. It now states the range, that both ends run in CI, that a TypeScript
179
+ 5-or-6 project dedupes to one copy, and that their own compiler version stays
180
+ their choice.
181
+
182
+ - **Said out loud that the archive gate trusts `first-run.json` and cannot check
183
+ it.** The reasoning had been written down since 0.3.0 — in a comment at the top
184
+ of `red-record.ts`, where no adopter reads it. What they read instead was that
185
+ `archive` is an executable gate and that this file is "the evidence", which
186
+ over-sells what running it buys: `hasRecordedRed` compares against `'fail'` and
187
+ nothing else, so a hand-written record clears `never-red` having proved
188
+ nothing.
189
+ The distinction worth stating, and now stated, is between the two kinds of file
190
+ Attest writes. The workflow document and a rendering are *derivable* — a pure
191
+ function of nothing, and a pure function of the registry — so a checker can
192
+ regenerate them and compare, which is exactly what `render --check` does. The
193
+ first-run record is not, and cannot be: it records that a scenario failed
194
+ **once**, and by the time a change is archivable everything is green, so there
195
+ is nothing left to recompute it from. That is inherent to what the file is
196
+ rather than a gap left open — if the state could be derived at archive time,
197
+ the record would not need to exist at all. So it is the one thing Attest writes
198
+ that cannot be checked by regenerating it, and what stands in for that check is
199
+ a reviewer. (The workflow document has no gate either, but that is a decision —
200
+ `init --check` was rejected, see below — not an impossibility.)
201
+ Landed in `troubleshooting.md#never-red` and under `attest archive` in the CLI
202
+ reference (both languages), and in the workflow document `attest init` writes —
203
+ where it also became a fourth entry in "things you must not do", beside editing
204
+ a scenario to match the implementation. Hand-writing the record belongs in that
205
+ list on the list's own terms: it turns the gate green without changing the
206
+ system. **Re-run `attest init` to pick it up.**
207
+ *Not in the README*, which links to the reference for this and stays the pitch
208
+ and the five-minute start. *Not a behaviour change*: nothing about what the
209
+ gate accepts moved, and binding the record to a CI run — the fix that would
210
+ make it checkable — is a separate decision, because it would stop `archive`
211
+ being a command that works offline.
212
+
213
+ ### Fixed
214
+
215
+ - **The packaging test could not run on Windows, and said so by reporting eleven
216
+ skipped tests.** `pnpm test:consumer` is the only thing that exercises the
217
+ published surface — the `files` allowlist, the `exports` map, the `bin` launcher,
218
+ peer resolution from a foreign `node_modules` — and it is what stops another
219
+ 0.1.1-style "green in-repo, broken on install" release. On Windows it had never
220
+ run at all. Three separate defects, each of which had to be fixed to see the
221
+ next one:
222
+ - *`npm` cannot be spawned by that name.* The file is `npm.cmd`, so `npm` is
223
+ ENOENT — and `npm.cmd` is EINVAL, because Node stopped spawning `.cmd` directly
224
+ when it closed the argument-injection hole in 22.x. The same applies to the
225
+ `.bin/attest` shim the install writes, so both the package manager and the CLI
226
+ under test were unspawnable. Both now go through one `spawn` helper that adds a
227
+ shell on win32 only, so POSIX — the platform CI gates on — spawns exactly as it
228
+ did before, and quotes its arguments where the shell is used.
229
+ - *The tarball path was substituted raw into JSON.* A Windows path's backslashes
230
+ are escape characters there, so `npm install` refused the generated manifest
231
+ with EJSONPARSE before reading a single dependency. This is the "path crossing
232
+ a boundary" shape `CLAUDE.md` already records, with JSON as the boundary rather
233
+ than a glob — so the fix is the one that rule already prescribes: the spec now
234
+ goes through `relativePath`, which answers both halves at once, POSIX
235
+ separators and a path short enough to have none. Spelling the `../` by hand
236
+ would have worked and was the first attempt, but it encodes the staging layout
237
+ as a constant: move the consumer project one level deeper and the literal is
238
+ wrong *while still being valid JSON and a valid specifier*, so the next failure
239
+ would arrive as a mystery ENOENT somewhere else.
240
+ - *The `bin` launcher's absence was untestable.* A missing shim surfaced only as
241
+ a spawn error from the helper, which reads as a broken test rather than a
242
+ broken `bin` field, so it now has an assertion of its own.
243
+ **The failure shape is the reason this survived four releases.** The throw was in
244
+ `beforeAll`, so Vitest reported the file's tests as *skipped* — the run said
245
+ nothing was wrong while executing none of it, and CI is Linux, so the pipeline
246
+ was never going to disagree. A third defect shape is now recorded in `CLAUDE.md`
247
+ beside the two it already carried.
248
+ *What is verified where:* all eleven tests pass on Windows, running the real
249
+ `npm` and the real `.bin/attest.cmd`. The POSIX branch is unchanged by
250
+ construction — no shell, same command name, arguments passed through untouched —
251
+ and its one new behaviour is the relative `file:` spec; that half is left to CI,
252
+ which runs this job on Linux on every push.
253
+
254
+ - **The crash path printed the checked project's text to the terminal with
255
+ nothing removed.** 0.4.0 closed terminal injection by putting every diagnostic
256
+ through a sanitiser, and left the two `catch` blocks in the CLI printing
257
+ `err.stack` raw beside it. That is not a lesser path: under `verify`,
258
+ `archive` or `--eval` the thing that throws is a module out of the repository
259
+ under test, so the message on that stack is that repository's text — and
260
+ throwing is the cheapest thing a hostile registry can do, cheaper than getting
261
+ a scenario name into a report. On a fork merge request the reader is not the
262
+ author, and the payload is the same one as before: erase the lines above,
263
+ repaint a red verdict green, retitle the window.
264
+ **Both `catch` blocks now go through one formatter**, which is `block` — the
265
+ same function the diagnostics use, so a stack's frames indent by four more
266
+ spaces and no line of an injected message can occupy the column a real `ERROR`
267
+ header sits in. One function rather than a copy each, because a copy each is
268
+ how the first one came to be missed; ATX-37 is restated over everything the
269
+ CLI prints rather than over "the report", since the narrow wording is what the
270
+ narrow implementation matched. `--json` is untouched and was never exposed —
271
+ serialising escapes the same characters, which is exactly why this had to be
272
+ fixed on the side people read.
273
+ **And the loader, which prints before Attest ever sees the error.** Driving
274
+ the built CLI at a registry that throws escape sequences is what turned this
275
+ up, after the `catch` blocks were already fixed: `logLevel: 'error'` quiets
276
+ Vite's chatter and not its errors, and when a `*.reqs.ts` throws, Vite writes
277
+ `Error when evaluating SSR module …: <the message>` to stderr on its own
278
+ account. So the `registry-invalid` diagnostic built from that very exception
279
+ was being sanitised while the raw bytes had already gone past it — a forged
280
+ `ERROR` header at column 0 and a `\r` to erase the line above it, from a file
281
+ the reviewer only ran. The loader now passes a `customLogger` that puts every
282
+ message through the same function. The whole message is treated as untrusted,
283
+ which costs Vite its own colour on that path: by the time it arrives the
284
+ project's text has been interpolated into it, so there is no half left to
285
+ trust.
286
+ *Where the sanitiser lives changed with it.* `block` / `inline` / `control`
287
+ moved from `cli/report.ts` to `core/terminal.ts`, because the loader is below
288
+ the CLI and cannot import from it — and a second copy of this decision is
289
+ precisely what the crash path had been.
290
+
291
+ - **A failed write left a scratch file in a version-controlled directory, and
292
+ named it something another process could take first.** `writeAtomic` is what
293
+ keeps a rendering, the agent skill and a change's first-run record from ever
294
+ being committed half-written, and the temporary file it renames from was doing
295
+ damage on both of the paths it could fail on. Nothing removed it when the
296
+ rename failed, so `.attest.<pid>.tmp` stayed next to the file it failed to
297
+ become — either committed, or a dirty working tree in `archive`, the one
298
+ command that takes care not to produce one. And the pid it was named for
299
+ separates two *runs*, not two *writes*: `init` writing several targets is the
300
+ second shape, and it was safe only by happening to be sequential, which makes
301
+ the safety a property of that loop rather than of the write.
302
+ **The scratch file now takes a random name, is created with `wx`, and is
303
+ removed when anything between the write and the rename throws.** `wx` is
304
+ `O_CREAT | O_EXCL`, so an existing path at that name — a symlink included — is
305
+ refused rather than followed; with an unpredictable name nothing can aim at
306
+ the path in the first place, and `wx` is what makes a guess useless rather
307
+ than merely unlikely. No `mode` is set: the temporary file *becomes* the
308
+ destination, so tightening it would quietly change the permissions of a
309
+ document the user commits and serves. (ATX-43)
310
+ *What ATX-43 does not state.* The exclusive creation is in the code and not in
311
+ the requirement, because an unpredictable name means no scenario can
312
+ pre-create the path to observe it — and a requirement whose scenario cannot
313
+ fail is worse than the comment that records it (design §11). The two
314
+ properties that *are* observable, and are stated, are that no scratch file
315
+ survives either outcome and that two writes into one directory cannot collide.
316
+
317
+ *Behaviour change.* Neither entry changes an exit code, a `--json` field or a
318
+ diagnostic. The visible differences are that a stack printed on stderr is
319
+ indented under its first line, that a `*.reqs.ts` which throws under `--eval`
320
+ now has Vite's report of it printed without colour, and that a temporary file
321
+ is named `.attest.<uuid>.tmp` rather than `.attest.<pid>.tmp` — anything
322
+ matching the old name in a `.gitignore` should become `.attest.*.tmp`, though
323
+ after this there is nothing left for it to match.
324
+
325
+ - **A delta could bring a requirement into the registry under an id the registry
326
+ itself would refuse, and one spelling of that id was not a key at all.** A
327
+ delta introduces an id in two places — an ADDED key, and the target of a
328
+ RENAME — and `applyDelta` checked neither: it validated the requirement a
329
+ delta adds while never looking at the key it files it under, and it called
330
+ `map.set(to, …)` without looking at `to`. So `^[A-Z]+-\d+$` — the grammar
331
+ `defineRequirements` holds every registry key to — did not apply on the one
332
+ other path that creates them. The mild form is an inconsistency: a change
333
+ could archive under `auth-7`, an id nobody could then write into the
334
+ `*.reqs.ts` the change is supposed to merge into, so the gate passed on an end
335
+ state the intent layer cannot represent.
336
+ The form that made it a defect is `__proto__`. Added ids do not stop at the
337
+ registry — `addedIds` carries them into the first-run record — and there
338
+ `record[id] ??= {}` never fired for that name, because reading it yields the
339
+ shared prototype rather than `undefined`. The outcome was then written onto
340
+ `Object.prototype`: one spec file in one change, and every object in the
341
+ process carries the key.
342
+ **Every id a delta introduces now goes through the registry's own schema
343
+ first, in one function both sites call.** ADDED reuses `add-invalid`, because
344
+ the reader's situation and the file to go and edit are unchanged and a second
345
+ code would ask them to learn a distinction that alters nothing they do. RENAME
346
+ gets a new one, `rename-target-invalid`, because the existing pair are both
347
+ about *existence* — a target that is taken, a source that is gone — and
348
+ neither can say the id is unusable; it is reported ahead of the source so a
349
+ delta with both ends wrong names the half the author has to retype. (ATX-41)
350
+ *This repository's own example was one of the offenders.* `renamed: [{ from:
351
+ 'AUTH-2', to: 'AUTH-2-login' }]` stood in both design documents, in the
352
+ workflow document `attest init` writes into every adopter's repo, and in these
353
+ tests — so the shape being refused is the shape we had been teaching. All of
354
+ them now read `to: 'SESSION-1'`, which is the better example anyway: it shows
355
+ why a rename happens at all (re-homing an id under its own prefix, per
356
+ `duplicate-prefix`) rather than decorating the old one.
357
+
358
+ - **The archive gate could be satisfied by evidence that was never recorded.**
359
+ `changes/<name>/first-run.json` is read into a plain object, and `JSON.parse`
360
+ makes `__proto__` an ordinary own property — so a record carrying that key
361
+ re-pointed the *record's* prototype, and `hasRecordedRed` then answered `true`
362
+ for a requirement the file says nothing about. The file serialises back as
363
+ `{}`, so the never-red obligation — the one mechanism whose entire purpose is
364
+ to hold a fact about history — was met by a file containing no history, and
365
+ the diff a reviewer reads showed an empty record. Reaching it needed nothing
366
+ but a text editor.
367
+ **The record is now built and read as a map with no prototype**, at both
368
+ levels, and `recordedOutcome` looks up own properties only rather than
369
+ `record[reqId]?.[scenario]` — so the guarantee does not depend on who
370
+ constructed the object the gate was handed. Prototype-free rather than a check
371
+ for the handful of names that misbehave: a blocklist is a second answer to
372
+ "is this a data key", and having no prototype is the first one. This also
373
+ stops the answer from resting on how the schema library happens to treat
374
+ `__proto__`, which today is what was discarding it before the gate saw it.
375
+ (ATX-42)
376
+
377
+ *Behaviour change, and who it turns red.* The first entry rejects input that
378
+ used to pass, on both halves: a delta that ADDs an id outside `^[A-Z]+-\d+$`
379
+ now fails `archive` with `add-invalid`, and one whose rename target is outside
380
+ it fails with `rename-target-invalid`, where both previously merged. The
381
+ second half is the one likelier to bite, because the pattern it refuses is the
382
+ one our own documentation showed. Both are real CI breaks for anyone who has
383
+ such a delta in flight, and both are deliberate — the alternative is archiving
384
+ a state that cannot be written back into a registry. The fix is to retype the
385
+ id in `requirements.delta.ts`; nothing else in the change moves, and a
386
+ requirement already merged under a bad id is unaffected, since this checks
387
+ deltas rather than registries. The second entry loosens nothing and tightens
388
+ nothing a well-formed record could notice: a record whose keys are real ids
389
+ reads and serialises exactly as before.
390
+ `rename-target-invalid` is a new `code` value and therefore additive — no
391
+ `SCHEMA_VERSION` bump, and a consumer branching on `issues[].code` sees a code
392
+ it has not heard of exactly as it would any other new diagnostic.
393
+
394
+ - **The package shipped 54 source maps and none of the sources they point at.**
395
+ `sourceMap` and `declarationMap` have both been on since the first build, and
396
+ every map tsc emits names its origin as `../../src/*.ts` — a path outside
397
+ everything the `files` allowlist carried, which was `dist`, `bin` and this
398
+ document. So each map in an installed copy resolved to nothing: a debugger
399
+ that will not step into the engine, and a *Go to Definition* that stops at the
400
+ `.d.ts` instead of the declaration behind it. Nothing errored, which is how it
401
+ survived four minors — every tool that reads a map ignores an unresolvable one
402
+ rather than failing on it, so this was a feature quietly not working rather
403
+ than a thing anyone could notice breaking.
404
+ **The maps are now excluded from the package rather than repaired.** `files`
405
+ grows a negation (`!dist/**/*.map`); they stay in `dist/` for this repo's own
406
+ use, where `src/` is present and they resolve, and the tarball drops to
407
+ 119.7 kB across 59 files — smaller than the 147 kB that shipped broken.
408
+ *Why not the other two shapes.* Adding `src` to `files` also works and was
409
+ the first fix written: it makes both maps resolve, at 195 kB and 148 files, to
410
+ serve stepping into the engine from an adopter's `node_modules` — which
411
+ nothing has asked for, and which anyone auditing the reader would do from the
412
+ repository instead. This file has rejected `init --check`, `divergent-param`
413
+ and countable obligations for costing more than a demonstrated need, and that
414
+ standard does not bend for a packaging decision. Turning the two compiler
415
+ options off is the cleanest published output, since it leaves no dangling
416
+ `sourceMappingURL` comment, but it also removes the maps from `dist/` here,
417
+ where `verify self` runs through the built CLI and they are the only thing
418
+ that maps a crash back to a line of source. (`inlineSources` is not a third
419
+ option: it embeds text into the `.js.map` and does nothing for the
420
+ `.d.ts.map`.)
421
+ *What is gated, and where.* `tests/consumer.spec.ts` asserts the installed
422
+ tree carries no `*.map` and no `.ts`. The pairing is the thing that can come
423
+ apart silently — drop the negation, and the package is back to shipping maps
424
+ that resolve to nothing — and the installed tree is the only place the
425
+ question exists, since in-repo `src/` is always there. Reversing the decision
426
+ means inverting that assertion, not deleting it, which the test says in
427
+ place.
428
+
429
+ - **`build` never removed `dist/`, so a module deleted from `src/` kept being
430
+ emitted.** `tsc` overwrites its outputs and deletes nothing, so a renamed or
431
+ removed module leaves its last emit behind indefinitely. Found by the check
432
+ above rather than by looking: `dist/cli/version.js` and `dist/core/guide.js`
433
+ were still present, both orphaned by `[0.4.1]`, one of them by the
434
+ `packageVersion()` move that release records.
435
+ **What it was not is a correctness hazard, and the distinction is worth
436
+ keeping.** `dist/` mirrors the import graph of `src/`, and a dangling import
437
+ in `src/` fails `tsc`, so an orphan is unreachable by construction — dead
438
+ weight rather than something `bin/attest.js` could load. No published tarball
439
+ carried one either: `dist/` is gitignored and the publish job builds from a
440
+ fresh checkout. What is left is a working tree where `dist/` is not the output
441
+ of any single build, which is worth one line to remove given that every
442
+ `attest …` command in this repo runs from it, and `CLAUDE.md` already has to
443
+ warn that those commands reflect the last build rather than `src/`.
444
+ `build` now runs a `clean` script first. `tsc -b --clean` was tried and does
445
+ not do this job — it deletes the outputs it can derive from the current
446
+ sources, which is every file except the orphan. The remaining shape without a
447
+ new dependency is a `node -e` one-liner; `rimraf` would read better in the
448
+ manifest and pulls a transitive tree in exchange, which is the trade the
449
+ `audit` job and the `brace-expansion` override exist to keep an eye on.
450
+
451
+ ## [0.4.1] - 2026-07-29
452
+
453
+ Every diagnostic now links to a section that explains it, and the README is a
454
+ README again — the reference it had grown into has a page of its own, in both
455
+ languages, saying which version each part of it arrived in. No behaviour a CI
456
+ step branches on changed; the human report gains one line per issue.
457
+
458
+ ### Added
459
+
460
+ - **A troubleshooting document, with one section per issue code — and every
461
+ diagnostic links to its own.** A diagnostic names a rule and states a fix in
462
+ one line, which is the right size for a reader who already knows the rule and
463
+ the wrong size for the one who does not — and the second reader is the one
464
+ adopting Attest, who meets six codes at once on their first run. The knowledge
465
+ was not missing so much as filed where nobody looks during a failure: the
466
+ cross-platform traps (a backslash is an escape in a glob; Vitest's 5-second
467
+ default against a scenario that starts a subprocess) lived in `CLAUDE.md`,
468
+ which ships with no release, and the rationale lived in a 450-line README
469
+ nobody greps mid-failure. Both languages, as always
470
+ (`docs/{en,zh}/troubleshooting.md`).
471
+
472
+ What makes this cheap to keep true is that `code` was already the
473
+ machine-readable name of a rule, so the section heading is the code verbatim
474
+ and the link is *derived*: there is no second string to keep in step, and so
475
+ no way to ship a diagnostic pointing at the wrong section. The remaining
476
+ failure mode — a section that does not exist — is closed from the other end:
477
+ `Issue.code` is narrowed from `string` to the roster in `core/docs.ts`, which
478
+ the suite asserts against both documents. An undocumented diagnostic is now a
479
+ compile error. (That narrowing found two families of codes a regex over the
480
+ source had missed, because they are built through a helper rather than written
481
+ at the issue site.) The two readers that restate a *subset* of the roster —
482
+ `StaticReadCode`, `DeltaReadCode` — are now asserted to be subsets of it in
483
+ `tests/issue-code-types.spec-d.ts`. That was already caught, but downstream
484
+ and by accident: the failure surfaced where the code is assigned into an
485
+ `Issue`, several files from the two lines that disagree.
486
+
487
+ Links are pinned to `v<version>` — the tag of the build that printed the
488
+ diagnostic — not to `main`. The two failure modes are not the same size. An
489
+ unresolved tag exists only between a release commit and its push, which is a
490
+ version nobody has installed, so no build in anyone's `node_modules` prints
491
+ those links. An unpinned link is dereferenced by every installed copy forever,
492
+ and the first breaking change to a code turns years of shipped builds into
493
+ links that miss — or worse, land on a section confidently describing a rule
494
+ that has since been redefined. (`main` remains the fallback for the one case
495
+ where pinning is impossible rather than imprecise: an unreadable manifest.)
496
+
497
+ - **`docsUrl` on every issue in `--json`.** Additive, so no `schemaVersion`
498
+ bump. It is attached in the one envelope every report is built through —
499
+ including the `internal-error` crash path — rather than at the ~26 sites that
500
+ construct an `Issue`, for the same reason `hasError` is one function. It is
501
+ for the human reading a CI log; a consumer should still branch on `code`,
502
+ which is the more precise form of the same information.
503
+
504
+ ### Changed
505
+
506
+ - **Every issue in the human report gains a line: `→ <docs url>`, dim, under the
507
+ message.** The one behaviour change an existing invocation can notice. No exit
508
+ code moves, no verdict moves, no input that passed now fails, and `--json` is
509
+ untouched by it — but a snapshot test over `attest`'s stdout will need
510
+ re-recording. That report is prose for humans by the same rule that makes
511
+ `Issue.message` prose (see *Versioning* above); the machine surface is
512
+ `--json`, which is where a CI step should have been looking.
513
+
514
+ - **`Issue.code` is typed `IssueCode`, not `string`.** Breaking only for code
515
+ that *constructs* an `Issue` — reading one, which is what `--json` consumers
516
+ do, gets strictly more type information than before. Nothing in the CLI
517
+ surface, the report shape or the exit codes changes.
518
+
519
+ `SCHEMA_VERSION` stays at 1 for this too, not only for `docsUrl`: the emitted
520
+ values are the same strings they always were, so nothing on the wire changed
521
+ and no consumer can tell the difference. The narrowing is a compile-time
522
+ constraint on this repository, not a report-shape change.
523
+
524
+ - **The README is a README again; the reference moved to
525
+ `docs/{en,zh}/cli-reference.md`.** It had become four documents wearing one
526
+ hat — a pitch, a tutorial, a CLI reference and a running design argument — at
527
+ 470 lines, which is long past the point where a newcomer reads it and well
528
+ past where anyone looks up a flag in it. It is now 199: the pitch, a
529
+ five-minute start, and a table of where everything else lives. Nothing was
530
+ deleted. Per-command behaviour, `--eval`, `--vitest-config`, the run scope and
531
+ the whole `--json` shape are in the new reference; the rationale for the
532
+ diagnostics that were argued through in the README (`registry-not-static`,
533
+ `duplicate-prefix`, `empty-spec`, `stale-spec-doc`) already had a better home
534
+ in the troubleshooting document, so the reference states the rule and links to
535
+ it rather than restating the argument a third time.
536
+
537
+ Each section of the reference that has changed since it first shipped now
538
+ carries a collapsed **History** table. Attest is 0.x and moves fast enough
539
+ that a page describes the latest build to a reader who installed an earlier
540
+ one, with nothing to tell them which half applies — `--vitest-config` has
541
+ existed since 0.1.5, the run has been scoped to attesting spec files only
542
+ since 0.2.0, and the registry has had to be a literal only since 0.2.0. The
543
+ rows were written from this file. A gate checks that every version they name
544
+ is a heading in it, so a row cannot cite a version that was never cut; it
545
+ cannot check that a row is *true*, which still needs a reader with the
546
+ changelog open. `Unreleased` counts as a heading while one exists — and stops
547
+ counting when a release removes it, which is what makes renumbering those rows
548
+ part of cutting the release rather than something to remember.
549
+
550
+ The two documentation gates widened with it: they scanned `README.md` alone,
551
+ and the JSON sample they exist to catch had just moved out from under them.
552
+ They now enumerate `README.md` plus every `*.md` in both language directories,
553
+ so a page added later is covered by default rather than by remembering.
554
+
555
+ - **`packageVersion()` moved from `src/cli/version.ts` to `src/core/version.ts`.**
556
+ It answers "which build is this", which `core/docs.ts` now has to ask in order
557
+ to pin a link — not a CLI concern. Neither path is in the `exports` map, so
558
+ nothing installable can have been importing either.
559
+
16
560
  ## [0.4.0] - 2026-07-28
17
561
 
18
562
  Reading a registry no longer opens a port or lets the repository under test write
@@ -1319,6 +1863,144 @@ Initial release.
1319
1863
  (MIT), whose four-stage engine and diff-first change model Attest's
1320
1864
  architecture is adapted from (re-implemented from scratch, no source copied).
1321
1865
 
1866
+ ## Planned
1867
+
1868
+ Decisions **to** build something, not yet built. The mirror of `Considered and
1869
+ rejected` at the foot of this file, and the three sections from here down live
1870
+ together for the same two reasons: nothing in them has shipped, so nothing in
1871
+ them belongs to a release, and a block pinned above the version history sinks
1872
+ one release deeper every time one is cut. They run by decreasing commitment —
1873
+ decided yes, undecided, decided no. An entry leaves this one by being
1874
+ implemented — at which point it is rewritten as an `Added` bullet under its
1875
+ version — or by being rejected, at which point it moves down with what killed
1876
+ it. Neither move is a deletion.
1877
+
1878
+ This is not the issue tracker and does not replace it. `[0.1.6]` records that
1879
+ feedback reports are triaged into issues or `Unreleased` and deliberately not
1880
+ kept in this tree; that rule stands, and holds for anything with a reporter, a
1881
+ repro, or a state to track. What is kept here is the narrower thing this file
1882
+ already keeps one section of: a **decision with an argument attached**, where
1883
+ losing the argument would cost more than losing the task. The first and last
1884
+ below came out of reading another repository against this one — `fast-check` and
1885
+ `msw` respectively. The middle one came out of building something queued here,
1886
+ which is the other way an entry arrives: a gate that finds a defect on its first
1887
+ run files the defect.
1888
+
1889
+ - **A structural parity gate over `docs/en/**` and `docs/zh/**`.** The mirror
1890
+ rule — one Chinese page per English page, matching down to the comments inside
1891
+ the code samples, both carried by the same commit — is enforced today by a
1892
+ paragraph in `CLAUDE.md` and by whoever remembers to read it. The home is
1893
+ `tests/docs-consistency.spec.ts`, which already enumerates both directories
1894
+ and already says in its own header why it checks only the two couplings tight
1895
+ enough to assert cheaply; this is a third. What is comparable without
1896
+ comparing prose: the heading tree (level, order, count), the count of fenced
1897
+ code blocks, and — for `troubleshooting.md` — the set of `##` issue-code
1898
+ headings, which is machine-derived on both sides and so must be equal
1899
+ exactly. Translation is not a diff and nothing here should pretend otherwise,
1900
+ which is also the limit: a page whose structure held while its meaning went
1901
+ stale passes. Same shape as the limit the History gate already carries — it
1902
+ can check that a row cites a real version, not that the row is true.
1903
+
1904
+ - **A diagnosable refusal when the resolved compiler has no AST API.**
1905
+ Discovered by building the `typescript` matrix (see `[0.4.2]`), which is
1906
+ what a matrix is for. `typescript@7` is the native port: its npm package's main
1907
+ export is `{ version, versionMajorMinor }`, and the AST moved behind
1908
+ `typescript/unstable/ast*` — so `ts.createSourceFile` and every `ts.isX` guard
1909
+ the two readers call is `undefined`. Measured: `attest check` on it dies with
1910
+ `TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
1911
+ stack, on the crash path this release just finished sanitising. No issue code,
1912
+ no fix hint, and nothing saying which compiler it wanted.
1913
+ **The range excludes 7, and that is not the same as being safe from it.** A
1914
+ caret is a request, not a constraint the adopter cannot override: `pnpm.overrides`
1915
+ and Yarn `resolutions` both pin a transitive dependency across the whole tree,
1916
+ and a repo standardising on one compiler is exactly the repo that uses them —
1917
+ this one has an `overrides` block of its own. So the reachable case is a
1918
+ deliberate, reasonable act by an adopter, answered with a stack trace.
1919
+ What is planned is a check of the API surface where the readers are entered,
1920
+ reported as an ERROR naming the resolved version and the supported range. Cheap,
1921
+ and it converts the one dependency failure this tool cannot survive from a crash
1922
+ into a diagnosis. *Deliberately not folded into the matrix commit*: a new ERROR
1923
+ is a behaviour change, it needs an issue code, a troubleshooting section in both
1924
+ languages and an `ATX-n` with a scenario, and none of that belongs in a commit
1925
+ whose whole claim is that nothing about behaviour moved.
1926
+ *What it cannot do:* make Attest work on TypeScript 7. That is a migration to
1927
+ `typescript/unstable/ast`, against an API whose own name says not to depend on
1928
+ it yet — a separate decision, with the honest answer for now being that the
1929
+ supported range has a ceiling and says so.
1930
+
1931
+ - **Fixtures that are also the code samples the documentation shows.**
1932
+ `tests/docs-consistency.spec.ts` opens by naming this exact hole: every `*.md`
1933
+ outside the generated `SPEC.md` is hand-written prose that no mechanism binds
1934
+ to the code — "the framework's own blind spot (design §11)" — and it asserts
1935
+ only the two couplings cheap enough to assert. This is a fourth, after the
1936
+ `docs/en` ↔ `docs/zh` parity gate queued above, and it is the one that reaches
1937
+ meaning rather than structure: a fenced block in `cli-reference.md` or
1938
+ `troubleshooting.md` showing a registry the engine would now reject is
1939
+ undetectable today. For a tool whose entire claim is reporting drift between
1940
+ intent and what the tests execute, its own documentation drifting from what the
1941
+ engine does is the failure that costs the most and is currently reported by
1942
+ nobody.
1943
+ **The shape that makes it cheap is a naming split, not a new mechanism.** `msw`
1944
+ requires every integration test to be two files: a `*.mocks.ts` holding a usage
1945
+ example with *nothing test-specific in it*, and a `*.test.ts` that loads the
1946
+ example and asserts against it — so the example is simultaneously the fixture
1947
+ and a snippet publishable to users unmodified. Here that means promoting a
1948
+ subset of `fixtures/` to that status and having the English page quote the file
1949
+ rather than restate it, with the assertion being byte-equality between the
1950
+ fenced block and the fixture.
1951
+ **Not every fixture is a candidate, and the exclusions are the existing ones.**
1952
+ `fixtures/runner-red` and `fixtures/e2e-bad` are inputs to child runs that
1953
+ assert failure, and `fixtures/nested-spec/` exists to put a separator in a path
1954
+ — none is written to be read as an example, and dressing them up as one would
1955
+ cost the thing they are for.
1956
+ *The cost is the mirror rule, and it caps what can be claimed.* `docs/zh/**`
1957
+ matches `docs/en/**` down to the comments inside its code samples, so
1958
+ byte-equality can hold on one side only; the Chinese block stays translated
1959
+ prose around the same code, which is the parity gate's problem and is why these
1960
+ two entries should land together or in that order. A page whose sample is
1961
+ byte-equal to a living fixture while the paragraph above it describes the old
1962
+ behaviour still passes — the same limit already written down for the History
1963
+ rows, and the same answer: this binds what is machine-derivable and leaves the
1964
+ rest to a reader.
1965
+
1966
+ ## Under consideration
1967
+
1968
+ Candidates with **no decision yet**, between the two sections either side of it
1969
+ and at the foot of the file for the reason both of those are: nothing here has
1970
+ shipped, so nothing here belongs to a release, and an open question filed under
1971
+ a version reads as something that version settled. An entry leaves by being
1972
+ decided — up into `Planned` and then into an `Added` bullet under its version,
1973
+ or down into `Considered and rejected` with what killed it. Neither move is a
1974
+ deletion. What each entry has to carry is the evidence currently blocking the
1975
+ decision, because that is what a re-proposal a year from now would otherwise
1976
+ have to gather again.
1977
+
1978
+ - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
1979
+ project with no concurrency limit: `findFiles` recurses through
1980
+ `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
1981
+ once, and `parseSpecs` does `Promise.all(files.map(readFile))`, which reads
1982
+ every spec file into memory before parsing any of them. Neither is bounded by
1983
+ anything, and `check` is the command this repository tells people to run first
1984
+ in a pipeline on an untrusted fork MR — so the input size is not ours to
1985
+ assume.
1986
+ *The evidence, and why it is not enough to act on.* Measured on a synthetic
1987
+ tree of 6000 spec files: the fan-out is real and unbounded — peak in-flight
1988
+ `readFile` calls came back as exactly 6000 — and `attest check` finished in
1989
+ 1.99 s without erroring. It could not be made to fail. Windows is why: Node
1990
+ uses Win32 handles there rather than POSIX descriptors, so the `ulimit -n` of
1991
+ 3200 that the shell reports does not govern the process, and the one platform
1992
+ where descriptor exhaustion is plausible is the one CI runs and this
1993
+ measurement could not reach (`CLAUDE.md`'s two-platform rule, pointing the
1994
+ unusual way round). What *is* portable is the memory: `parseSpecs` holds every
1995
+ source at once, which at a realistic 8 KiB per spec file is ~47 MiB at this
1996
+ count — noticeable, not fatal.
1997
+ So the fix is known and cheap — a concurrency limit of ~32 at both sites, and
1998
+ parsing each source as it arrives rather than after all of them, which makes
1999
+ the peak constant — and it is held back by this file's own standard: no report,
2000
+ and no demonstrated failure on any platform. Recorded here so that the day
2001
+ someone hits an `EMFILE` from `attest check`, the diagnosis is already written
2002
+ down rather than rediscovered.
2003
+
1322
2004
  ## Considered and rejected
1323
2005
 
1324
2006
  Decisions **not** to build something, kept where they can be found before the
@@ -1329,231 +2011,269 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
1329
2011
  sinking one version deeper each time a release was cut above it, and a rejection
1330
2012
  filed under a version reads as belonging to it.
1331
2013
 
1332
- - **A requirement's id prefix must match its registry file's name.** The other
1333
- half of the rule that shipped as `duplicate-prefix` (see `[Unreleased]`), and
1334
- the half that does not pay for itself. What killed it is that the corpus
1335
- already conforms: every fixture registry matches (`auth.reqs.ts` `AUTH-`,
1336
- `core.reqs.ts` `CORE-`), so the rule would detect nothing anywhere it was
1337
- measured except one file `self/requirements/attest.reqs.ts`, which holds
1338
- `ATX-*`. Its first act would therefore be to rename this repo's own registry
1339
- and every `self/*.spec.ts` that imports it, and its first act in an adopter's
1340
- repository would be the same thing to theirs. That is rejecting input valid
1341
- today, a minor bump under this file's Versioning rule, in exchange for making
1342
- the prefix *guessable from the path* — which matters only to a reader already
1343
- looking at the file, and who could have read the ids in it. The collision rule
1344
- delivers what the gap is actually about, which is two owners of one space, and
1345
- it delivers it without a rename because it fires on a relationship between
1346
- files rather than on a spelling. Reconsider only with evidence that a reader or
1347
- a tool needs the prefix before opening the file a naming *convention* stays
1348
- the right shape for this, and the convention is what `init` and the docs
1349
- already teach.
1350
- What is not a reason to revisit it: the objection that stalled the collision
1351
- rule while both were queued together that it guards a convention nothing had
1352
- yet violated was answered by shipping it, since a rule that costs no
1353
- migration and no concept is worth having before the first collision rather
1354
- than after it. That argument does not transfer here, because this rule's cost
1355
- is a migration by construction.
1356
-
1357
- - **Two gaps in the intent layer: nothing resists a bloated requirement, and
1358
- nothing resists a duplicated one.** The gaps themselves are described in design
1359
- §11, which is where a permanent property of the design belongs. What is kept
1360
- here is the list of mechanisms tried and what killed each, so the same three
1361
- are not proposed again without new evidence. All three were scored against
1362
- every registry this repo ships *before* any was written into the enginethe
1363
- order `divergent-param` was originally designed in the wrong way round. The
1364
- measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
1365
- when a requirement is added without a hand-labelled obligation count, because
1366
- the reach figure once quoted here went stale exactly that way. The guard makes
1367
- the *suite* re-count; it cannot make this file re-read, so every figure below
1368
- is a quotation of that snapshot and is only ever as fresh as the last edit
1369
- here read the snapshot when the number is what matters.
1370
- *`compound-requirement` (WARNING) rejected.* More than one RFC-2119 keyword
1371
- in one `statement` is objectively more than one obligation. Measured (figures
1372
- re-read from the suite, which is the only place they are not prose): of the 8
1373
- requirements in the corpus carrying more than one, it flags 3 (`ATX-10`,
1374
- `ATX-21`, `ATX-26`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17`
1375
- every one that packs clauses under a single keyword, which is the form the
1376
- coverage incentive actually rewards. A wider variant (keyword plus a
1377
- coordinating `and`/`while`/`;`) reaches 7 of 8, at 3 wrong hits (`ATX-6`,
1378
- `ATX-22`, `ATX-23`) rather than the 1 recorded when it was first scored — so
1379
- reach is not the deciding argument, and the precision it trades away grows
1380
- with the registry.
1381
- This is: both variants are silenced by deleting the word that triggered them,
1382
- and neither can tell that deletion from a real split. `SHALL do A and MUST do
1383
- B` clears the warning as `SHALL do A and do B` same two obligations, one
1384
- fewer normative keyword. A rule whose cheapest fix degrades the artifact it
1385
- protects does not ship.
1386
- *`divergent-param` (WARNING) rejected.* One param name declared with
1387
- different values in two requirements sharing an id prefix. Measured: it fires
1388
- twice on this repo and both firings are wrong `idleTimeoutMin` (`15` in
1389
- `ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
1390
- (`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
1391
- `rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
1392
- agree. The second arrived on its own when `ATX-18` was added and grew a third
1393
- member on its own again when `ATX-21` was, then `ATX-25`, then `ATX-26`, which
1394
- is the more damning half: a generic param name collides more often as a
1395
- registry grows, so the false-positive rate rises with adoption the opposite
1396
- of what a shippable rule does. `ATX-26` added the sharper version of the same
1397
- point: it names the *same* diagnostic as `ATX-17`, so the two now agree by
1398
- coincidence and `ATX-17` drops out of the set the rule reports — a rule that
1399
- reads agreement as safety falls silent on a pair it had been flagging, for a
1400
- reason that has nothing to do with either requirement.
1401
- **A second param name now collides, and that is what closes the last defence
1402
- of the rule.** `file` names a path Attest writes — the agent skill in `ATX-27`,
1403
- a change's first-run record in `ATX-30` two unrelated constants, a third
1404
- wrong firing. Until it appeared, every false positive had been on the single
1405
- name `code`, which left "pick a less generic param name" open as a repo-side
1406
- workaround and therefore left the rule arguably salvageable. It is not the
1407
- word: two requirements owning unrelated constants of the same *kind* is the
1408
- structure, and any registry large enough to be worth linting has it.
1409
- Still zero true positives, now across a 34-entry corpus (the `corpus` figure in
1410
- the suite's snapshot, which counts a fixture id once per registry file that
1411
- declares it).
1412
- The escape hatch it was blocked on has since been designed
1413
- and does not rescue it: with no config file the only workable shape is an
1414
- additive `independentParams?: string[]` on the requirement (a CLI flag is
1415
- per-invocation for what is a permanent property of two requirements; changing
1416
- the `params` shape rejects every existing registry). Viable, but it is
1417
- permanent schema surface for a rule with no demonstrated true positive, and in
1418
- practice it would be written *after* the warning fires — a one-line silencer.
1419
- *Countable obligations (breaking) rejected.* An `obligations: string[]` per
1420
- requirement, with coverage counted per obligation, removing the lumping
1421
- incentive at the source instead of detecting it afterwards. Rejected because
1422
- the **id is already that unit**: splitting into two ids costs two scenarios and
1423
- buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
1424
- split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
1425
- and the `--json` schema, and it relocates the incentive rather than removing
1426
- itnothing can force the array to be complete.
1427
- *Not considered:* similarity matching between statements (token overlap,
1428
- embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
1429
- real semantic duplication needs judgement and belongs to human review at the
1430
- propose stage.
1431
-
1432
- - **A freshness gate on the file `attest init` writes (`init --check`).**
1433
- `render --check` is the obvious precedent and is the opposite case. A rendering
1434
- is a function of the *user's registry*, so its staleness means the user moved
1435
- intent without regenerating a real signal, with the fix in their hands. The
1436
- skill is a function of the *installed version*, so a gate on it turns red on a
1437
- change the user did not make, and is cleared by a commit containing no
1438
- decision. That is a chore wearing a diagnostic's clothes, and a gate that
1439
- cannot be about anything the reader did is worse than no gate the same
1440
- standard 0.1.6 set when it gave `render` no formatting options at all.
1441
- An earlier draft of `attest init` spent a second command, this gate and a
1442
- second self-requirement on it. What that would have bought is immunity to one
1443
- honest and small exposure: an old file describes an old workflow to an agent
1444
- that trusts it. The backstop is that every mistake it can cause is already a
1445
- diagnostic with a fix hint a registry written the old way is
1446
- `registry-not-static` so the agent corrects itself from the report, and
1447
- `init` is idempotent, which makes re-running it the entire update mechanism.
1448
- Recorded here rather than left in the 0.3.0 entry that shipped `init`, because
1449
- the queue above depends on it: a gate is the first thing proposed each time a
1450
- new agent target is considered, and the answer must not need re-deciding per
1451
- target.
1452
-
1453
- - **A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`.** The cross-tool
1454
- convention Codex, Cursor and Copilot read, and the reason it looks like the
1455
- obvious next target after the skill is that it reaches the most agents per unit
1456
- of work. Rejected on three counts, none of which is about effort.
1457
- **It is the only file `init` would write that Attest does not own.**
1458
- `.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
1459
- exists because the *project* does, and what is in it is prose its maintainer
1460
- wrote and curated. Writing a block into it means an idempotent
1461
- read-modify-write on someone else's document find the markers, preserve
1462
- everything outside them, and then handle the markers being absent, duplicated,
1463
- hand-edited, moved, the file missing, the file being `CLAUDE.md` instead, or
1464
- both existing. That is a merge tool, and each of its failure modes is
1465
- destructive on a file the user cannot regenerate.
1466
- **It would remove the property that makes `init` safe to automate.**
1467
- `skillContent()` takes no argument the content is a pure function of nothing
1468
- at all — so `init` is one atomic write and its idempotence holds *by
1469
- construction*, which is what lets "re-run it after upgrading" be the whole
1470
- update mechanism and be safe in a script. A marked block makes the output a
1471
- function of the file already there, demoting idempotence from a property of the
1472
- design to a behaviour that needs test coverage, on the one path where being
1473
- wrong destroys input.
1474
- **It is a worse carrier for the same text.** An `AGENTS.md` block is
1475
- unconditionally resident in the agent's context, where a skill body is loaded
1476
- when its description matches so every unrelated request in that repo pays for
1477
- a workflow it will not use. It was never a port of the skill; it is a second,
1478
- weaker capability wearing the same words.
1479
- The interaction with the entry above is what settles it: a user-edited block
1480
- cannot be replaced on upgrade without discarding the edit, so this shape needs
1481
- either the freshness gate rejected above or a three-way merge. *What replaces
1482
- it*, for an adopter whose agent reads `AGENTS.md`: one line they add
1483
- themselves, pointing at the skill file. A pointer is cheap enough to be
1484
- resident, it is their edit in their file, and it cannot go stale — the file it
1485
- names is the one `init` keeps current.
1486
-
1487
- - **A Codex target for `attest init`.** Evaluated when `init` grew `--target` and
1488
- shipped for Claude Code, Cursor and Copilot; rejected because the tool offers
1489
- nothing to write, not because of what writing it would cost. Codex's
1490
- project-scoped instruction surface is `AGENTS.md`, which the entry above
1491
- rejects permanently, and its custom prompts live in `~/.codex/prompts` — per
1492
- machine, outside the project root `init` is pointed at, and not a thing a
1493
- project can commit or a CI checkout can carry. That leaves no path that is both
1494
- **Attest's own** and **read by the tool**, so the two rules a target has to
1495
- satisfy are not merely unmet here, they are unmeetable with what exists: any
1496
- Codex-shaped implementation would be either the rejected merge or a file
1497
- nothing reads, and the second is worse, because it looks like coverage.
1498
- Filed here rather than queued as a candidate, since nothing about the effort
1499
- or the evidence is what is holding it — the pointer line above is the whole
1500
- answer for a Codex user, and this reopens only if Codex grows a project-scoped
1501
- path of its own.
1502
-
1503
- - **A committed snapshot of the params, to give `check` detection power over a
1504
- value that moves.** Design §11 records the measurement this answers: editing a
1505
- param (`'main'` `'trunk'`, one entry dropped from a nine-element list) leaves
1506
- `attest check` at `✓ No issues`, because the single source makes a value
1507
- impossible to *diverge*, which is not the claim that it is flagged when it
1508
- *moves*. The candidate was the shape `render --check` already proves a
1509
- `params.lock` beside the registry, or `check --params-check` and the
1510
- mechanism would have worked.
1511
- *What rejected it: the precedent is the objection.* §9 states when a freshness
1512
- gate earns its place, and a rendering earns it because it is a function of the
1513
- *user's registry*, so staleness means the user moved intent without
1514
- regenerating a real signal with the fix in their hands. A params snapshot is
1515
- a function of the registry too, but the population it fires on is different:
1516
- **every legitimate param edit turns it red, and the fix is a commit containing
1517
- no decision.** That is precisely the chore §9 declined to ship for `init`, and
1518
- it is worse here because of frequency intent is rendered rarely, while params
1519
- move whenever a value moves, which is the entire reason they are params. The
1520
- gate would spend its whole life red about correct edits, and a gate that is
1521
- usually wrong about staleness is the failure mode §9 names as worse than no
1522
- gate.
1523
- *What the gap keeps instead.* §6's mechanism 3 already exists to make this
1524
- exact absence visible: `possible-drift` fires when a requirement owns params
1525
- that no covering scenario reads, which is the only condition under which a
1526
- moved param goes unnoticed. The mechanism that guards a param is a scenario
1527
- reading it, not a file recording it. This reopens only on an adoption report
1528
- where a param moved, no scenario caught it, and `possible-drift` was **not**
1529
- already printingbecause if it was printing, what failed was reading the
1530
- report, and a second gate does not fix that.
1531
-
1532
- - **Grouping `render`'s output by prefix, so a reviewer sees contradicting
1533
- requirements together.** The *detection* half of this is already rejected above
1534
- (every automatic candidate either saw almost nothing or was a similarity
1535
- matcher, which is what §0 exists to remove), leaving human review at propose
1536
- (§7) as the answer and the idea was to help that review by putting
1537
- requirements about one subject side by side in the one document a human reads
1538
- end to end.
1539
- *What rejected it: the ordering already ships, and the adjacency it buys is not
1540
- where the gap is.* `compareIds` (`render.ts`) has ordered the document by
1541
- prefix first and number second since `render` shipped, so same-prefix
1542
- requirements are **already** adjacent; the candidate was never an ordering
1543
- change, only a heading over an ordering that already holds. And §11's own
1544
- example of the gap is `AUTH-3` expiring a session against `SESS-7` saying
1545
- sessions never expire a contradiction **across** prefixes, which prefix
1546
- grouping pushes further apart rather than closer. What remains is a reader aid
1547
- for same-prefix contradictions, which are the pairs already adjacent and
1548
- therefore the ones review is least likely to miss, bought at the price of
1549
- changing the bytes of every committed rendering and turning `render --check`
1550
- red across every adopting repo on upgrade for a change the user did not make.
1551
- *The gap itself stays open* and stays where it belongs, in design §11: nothing
1552
- resists a duplicated or contradictory requirement, and human review at propose
1553
- is still the whole answer. What is rejected is grouping as a way to assist it,
1554
- and this reopens only on a contradiction that grouping would have caught.
1555
-
1556
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.0...main
2014
+ ### `typescript` as a peer dependency instead of a bundled one
2015
+
2016
+ The open question the matrix entry was queued to settle (see `[0.4.2]`), and
2017
+ it is worth keeping because the reasoning that made it attractive is sound and
2018
+ still wrong. A runtime `dependencies` on a caret is a guarantee the resolver does
2019
+ not honour a package manager may dedupe it against the adopter's own compiler,
2020
+ so the version that actually parses their registry is not this repo's to choose.
2021
+ A peer makes that explicit instead of pretending otherwise, which is the shape
2022
+ this repository chose for `vite` and `vitest` in `[0.4.0]` for exactly that
2023
+ reason.
2024
+
2025
+ **What killed it is that the API a peer would supply does not exist.**
2026
+ TypeScript's `latest` is 7.x, whose npm package is the native port: the main
2027
+ export is `{ version, versionMajorMinor }` and the compiler API moved behind
2028
+ `typescript/unstable/ast*`. So a peer range has two spellings and both are worse
2029
+ than bundling. Admit 7 and the peer is satisfiable by a package with no
2030
+ `createSourceFile` at all the dependency declaration would state a requirement
2031
+ it cannot check, and the failure lands as a crash inside the reader that decides a
2032
+ security property. Exclude 7 and every adopter on the current TypeScript release
2033
+ gets a peer warning telling them to install an older compiler *explicitly*, in
2034
+ service of a tool that could have carried its own which is the ceiling-hurting-
2035
+ adopters failure `msw` reached the no-upper-bound answer from, arriving here from
2036
+ the opposite direction.
2037
+
2038
+ The asymmetry with `vite`/`vitest` is the whole of it, and it is not about
2039
+ stability: those two are peers because Attest must run *inside the adopter's own
2040
+ runner* a second copy would be a second test run, which is not a thing that
2041
+ works. Nothing about reading an AST needs the adopter's compiler; the reader wants
2042
+ *a* parser for TypeScript syntax, not *theirs*. A bundled one is therefore the
2043
+ only shape that can guarantee the API exists, and the duplicate install on a
2044
+ TypeScript 7 project is a real cost paid knowingly, not an oversightone extra
2045
+ `node_modules` entry, against a tool that otherwise cannot start.
2046
+
2047
+ Reconsider only if the compiler API becomes something a peer range can honestly
2048
+ describe again: `typescript/unstable/ast` reaching stability under a name that is
2049
+ not `unstable`, with a version range across which the AST shape is contractual.
2050
+ Until then the ceiling is the honest statement, and the range is run at both ends
2051
+ rather than asserted. *Not a reason to revisit:* an adopter reporting two copies of
2052
+ `typescript` in their tree. That is the decision working, and the README says so
2053
+ under Prerequisites.
2054
+
2055
+ ### A requirement's id prefix must match its registry file's name
2056
+
2057
+ The other half of the rule that shipped as `duplicate-prefix` (see `[0.4.0]`),
2058
+ and the half that does not pay for itself. What killed it is that the corpus
2059
+ already conforms: every fixture registry matches (`auth.reqs.ts` `AUTH-`,
2060
+ `core.reqs.ts` `CORE-`), so the rule would detect nothing anywhere it was
2061
+ measured except one file `self/requirements/attest.reqs.ts`, which holds
2062
+ `ATX-*`. Its first act would therefore be to rename this repo's own registry and
2063
+ every `self/*.spec.ts` that imports it, and its first act in an adopter's
2064
+ repository would be the same thing to theirs. That is rejecting input valid
2065
+ today, a minor bump under this file's Versioning rule, in exchange for making
2066
+ the prefix *guessable from the path* which matters only to a reader already
2067
+ looking at the file, and who could have read the ids in it. The collision rule
2068
+ delivers what the gap is actually about, which is two owners of one space, and
2069
+ it delivers it without a rename because it fires on a relationship between files
2070
+ rather than on a spelling. Reconsider only with evidence that a reader or a tool
2071
+ needs the prefix before opening the file a naming *convention* stays the right
2072
+ shape for this, and the convention is what `init` and the docs already teach.
2073
+ What is not a reason to revisit it: the objection that stalled the collision
2074
+ rule while both were queued together that it guards a convention nothing had
2075
+ yet violated was answered by shipping it, since a rule that costs no migration
2076
+ and no concept is worth having before the first collision rather than after it.
2077
+ That argument does not transfer here, because this rule's cost is a migration by
2078
+ construction.
2079
+
2080
+ ### Nothing resists a bloated requirement, and nothing resists a duplicated one
2081
+
2082
+ These two gaps in the intent layer are themselves described in design §11, which
2083
+ is where a permanent property of the design belongs. What is kept here is the
2084
+ list of mechanisms tried and what killed each, so the same three are not
2085
+ proposed again without new evidence. All three were scored against every
2086
+ registry this repo ships *before* any was written into the engine the order
2087
+ `divergent-param` was originally designed in the wrong way round. The
2088
+ measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails when
2089
+ a requirement is added without a hand-labelled obligation count, because the
2090
+ reach figure once quoted here went stale exactly that way. The guard makes the
2091
+ *suite* re-count; it cannot make this file re-read, so every figure below is a
2092
+ quotation of that snapshot and is only ever as fresh as the last edit here —
2093
+ read the snapshot when the number is what matters. *`compound-requirement`
2094
+ (WARNING) rejected.* More than one RFC-2119 keyword in one `statement` is
2095
+ objectively more than one obligation. Measured (figures re-read from the suite,
2096
+ which is the only place they are not prose): of the 8 requirements in the corpus
2097
+ carrying more than one, it flags 3 (`ATX-10`, `ATX-21`, `ATX-26`) and misses
2098
+ `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17` every one that packs clauses
2099
+ under a single keyword, which is the form the coverage incentive actually
2100
+ rewards. A wider variant (keyword plus a coordinating `and`/`while`/`;`) reaches
2101
+ 7 of 8, at 3 wrong hits (`ATX-6`, `ATX-22`, `ATX-23`) rather than the 1 recorded
2102
+ when it was first scored — so reach is not the deciding argument, and the
2103
+ precision it trades away grows with the registry. This is: both variants are
2104
+ silenced by deleting the word that triggered them, and neither can tell that
2105
+ deletion from a real split. `SHALL do A and MUST do B` clears the warning as
2106
+ `SHALL do A and do B` — same two obligations, one fewer normative keyword. A
2107
+ rule whose cheapest fix degrades the artifact it protects does not ship.
2108
+ *`divergent-param` (WARNING) rejected.* One param name declared with different
2109
+ values in two requirements sharing an id prefix. Measured: it fires twice on
2110
+ this repo and both firings are wrong `idleTimeoutMin` (`15` in `ATX-3`, `30`
2111
+ in `ATX-10`), unrelated illustrative fixtures, and `code` (`registry-not-static`
2112
+ in `ATX-17`, `empty-spec` in `ATX-18`, `rationale-placeholder` in `ATX-21`),
2113
+ diagnostics that could not possibly agree. The second arrived on its own when
2114
+ `ATX-18` was added and grew a third member on its own again when `ATX-21` was,
2115
+ then `ATX-25`, then `ATX-26`, which is the more damning half: a generic param
2116
+ name collides more often as a registry grows, so the false-positive rate rises
2117
+ with adoptionthe opposite of what a shippable rule does. `ATX-26` added the
2118
+ sharper version of the same point: it names the *same* diagnostic as `ATX-17`,
2119
+ so the two now agree by coincidence and `ATX-17` drops out of the set the rule
2120
+ reports a rule that reads agreement as safety falls silent on a pair it had
2121
+ been flagging, for a reason that has nothing to do with either requirement. **A
2122
+ second param name now collides, and that is what closes the last defence of the
2123
+ rule.** `file` names a path Attest writes the agent skill in `ATX-27`, a
2124
+ change's first-run record in `ATX-30` two unrelated constants, a third wrong
2125
+ firing. Until it appeared, every false positive had been on the single name
2126
+ `code`, which left "pick a less generic param name" open as a repo-side
2127
+ workaround and therefore left the rule arguably salvageable. It is not the word:
2128
+ two requirements owning unrelated constants of the same *kind* is the structure,
2129
+ and any registry large enough to be worth linting has it. Still zero true
2130
+ positives, now across a 34-entry corpus (the `corpus` figure in the suite's
2131
+ snapshot, which counts a fixture id once per registry file that declares it).
2132
+ The escape hatch it was blocked on has since been designed and does not rescue
2133
+ it: with no config file the only workable shape is an additive
2134
+ `independentParams?: string[]` on the requirement (a CLI flag is per-invocation
2135
+ for what is a permanent property of two requirements; changing the `params`
2136
+ shape rejects every existing registry). Viable, but it is permanent schema
2137
+ surface for a rule with no demonstrated true positive, and in practice it would
2138
+ be written *after* the warning fires a one-line silencer. *Countable
2139
+ obligations (breaking) rejected.* An `obligations: string[]` per requirement,
2140
+ with coverage counted per obligation, removing the lumping incentive at the
2141
+ source instead of detecting it afterwards. Rejected because the **id is already
2142
+ that unit**: splitting into two ids costs two scenarios and buys the same
2143
+ incentive with no new concept (`ATX-11`/`ATX-12` are exactly that split). It
2144
+ would touch the parser, coverage, `render`, `SPEC.md`, delta apply and the
2145
+ `--json` schema, and it relocates the incentive rather than removing it —
2146
+ nothing can force the array to be complete. *Not considered:* similarity
2147
+ matching between statements (token overlap, embeddings). That reintroduces the
2148
+ fuzzy comparison design §0 exists to remove; real semantic duplication needs
2149
+ judgement and belongs to human review at the propose stage.
2150
+
2151
+ ### A freshness gate on the file `attest init` writes (`init --check`)
2152
+
2153
+ `render --check` is the obvious precedent and is the opposite case. A rendering
2154
+ is a function of the *user's registry*, so its staleness means the user moved
2155
+ intent without regenerating — a real signal, with the fix in their hands. The
2156
+ skill is a function of the *installed version*, so a gate on it turns red on a
2157
+ change the user did not make, and is cleared by a commit containing no decision.
2158
+ That is a chore wearing a diagnostic's clothes, and a gate that cannot be about
2159
+ anything the reader did is worse than no gate the same standard 0.1.6 set when
2160
+ it gave `render` no formatting options at all. An earlier draft of `attest init`
2161
+ spent a second command, this gate and a second self-requirement on it. What that
2162
+ would have bought is immunity to one honest and small exposure: an old file
2163
+ describes an old workflow to an agent that trusts it. The backstop is that every
2164
+ mistake it can cause is already a diagnostic with a fix hint — a registry
2165
+ written the old way is `registry-not-static` so the agent corrects itself from
2166
+ the report, and `init` is idempotent, which makes re-running it the entire
2167
+ update mechanism. Recorded here rather than left in the 0.3.0 entry that shipped
2168
+ `init`, because the queue above depends on it: a gate is the first thing
2169
+ proposed each time a new agent target is considered, and the answer must not
2170
+ need re-deciding per target.
2171
+
2172
+ ### A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`
2173
+
2174
+ The cross-tool convention Codex, Cursor and Copilot read, and the reason it
2175
+ looks like the obvious next target after the skill is that it reaches the most
2176
+ agents per unit of work. Rejected on three counts, none of which is about
2177
+ effort. **It is the only file `init` would write that Attest does not own.**
2178
+ `.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
2179
+ exists because the *project* does, and what is in it is prose its maintainer
2180
+ wrote and curated. Writing a block into it means an idempotent read-modify-write
2181
+ on someone else's document find the markers, preserve everything outside them,
2182
+ and then handle the markers being absent, duplicated, hand-edited, moved, the
2183
+ file missing, the file being `CLAUDE.md` instead, or both existing. That is a
2184
+ merge tool, and each of its failure modes is destructive on a file the user
2185
+ cannot regenerate. **It would remove the property that makes `init` safe to
2186
+ automate.** `skillContent()` takes no argument the content is a pure function
2187
+ of nothing at all — so `init` is one atomic write and its idempotence holds *by
2188
+ construction*, which is what lets "re-run it after upgrading" be the whole
2189
+ update mechanism and be safe in a script. A marked block makes the output a
2190
+ function of the file already there, demoting idempotence from a property of the
2191
+ design to a behaviour that needs test coverage, on the one path where being
2192
+ wrong destroys input. **It is a worse carrier for the same text.** An
2193
+ `AGENTS.md` block is unconditionally resident in the agent's context, where a
2194
+ skill body is loaded when its description matches so every unrelated request
2195
+ in that repo pays for a workflow it will not use. It was never a port of the
2196
+ skill; it is a second, weaker capability wearing the same words. The interaction
2197
+ with the entry above is what settles it: a user-edited block cannot be replaced
2198
+ on upgrade without discarding the edit, so this shape needs either the freshness
2199
+ gate rejected above or a three-way merge. *What replaces it*, for an adopter
2200
+ whose agent reads `AGENTS.md`: one line they add themselves, pointing at the
2201
+ skill file. A pointer is cheap enough to be resident, it is their edit in their
2202
+ file, and it cannot go stale the file it names is the one `init` keeps
2203
+ current.
2204
+
2205
+ ### A Codex target for `attest init`
2206
+
2207
+ Evaluated when `init` grew `--target` and shipped for Claude Code, Cursor and
2208
+ Copilot; rejected because the tool offers nothing to write, not because of what
2209
+ writing it would cost. Codex's project-scoped instruction surface is
2210
+ `AGENTS.md`, which the entry above rejects permanently, and its custom prompts
2211
+ live in `~/.codex/prompts` per machine, outside the project root `init` is
2212
+ pointed at, and not a thing a project can commit or a CI checkout can carry.
2213
+ That leaves no path that is both **Attest's own** and **read by the tool**, so
2214
+ the two rules a target has to satisfy are not merely unmet here, they are
2215
+ unmeetable with what exists: any Codex-shaped implementation would be either the
2216
+ rejected merge or a file nothing reads, and the second is worse, because it
2217
+ looks like coverage. Filed here rather than queued as a candidate, since nothing
2218
+ about the effort or the evidence is what is holding it the pointer line above
2219
+ is the whole answer for a Codex user, and this reopens only if Codex grows a
2220
+ project-scoped path of its own.
2221
+
2222
+ ### A committed snapshot of the params
2223
+
2224
+ Design §11 records the measurement this answers: editing a param (`'main'` →
2225
+ `'trunk'`, one entry dropped from a nine-element list) leaves `attest check` at
2226
+ `✓ No issues`, because the single source makes a value impossible to *diverge*,
2227
+ which is not the claim that it is flagged when it *moves*. The candidate was the
2228
+ shape `render --check` already proves a `params.lock` beside the registry, or
2229
+ `check --params-check` and the mechanism would have worked. *What rejected it:
2230
+ the precedent is the objection.* §9 states when a freshness gate earns its
2231
+ place, and a rendering earns it because it is a function of the *user's
2232
+ registry*, so staleness means the user moved intent without regenerating a
2233
+ real signal with the fix in their hands. A params snapshot is a function of the
2234
+ registry too, but the population it fires on is different: **every legitimate
2235
+ param edit turns it red, and the fix is a commit containing no decision.** That
2236
+ is precisely the chore §9 declined to ship for `init`, and it is worse here
2237
+ because of frequency — intent is rendered rarely, while params move whenever a
2238
+ value moves, which is the entire reason they are params. The gate would spend
2239
+ its whole life red about correct edits, and a gate that is usually wrong about
2240
+ staleness is the failure mode §9 names as worse than no gate. *What the gap
2241
+ keeps instead.* §6's mechanism 3 already exists to make this exact absence
2242
+ visible: `possible-drift` fires when a requirement owns params that no covering
2243
+ scenario reads, which is the only condition under which a moved param goes
2244
+ unnoticed. The mechanism that guards a param is a scenario reading it, not a
2245
+ file recording it. This reopens only on an adoption report where a param moved,
2246
+ no scenario caught it, and `possible-drift` was **not** already printing —
2247
+ because if it was printing, what failed was reading the report, and a second
2248
+ gate does not fix that.
2249
+
2250
+ ### Grouping `render`'s output by prefix
2251
+
2252
+ The *detection* half of this is already rejected above (every automatic
2253
+ candidate either saw almost nothing or was a similarity matcher, which is what
2254
+ §0 exists to remove), leaving human review at propose (§7) as the answer — and
2255
+ the idea was to help that review by putting requirements about one subject side
2256
+ by side in the one document a human reads end to end. *What rejected it: the
2257
+ ordering already ships, and the adjacency it buys is not where the gap is.*
2258
+ `compareIds` (`render.ts`) has ordered the document by prefix first and number
2259
+ second since `render` shipped, so same-prefix requirements are **already**
2260
+ adjacent; the candidate was never an ordering change, only a heading over an
2261
+ ordering that already holds. And §11's own example of the gap is `AUTH-3`
2262
+ expiring a session against `SESS-7` saying sessions never expire — a
2263
+ contradiction **across** prefixes, which prefix grouping pushes further apart
2264
+ rather than closer. What remains is a reader aid for same-prefix contradictions,
2265
+ which are the pairs already adjacent and therefore the ones review is least
2266
+ likely to miss, bought at the price of changing the bytes of every committed
2267
+ rendering and turning `render --check` red across every adopting repo on upgrade
2268
+ — for a change the user did not make. *The gap itself stays open* and stays
2269
+ where it belongs, in design §11: nothing resists a duplicated or contradictory
2270
+ requirement, and human review at propose is still the whole answer. What is
2271
+ rejected is grouping as a way to assist it, and this reopens only on a
2272
+ contradiction that grouping would have caught.
2273
+
2274
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.2...main
2275
+ [0.4.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.2
2276
+ [0.4.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.1
1557
2277
  [0.4.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.0
1558
2278
  [0.3.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.3.0
1559
2279
  [0.2.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.2