@am_shork/attest 0.4.1 → 0.4.3

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 (77) hide show
  1. package/CHANGELOG.md +982 -225
  2. package/README.md +25 -4
  3. package/dist/cli/index.js +8 -4
  4. package/dist/cli/report.d.ts +20 -0
  5. package/dist/cli/report.js +25 -51
  6. package/dist/core/apply.js +49 -1
  7. package/dist/core/docs.d.ts +1 -1
  8. package/dist/core/docs.js +1 -0
  9. package/dist/core/loader.d.ts +22 -0
  10. package/dist/core/loader.js +35 -1
  11. package/dist/core/parser.js +0 -0
  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/write.js +37 -5
  23. package/package.json +5 -3
  24. package/dist/cli/index.d.ts.map +0 -1
  25. package/dist/cli/index.js.map +0 -1
  26. package/dist/cli/json.d.ts.map +0 -1
  27. package/dist/cli/json.js.map +0 -1
  28. package/dist/cli/report.d.ts.map +0 -1
  29. package/dist/cli/report.js.map +0 -1
  30. package/dist/core/apply.d.ts.map +0 -1
  31. package/dist/core/apply.js.map +0 -1
  32. package/dist/core/docs.d.ts.map +0 -1
  33. package/dist/core/docs.js.map +0 -1
  34. package/dist/core/gate.d.ts.map +0 -1
  35. package/dist/core/gate.js.map +0 -1
  36. package/dist/core/loader.d.ts.map +0 -1
  37. package/dist/core/loader.js.map +0 -1
  38. package/dist/core/locate.d.ts.map +0 -1
  39. package/dist/core/locate.js.map +0 -1
  40. package/dist/core/order.d.ts.map +0 -1
  41. package/dist/core/order.js.map +0 -1
  42. package/dist/core/parser.d.ts.map +0 -1
  43. package/dist/core/parser.js.map +0 -1
  44. package/dist/core/paths.d.ts.map +0 -1
  45. package/dist/core/paths.js.map +0 -1
  46. package/dist/core/pipeline.d.ts.map +0 -1
  47. package/dist/core/pipeline.js.map +0 -1
  48. package/dist/core/red-record.d.ts.map +0 -1
  49. package/dist/core/red-record.js.map +0 -1
  50. package/dist/core/registry.d.ts.map +0 -1
  51. package/dist/core/registry.js.map +0 -1
  52. package/dist/core/render.d.ts.map +0 -1
  53. package/dist/core/render.js.map +0 -1
  54. package/dist/core/runner.d.ts.map +0 -1
  55. package/dist/core/runner.js.map +0 -1
  56. package/dist/core/schema.d.ts.map +0 -1
  57. package/dist/core/schema.js.map +0 -1
  58. package/dist/core/skill.d.ts.map +0 -1
  59. package/dist/core/skill.js.map +0 -1
  60. package/dist/core/static-registry.d.ts.map +0 -1
  61. package/dist/core/static-registry.js.map +0 -1
  62. package/dist/core/status.d.ts.map +0 -1
  63. package/dist/core/status.js.map +0 -1
  64. package/dist/core/targets.d.ts.map +0 -1
  65. package/dist/core/targets.js.map +0 -1
  66. package/dist/core/types.d.ts.map +0 -1
  67. package/dist/core/types.js.map +0 -1
  68. package/dist/core/validator.d.ts.map +0 -1
  69. package/dist/core/validator.js.map +0 -1
  70. package/dist/core/version.d.ts.map +0 -1
  71. package/dist/core/version.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
package/CHANGELOG.md CHANGED
@@ -13,6 +13,607 @@ 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.3] - 2026-08-01
17
+
18
+ ### Added
19
+
20
+ - **The README's two getting-started samples are now the fixture the packaging
21
+ test installs and runs.** `tests/docs-consistency.spec.ts` opened by naming
22
+ this hole: every `*.md` outside the generated `SPEC.md` is hand-written prose
23
+ that no mechanism binds to the code, "the framework's own blind spot (design
24
+ §11)". Every check in that file until now gated a version string or a name;
25
+ this is the first that gates *meaning* — a registry or a spec on the page that
26
+ the engine would now reject was undetectable, which for a tool whose whole
27
+ claim is reporting drift between intent and what the tests execute is the
28
+ failure that costs the most.
29
+ The mechanism is a naming split rather than new machinery, and most of it was
30
+ already there and unnamed: `fixtures/consumer/` imports by bare specifier from
31
+ `@am_shork/attest` and `@am_shork/attest/define` because it is installed from a
32
+ real tarball outside the repo — which is exactly what a reader has to be able
33
+ to paste. So the two files lost their fixture-specific commentary, gained the
34
+ README's teaching comments, and the README now quotes them; the new assertion
35
+ is byte-equality between the fenced block and the file, newline-normalised so a
36
+ CRLF checkout cannot call a clean tree stale. The fixture is the original and
37
+ the page is the quote: breaking either side fails.
38
+ *What moved in the README:* the spec sample is now `session.spec.ts` beside
39
+ `session.ts` rather than `auth/session.spec.ts`, matching the layout the
40
+ packaging test actually runs, and it shows both of the requirement's scenarios
41
+ because the installed run asserts `scenarioCount: 2`.
42
+ *Limits, both deliberate:* the gate covers 2 of the 23 fenced `ts` blocks in
43
+ the README and `docs/en/**` — the rest are elided, ✓/✗-annotated, or
44
+ deliberately rejected by the engine, and none of those can equal a runnable
45
+ file. And the two claims are joined only on a release: this gate runs in
46
+ `pnpm test` and proves the page and the fixture agree, while the run that
47
+ proves the fixture still *works* is `pnpm test:consumer`, which needs a network
48
+ and a tarball. Both remaining halves are in `Planned`.
49
+
50
+ - **The `docs/en` ↔ `docs/zh` mirror rule is now a gate rather than a paragraph
51
+ in `CLAUDE.md`.** Two assertions in `tests/docs-consistency.spec.ts`, both
52
+ scoped to what is comparable without comparing prose. The file set: a page
53
+ added on one side only is not a stale translation, it is a page that does not
54
+ exist, and nothing in the repository would have noticed. And the structure of
55
+ each pair — the heading tree as levels in document order, and the sequence of
56
+ fence info strings — which catches a section dropped or reordered in
57
+ translation, a code sample present in one language only, and a ```ts that
58
+ became ```bash.
59
+ *The parse is the part with a trap in it.* A `#` at the start of a line inside
60
+ a ```bash sample is a shell comment, and these pages are full of them, so the
61
+ reader tracks fences and skips their contents; a closing fence must repeat the
62
+ character that opened it, or a ``` quoted inside a ~~~ block ends the wrong
63
+ thing. An unterminated fence is reported rather than ignored, because it hides
64
+ every heading after it — two documents can agree by being truncated. All three
65
+ are asserted directly, on the standard this file already applies to its other
66
+ parses: a check whose parser can silently match nothing is not a check.
67
+ *One third of the queued entry turned out to be already covered and was not
68
+ written.* It asked for the `##` issue-code headings of `troubleshooting.md` to
69
+ be held equal between languages; the roster check that predates it already
70
+ asserts each language against `ISSUE_CODES` in both directions, so their
71
+ equality follows and a third assertion would catch nothing they let through.
72
+ *The limit, stated because it is not small:* a page whose structure held while
73
+ its meaning went stale passes everything here. Translation is not a diff. Same
74
+ shape as the limit the History gate carries — it can check that a row cites a
75
+ real version, not that the row is true.
76
+
77
+ ### Documentation
78
+
79
+ - **The agent-facing documents gained a diagnosis discipline, a review pass, and
80
+ a design vocabulary**, adapted from an external skill collection rather than
81
+ copied from it — the parts that survived are the ones this repository's own
82
+ defect history had already paid for.
83
+ *New skill, `.claude/skills/diagnose/`.* Its one rule is that no hypothesis may
84
+ be formed before a command exists that has been run at least once and can go
85
+ **red** on the bug in hand. Every defect this repository has recorded failed
86
+ silently rather than loudly, so that bar is higher here than the phrase
87
+ suggests, and the skill's local content is the questions that tell a green run
88
+ apart from one that never happened: read the test count rather than the colour,
89
+ since a `beforeAll` throw reports a file as *skipped* (`[0.4.2]`); confirm the
90
+ file you care about actually ran, since a glob that matched nothing is a clean
91
+ run over code that never loaded; and name which half a command covers before
92
+ trusting it. The ways that goes wrong are the Gotchas in `CLAUDE.md`, pointed
93
+ at rather than restated — a second copy of them would only be a second copy to
94
+ drift. The remainder is the standard loop: minimise until every element is
95
+ load-bearing, three to five falsifiable hypotheses before testing any,
96
+ `[DEBUG-…]`-tagged probes, and "no correct seam is itself the finding".
97
+ *`pre-commit-check` gained §8, a reading pass on two axes*, kept unmerged and
98
+ unranked against each other because a change can pass either and fail the
99
+ other. The Spec axis is mostly machine-checked here and nowhere else — `check`,
100
+ `cover` and `verify` *are* drift detection — so it reads only for what those
101
+ cannot reach: whether an `ATX-n`'s text states the contract, whether `params`
102
+ pin the values the scenario should be pinned to, and whether a green scenario
103
+ attests its requirement or merely passes. The Standards axis carries a twelve-
104
+ smell baseline (Fowler, *Refactoring* ch.3) as labelled heuristics that any
105
+ documented rule here overrides, with two weighted for this repository:
106
+ duplicated path-shaped code means a second copy that did not go through
107
+ `src/core/paths.ts`, and speculative generality is the other face of §1's rule
108
+ that every requirement costs a scenario forever.
109
+ *`CLAUDE.md` gained a design vocabulary* — module, interface, depth, seam,
110
+ adapter, and the three tests that settle most arguments — which exists because
111
+ the two structures it names were already these things and had no word for it.
112
+ `src/core/paths.ts` is the repository's deepest module, and the deletion test
113
+ is not hypothetical for it: every silent path defect in the Gotchas is what
114
+ this codebase looked like when that knowledge lived at the call sites. The two
115
+ registry readers are two adapters at one real seam, which is what makes the
116
+ differential suite possible — and the entry marks where the analogy stops,
117
+ since which adapter runs is fixed by the command and never a fallback, that
118
+ being a security property rather than a design preference.
119
+ *And a section on where a decision goes*, which is this file. It is stated in
120
+ `CLAUDE.md` rather than only in `pre-commit-check` because the moment to write
121
+ one is when the decision happens; a design evaluated and dropped mid-session is
122
+ what is most likely to be lost, and by commit time the reasoning that killed it
123
+ is already gone.
124
+ *The candidate that did not survive is the reason the section exists.* The
125
+ proposal was a `docs/adr/` directory holding three decisions lifted out of the
126
+ Gotchas — why `fixtures/nested-spec/` may not be flattened, why `render
127
+ --check` compares the document rather than the bytes, why one `spawn` helper.
128
+ All three were already recorded here, in more detail than the proposal would
129
+ have carried, so the directory's whole effect would have been to split a store
130
+ this file keeps deliberately single. Recorded rather than filed as work, since
131
+ no such directory exists: had it been put under `docs/en/`, the mirror gate
132
+ added above would not have seen it at all — `pages()` is a non-recursive
133
+ `readdir` filtered to `*.md`, so a subdirectory is invisible to the file-set
134
+ check, the structure check, and the version-bearing sample scan alike. A
135
+ documentation subdirectory is therefore an unmirrored one, silently, which is
136
+ this repository's signature failure shape wearing documentation's clothes.
137
+
138
+ ### Fixed
139
+
140
+ - **The packaging test installed into a directory Windows spells two ways, and
141
+ read the result as the package being broken.** `tests/consumer.spec.ts` staged
142
+ its throwaway project under `os.tmpdir()`, which on Windows reads `TEMP` — set
143
+ by the OS to the 8.3 short form, `C:\Users\JOHN_C~1\...`. Vite resolves the
144
+ peer `vitest` from the *long* form of the same directory, so the consumer's
145
+ spec file and the installed `@am_shork/attest` imported two module instances of
146
+ it; `describe` ran against an uninitialised runner and the spec died at import
147
+ with `Cannot read properties of undefined (reading 'config')`, before a
148
+ scenario executed. What that surfaced as is the part worth recording: `attest
149
+ verify` reported `tests-red` plus `declared-not-run` for every scenario —
150
+ a report indistinguishable from a genuinely broken package, produced by a
151
+ package that is fine. Staging through `realpath` fixes it.
152
+ This is `CLAUDE.md`'s first failure family one form further out — not a
153
+ separator inside a path, but two spellings of one directory — and it was
154
+ unreachable until `[0.4.2]` made this suite run on Windows at all. CI is Linux,
155
+ where `os.tmpdir()` has one spelling, so the pipeline was never going to say
156
+ otherwise.
157
+
158
+ - **The AST parser's own source file was binary, so every change to it since the
159
+ initial commit was unreviewable.** `src/core/parser.ts` separated the two
160
+ halves of its param-ref dedupe key with a **raw NUL byte** rather than the
161
+ `\x00` escape. One character no editor renders reclassified the whole file:
162
+ `git diff` answered `Binary files a/… and b/… differ` for every change to it,
163
+ `git grep` and `grep` skipped it in silence, and `.gitattributes`' `eol=lf`
164
+ never applied, because `text=auto` tests for binary first.
165
+ *What it cost is the reviewing, not the running.* A string escape is resolved
166
+ by the compiler, so the key is the same three code units on every platform —
167
+ no behaviour changes, and no requirement, scenario or schema moves. What was
168
+ lost is that every text sweep over this repository silently excluded the one
169
+ module that turns spec source into the plan every command reads. A pre-commit
170
+ pass grepping `src/` for a rule — a `path.relative` outside `paths.ts`, a
171
+ stray `spawn` — was answering about 27 of 28 files and reporting no gap, which
172
+ is worse than not having run it.
173
+ *And it shipped.* `files` carries `dist`, and `tsc` copies a template
174
+ literal's contents verbatim, so `dist/core/parser.js` put the byte into every
175
+ installed copy.
176
+ Recorded at this length because the defect is not the byte but its invisibility:
177
+ this is `CLAUDE.md`'s silent-failure shape with no platform in it at all, and
178
+ the tools anyone would bring to look for it are the ones it disables. The last
179
+ thing it costs is its own diff — the old side of this commit is still the
180
+ binary blob, so this entry is the only readable account of the change.
181
+
182
+ ## [0.4.2] - 2026-07-30
183
+
184
+ ### Added
185
+
186
+ - **The engine ↔ runner boundary is now a gate over the import graph, and it
187
+ had already been crossed.** Design §11 records the widest limit this design
188
+ has: `typescript` is a runtime dependency and `vite`/`vitest` are hard peers,
189
+ so the addressable set of repositories is a subset of one ecosystem's. The
190
+ answer that escapes it is a package split — a core that does not know a runner
191
+ exists plus one thin adapter per runner — and **that split is deliberately not
192
+ done**: a monorepo's build matrix, version coupling and CI fan-out are not
193
+ earned by one package at 0.4.x, and the dogfooding loop is what would pay,
194
+ since `bin/attest.js` loads `dist/` and `self/` is verified through it.
195
+ What ships is the cheap half. `tests/import-boundary.spec.ts` asserts that
196
+ exactly three modules may name `vite`/`vitest` — `src/runtime.ts` (the shipped
197
+ runtime, which is Vitest's `describe`/`it` under Attest's names and executes
198
+ *inside* the child run), `src/core/loader.ts` (the Vite loader) and
199
+ `src/core/runner.ts` (the only module that starts Vitest). It reads the
200
+ specifiers from the AST rather than matching text, so a comment naming
201
+ `vitest/node` is not an import and a dynamic `import()` is one.
202
+ **The boundary was not intact when the gate was written**, which is the whole
203
+ argument for having it: `pipeline.ts` imported `configDefaults` from
204
+ `vitest/config` for one exclude list, putting the peer dependency on the module
205
+ every static command goes through — `check`, `cover` and `render` included.
206
+ Nothing was wrong with the behaviour and no behavioural test could have been
207
+ wrong about it. Only the graph shows it, and a boundary is lost exactly this
208
+ way: one convenient import, in a module nobody thought of as being on the
209
+ runner's side of the line.
210
+ *What it cannot do:* gate **reachability** from the static commands, which is
211
+ not assertable today — `pipeline.ts` holds both halves of the engine, so it
212
+ reaches `runner.ts` unconditionally. That is the same fact the deferred split
213
+ would fix. A second test narrows the reachability claim to where it is both
214
+ meaningful and true: no path from `static-registry.ts` or `parser.ts` — the two
215
+ modules that must read a file without executing it — reaches a runner through
216
+ any module.
217
+
218
+ - **A positive control on the non-execution tests, which were passing without
219
+ one.** The tests asserting that `check`, `cover`, `render` and `status` never
220
+ execute the project work by writing a sentinel file at a registry's module
221
+ scope and expecting it *absent* afterwards. Every one of those assertions is
222
+ equally satisfied by an instrument that never worked: a `writeFileSync` to a
223
+ path that could not be written, or a registry the loader never reached, reads as
224
+ "absent" — indistinguishable from the guarantee being kept. `runVerify` now
225
+ runs last against the same project and the same sentinel, and the sentinel must
226
+ be *present*, which is what turns four absences into evidence. Added in the
227
+ default suite (`tests/pipeline.spec.ts`) and dogfooded under `ATX-16`
228
+ (`self/registry-reader.spec.ts`), since `vitest run` does not run `self/`.
229
+ *Not a new requirement:* this states nothing new about behaviour, it makes
230
+ `ATX-16`'s existing claim falsifiable — so it belongs to that scenario as its
231
+ control rather than to an `ATX-n` of its own.
232
+ *What is still not covered:* `archive` has no positive control of its own. It
233
+ evaluates the delta through the same `loader.load` the `--eval` escape hatch
234
+ uses, and that path is controlled under `ATX-32`, but nothing asserts that
235
+ `archive` specifically would notice if it stopped evaluating.
236
+
237
+ - **The `typescript` range is now run at both ends.** `[0.4.0]` collapsed the
238
+ peer range to `vite ^8` / `vitest ^4` on the argument that advertising a
239
+ version no job runs end to end moves the failure into the adopter's
240
+ repository. That test had never been applied to the third dependency it fits:
241
+ `typescript` is a runtime `dependencies`, and the range was a claim about every
242
+ 5.x while CI resolved exactly one of them — 5.9.3, four minors above the floor
243
+ the manifest advertised, which had therefore never been run at all.
244
+ It matters more here than for the peers, because a caret on a direct
245
+ dependency is not this repo's to resolve: a package manager may dedupe it
246
+ against the adopter's own `typescript`, so `parser.ts` and `static-registry.ts`
247
+ — the only two import sites, and both on the path that must read an AST and
248
+ never evaluate it — execute on whichever compiler in the range that project
249
+ already had. TypeScript is also not distributed under SemVer but as
250
+ `{marketing}.{major}.{minor}`, so a "minor" inside a major is free to change
251
+ parse behaviour.
252
+ The new `typescript-matrix` job installs each end and re-runs the differential
253
+ suite (`tests/static-registry.spec.ts`, the one extractor changes were already
254
+ required to go through) plus `tests/parser.spec.ts`. With the lockfile at 5.9.3
255
+ that is three points: 5.5.4, 5.9.3, 6.0.3. **Measured green at all three**, and
256
+ the floor is the one that had never been checked.
257
+ *What it cannot do:* prove the extractor against syntax this repo's corpus does
258
+ not contain — the same limit the History gate carries.
259
+ *No scheduled `typescript@rc` job*, which the queued entry had asked for. It is
260
+ rejected on measurement rather than skipped: `rc` and `latest` are 7.x, which
261
+ exposes no compiler API (see `Planned`), so the job would be red forever — the
262
+ decoration the `audit` job's comment refuses.
263
+
264
+ - **The README's dependency versions are now asserted against the manifest.**
265
+ Found by running the pre-commit list over this change rather than by reading the
266
+ diff: the Prerequisites section makes version claims about all three
267
+ dependencies, the manifest is the only place any of them is true, and nothing
268
+ bound the two. The existing gates in `tests/docs-consistency.spec.ts` pin
269
+ Attest's *own* version into samples and pinned docs URLs; the peer range had
270
+ been unbound prose since `[0.4.0]` collapsed it, which means the sentence
271
+ claiming the range is "exactly the one CI runs" was the one line in it nothing
272
+ ran. This change would have widened that hole by adding a second such claim, so
273
+ it closes it instead.
274
+ The coupling to wording is the cost, and it is narrower than it looks: the
275
+ string matched **is** the claim, unlike an `Issue.message`, which is prose
276
+ precisely so it can be reworded. Rephrasing the sentence is free as long as the
277
+ version stays in it, which is what the failure says.
278
+
279
+ ### Changed
280
+
281
+ - **`typescript` widened to `^5.5.0 || ^6.0.0`, and stays a `dependency`.** The
282
+ open question the queued entry raised was whether it should be a **peer**
283
+ instead: a runtime dependency is what buys determinism in principle, dedupe is
284
+ what removes it in practice, so the manifest stated a guarantee the resolver
285
+ does not honour. Measuring it answered the question in the opposite direction
286
+ from the way it was posed.
287
+ **A peer is not available, because the API is not.** TypeScript's `latest` is
288
+ now 7.x, whose npm package is the native port: its main export is
289
+ `{ version, versionMajorMinor }` and the AST moved behind
290
+ `typescript/unstable/ast*` — explicitly unstable. A peer range would therefore
291
+ either exclude 7 and so warn every adopter on the current release into
292
+ installing an older compiler explicitly, or admit one that cannot parse
293
+ anything. Bundling is the only shape that guarantees the API exists at all, so
294
+ what looked like a hedge is the load-bearing decision. Recorded under
295
+ `Considered and rejected` with the measurement.
296
+ The ceiling moved because 6.0.3 measures green on the full API surface the two
297
+ readers use and on both typecheck projects, and because the ceiling had become
298
+ a claim about a major that has since shipped and works. A TS-6 adopter now
299
+ dedupes to one copy running the compiler that matches their own syntax instead
300
+ of installing a second 5.9.
301
+ *Not a bump under this file's Versioning rule, and the reason is worth being
302
+ exact about.* No input that was valid is rejected and no exit code moves. An
303
+ already-installed adopter is untouched, because their lockfile pins what they
304
+ resolved. A **fresh** install genuinely can differ — in a repository already on
305
+ TypeScript 6 the resolver may now dedupe there where it previously fetched a
306
+ 5.x of its own, so the compiler behind the AST readers changes. That is a real
307
+ change and not a hidden one: it is the entire purpose of widening the range,
308
+ both ends are gated by the job above, and the widening only ever admits a
309
+ compiler this repo runs the differential suite against. What would make it a
310
+ breaking change is a range whose ends are *asserted* rather than run, which is
311
+ the state this entry ends. This repo's own resolution did not move: the lockfile
312
+ still holds 5.9.3.
313
+
314
+ - **`pipeline.ts` no longer imports `vitest/config`.** `runArchive` needed
315
+ Vitest's default exclusions to compose its own exclude set; the list is now
316
+ re-exported as `BASE_EXCLUDE` from `runner.ts`, which already had the import
317
+ and is one of the three modules allowed to. *Not a behaviour change* — the
318
+ composed exclude set is identical, and `BASE_EXCLUDE` is internal, not part of
319
+ the package's exports.
320
+
321
+ ### Documentation
322
+
323
+ - **§11's language-binding bullet now records an answer deferred rather than no
324
+ answer.** It stated the limit — one ecosystem's repositories, no more — and
325
+ said nothing about what holds the line in the meantime. It now says that the
326
+ dependency is confined to three modules and that the confinement is gated on
327
+ the import graph rather than left to review, that the confinement had already
328
+ failed once, and that the package split which actually escapes the limit is
329
+ deferred on cost grounds rather than unconsidered.
330
+ It also states the part of the binding that is **narrower** than "an
331
+ ecosystem", and which has already moved: the AST layer needs the TypeScript
332
+ *compiler API*, and TypeScript 7 does not ship one. So what Attest installs
333
+ into is a repository whose compiler is of a particular generation — which is
334
+ why the range has a ceiling and why the compiler is bundled rather than a peer.
335
+ §0 bought the parse layer with a dependency on one vendor's internal API, and
336
+ "never regex" is what makes that worth paying; the point now written down is
337
+ that the bill arrives again every time the API moves, on a schedule nobody here
338
+ controls. Both languages.
339
+
340
+ - **The README says which TypeScript versions are supported, and that yours is
341
+ not involved.** Prerequisites already explained the peer range and why it is
342
+ exactly what CI runs; the bundled compiler was not mentioned at all, so an
343
+ adopter on TypeScript 7 seeing a second `typescript` in their tree had nothing
344
+ to read. It now states the range, that both ends run in CI, that a TypeScript
345
+ 5-or-6 project dedupes to one copy, and that their own compiler version stays
346
+ their choice.
347
+
348
+ - **Said out loud that the archive gate trusts `first-run.json` and cannot check
349
+ it.** The reasoning had been written down since 0.3.0 — in a comment at the top
350
+ of `red-record.ts`, where no adopter reads it. What they read instead was that
351
+ `archive` is an executable gate and that this file is "the evidence", which
352
+ over-sells what running it buys: `hasRecordedRed` compares against `'fail'` and
353
+ nothing else, so a hand-written record clears `never-red` having proved
354
+ nothing.
355
+ The distinction worth stating, and now stated, is between the two kinds of file
356
+ Attest writes. The workflow document and a rendering are *derivable* — a pure
357
+ function of nothing, and a pure function of the registry — so a checker can
358
+ regenerate them and compare, which is exactly what `render --check` does. The
359
+ first-run record is not, and cannot be: it records that a scenario failed
360
+ **once**, and by the time a change is archivable everything is green, so there
361
+ is nothing left to recompute it from. That is inherent to what the file is
362
+ rather than a gap left open — if the state could be derived at archive time,
363
+ the record would not need to exist at all. So it is the one thing Attest writes
364
+ that cannot be checked by regenerating it, and what stands in for that check is
365
+ a reviewer. (The workflow document has no gate either, but that is a decision —
366
+ `init --check` was rejected, see below — not an impossibility.)
367
+ Landed in `troubleshooting.md#never-red` and under `attest archive` in the CLI
368
+ reference (both languages), and in the workflow document `attest init` writes —
369
+ where it also became a fourth entry in "things you must not do", beside editing
370
+ a scenario to match the implementation. Hand-writing the record belongs in that
371
+ list on the list's own terms: it turns the gate green without changing the
372
+ system. **Re-run `attest init` to pick it up.**
373
+ *Not in the README*, which links to the reference for this and stays the pitch
374
+ and the five-minute start. *Not a behaviour change*: nothing about what the
375
+ gate accepts moved, and binding the record to a CI run — the fix that would
376
+ make it checkable — is a separate decision, because it would stop `archive`
377
+ being a command that works offline.
378
+
379
+ ### Fixed
380
+
381
+ - **The packaging test could not run on Windows, and said so by reporting eleven
382
+ skipped tests.** `pnpm test:consumer` is the only thing that exercises the
383
+ published surface — the `files` allowlist, the `exports` map, the `bin` launcher,
384
+ peer resolution from a foreign `node_modules` — and it is what stops another
385
+ 0.1.1-style "green in-repo, broken on install" release. On Windows it had never
386
+ run at all. Three separate defects, each of which had to be fixed to see the
387
+ next one:
388
+ - *`npm` cannot be spawned by that name.* The file is `npm.cmd`, so `npm` is
389
+ ENOENT — and `npm.cmd` is EINVAL, because Node stopped spawning `.cmd` directly
390
+ when it closed the argument-injection hole in 22.x. The same applies to the
391
+ `.bin/attest` shim the install writes, so both the package manager and the CLI
392
+ under test were unspawnable. Both now go through one `spawn` helper that adds a
393
+ shell on win32 only, so POSIX — the platform CI gates on — spawns exactly as it
394
+ did before, and quotes its arguments where the shell is used.
395
+ - *The tarball path was substituted raw into JSON.* A Windows path's backslashes
396
+ are escape characters there, so `npm install` refused the generated manifest
397
+ with EJSONPARSE before reading a single dependency. This is the "path crossing
398
+ a boundary" shape `CLAUDE.md` already records, with JSON as the boundary rather
399
+ than a glob — so the fix is the one that rule already prescribes: the spec now
400
+ goes through `relativePath`, which answers both halves at once, POSIX
401
+ separators and a path short enough to have none. Spelling the `../` by hand
402
+ would have worked and was the first attempt, but it encodes the staging layout
403
+ as a constant: move the consumer project one level deeper and the literal is
404
+ wrong *while still being valid JSON and a valid specifier*, so the next failure
405
+ would arrive as a mystery ENOENT somewhere else.
406
+ - *The `bin` launcher's absence was untestable.* A missing shim surfaced only as
407
+ a spawn error from the helper, which reads as a broken test rather than a
408
+ broken `bin` field, so it now has an assertion of its own.
409
+ **The failure shape is the reason this survived four releases.** The throw was in
410
+ `beforeAll`, so Vitest reported the file's tests as *skipped* — the run said
411
+ nothing was wrong while executing none of it, and CI is Linux, so the pipeline
412
+ was never going to disagree. A third defect shape is now recorded in `CLAUDE.md`
413
+ beside the two it already carried.
414
+ *What is verified where:* all eleven tests pass on Windows, running the real
415
+ `npm` and the real `.bin/attest.cmd`. The POSIX branch is unchanged by
416
+ construction — no shell, same command name, arguments passed through untouched —
417
+ and its one new behaviour is the relative `file:` spec; that half is left to CI,
418
+ which runs this job on Linux on every push.
419
+
420
+ - **The crash path printed the checked project's text to the terminal with
421
+ nothing removed.** 0.4.0 closed terminal injection by putting every diagnostic
422
+ through a sanitiser, and left the two `catch` blocks in the CLI printing
423
+ `err.stack` raw beside it. That is not a lesser path: under `verify`,
424
+ `archive` or `--eval` the thing that throws is a module out of the repository
425
+ under test, so the message on that stack is that repository's text — and
426
+ throwing is the cheapest thing a hostile registry can do, cheaper than getting
427
+ a scenario name into a report. On a fork merge request the reader is not the
428
+ author, and the payload is the same one as before: erase the lines above,
429
+ repaint a red verdict green, retitle the window.
430
+ **Both `catch` blocks now go through one formatter**, which is `block` — the
431
+ same function the diagnostics use, so a stack's frames indent by four more
432
+ spaces and no line of an injected message can occupy the column a real `ERROR`
433
+ header sits in. One function rather than a copy each, because a copy each is
434
+ how the first one came to be missed; ATX-37 is restated over everything the
435
+ CLI prints rather than over "the report", since the narrow wording is what the
436
+ narrow implementation matched. `--json` is untouched and was never exposed —
437
+ serialising escapes the same characters, which is exactly why this had to be
438
+ fixed on the side people read.
439
+ **And the loader, which prints before Attest ever sees the error.** Driving
440
+ the built CLI at a registry that throws escape sequences is what turned this
441
+ up, after the `catch` blocks were already fixed: `logLevel: 'error'` quiets
442
+ Vite's chatter and not its errors, and when a `*.reqs.ts` throws, Vite writes
443
+ `Error when evaluating SSR module …: <the message>` to stderr on its own
444
+ account. So the `registry-invalid` diagnostic built from that very exception
445
+ was being sanitised while the raw bytes had already gone past it — a forged
446
+ `ERROR` header at column 0 and a `\r` to erase the line above it, from a file
447
+ the reviewer only ran. The loader now passes a `customLogger` that puts every
448
+ message through the same function. The whole message is treated as untrusted,
449
+ which costs Vite its own colour on that path: by the time it arrives the
450
+ project's text has been interpolated into it, so there is no half left to
451
+ trust.
452
+ *Where the sanitiser lives changed with it.* `block` / `inline` / `control`
453
+ moved from `cli/report.ts` to `core/terminal.ts`, because the loader is below
454
+ the CLI and cannot import from it — and a second copy of this decision is
455
+ precisely what the crash path had been.
456
+
457
+ - **A failed write left a scratch file in a version-controlled directory, and
458
+ named it something another process could take first.** `writeAtomic` is what
459
+ keeps a rendering, the agent skill and a change's first-run record from ever
460
+ being committed half-written, and the temporary file it renames from was doing
461
+ damage on both of the paths it could fail on. Nothing removed it when the
462
+ rename failed, so `.attest.<pid>.tmp` stayed next to the file it failed to
463
+ become — either committed, or a dirty working tree in `archive`, the one
464
+ command that takes care not to produce one. And the pid it was named for
465
+ separates two *runs*, not two *writes*: `init` writing several targets is the
466
+ second shape, and it was safe only by happening to be sequential, which makes
467
+ the safety a property of that loop rather than of the write.
468
+ **The scratch file now takes a random name, is created with `wx`, and is
469
+ removed when anything between the write and the rename throws.** `wx` is
470
+ `O_CREAT | O_EXCL`, so an existing path at that name — a symlink included — is
471
+ refused rather than followed; with an unpredictable name nothing can aim at
472
+ the path in the first place, and `wx` is what makes a guess useless rather
473
+ than merely unlikely. No `mode` is set: the temporary file *becomes* the
474
+ destination, so tightening it would quietly change the permissions of a
475
+ document the user commits and serves. (ATX-43)
476
+ *What ATX-43 does not state.* The exclusive creation is in the code and not in
477
+ the requirement, because an unpredictable name means no scenario can
478
+ pre-create the path to observe it — and a requirement whose scenario cannot
479
+ fail is worse than the comment that records it (design §11). The two
480
+ properties that *are* observable, and are stated, are that no scratch file
481
+ survives either outcome and that two writes into one directory cannot collide.
482
+
483
+ *Behaviour change.* Neither entry changes an exit code, a `--json` field or a
484
+ diagnostic. The visible differences are that a stack printed on stderr is
485
+ indented under its first line, that a `*.reqs.ts` which throws under `--eval`
486
+ now has Vite's report of it printed without colour, and that a temporary file
487
+ is named `.attest.<uuid>.tmp` rather than `.attest.<pid>.tmp` — anything
488
+ matching the old name in a `.gitignore` should become `.attest.*.tmp`, though
489
+ after this there is nothing left for it to match.
490
+
491
+ - **A delta could bring a requirement into the registry under an id the registry
492
+ itself would refuse, and one spelling of that id was not a key at all.** A
493
+ delta introduces an id in two places — an ADDED key, and the target of a
494
+ RENAME — and `applyDelta` checked neither: it validated the requirement a
495
+ delta adds while never looking at the key it files it under, and it called
496
+ `map.set(to, …)` without looking at `to`. So `^[A-Z]+-\d+$` — the grammar
497
+ `defineRequirements` holds every registry key to — did not apply on the one
498
+ other path that creates them. The mild form is an inconsistency: a change
499
+ could archive under `auth-7`, an id nobody could then write into the
500
+ `*.reqs.ts` the change is supposed to merge into, so the gate passed on an end
501
+ state the intent layer cannot represent.
502
+ The form that made it a defect is `__proto__`. Added ids do not stop at the
503
+ registry — `addedIds` carries them into the first-run record — and there
504
+ `record[id] ??= {}` never fired for that name, because reading it yields the
505
+ shared prototype rather than `undefined`. The outcome was then written onto
506
+ `Object.prototype`: one spec file in one change, and every object in the
507
+ process carries the key.
508
+ **Every id a delta introduces now goes through the registry's own schema
509
+ first, in one function both sites call.** ADDED reuses `add-invalid`, because
510
+ the reader's situation and the file to go and edit are unchanged and a second
511
+ code would ask them to learn a distinction that alters nothing they do. RENAME
512
+ gets a new one, `rename-target-invalid`, because the existing pair are both
513
+ about *existence* — a target that is taken, a source that is gone — and
514
+ neither can say the id is unusable; it is reported ahead of the source so a
515
+ delta with both ends wrong names the half the author has to retype. (ATX-41)
516
+ *This repository's own example was one of the offenders.* `renamed: [{ from:
517
+ 'AUTH-2', to: 'AUTH-2-login' }]` stood in both design documents, in the
518
+ workflow document `attest init` writes into every adopter's repo, and in these
519
+ tests — so the shape being refused is the shape we had been teaching. All of
520
+ them now read `to: 'SESSION-1'`, which is the better example anyway: it shows
521
+ why a rename happens at all (re-homing an id under its own prefix, per
522
+ `duplicate-prefix`) rather than decorating the old one.
523
+
524
+ - **The archive gate could be satisfied by evidence that was never recorded.**
525
+ `changes/<name>/first-run.json` is read into a plain object, and `JSON.parse`
526
+ makes `__proto__` an ordinary own property — so a record carrying that key
527
+ re-pointed the *record's* prototype, and `hasRecordedRed` then answered `true`
528
+ for a requirement the file says nothing about. The file serialises back as
529
+ `{}`, so the never-red obligation — the one mechanism whose entire purpose is
530
+ to hold a fact about history — was met by a file containing no history, and
531
+ the diff a reviewer reads showed an empty record. Reaching it needed nothing
532
+ but a text editor.
533
+ **The record is now built and read as a map with no prototype**, at both
534
+ levels, and `recordedOutcome` looks up own properties only rather than
535
+ `record[reqId]?.[scenario]` — so the guarantee does not depend on who
536
+ constructed the object the gate was handed. Prototype-free rather than a check
537
+ for the handful of names that misbehave: a blocklist is a second answer to
538
+ "is this a data key", and having no prototype is the first one. This also
539
+ stops the answer from resting on how the schema library happens to treat
540
+ `__proto__`, which today is what was discarding it before the gate saw it.
541
+ (ATX-42)
542
+
543
+ *Behaviour change, and who it turns red.* The first entry rejects input that
544
+ used to pass, on both halves: a delta that ADDs an id outside `^[A-Z]+-\d+$`
545
+ now fails `archive` with `add-invalid`, and one whose rename target is outside
546
+ it fails with `rename-target-invalid`, where both previously merged. The
547
+ second half is the one likelier to bite, because the pattern it refuses is the
548
+ one our own documentation showed. Both are real CI breaks for anyone who has
549
+ such a delta in flight, and both are deliberate — the alternative is archiving
550
+ a state that cannot be written back into a registry. The fix is to retype the
551
+ id in `requirements.delta.ts`; nothing else in the change moves, and a
552
+ requirement already merged under a bad id is unaffected, since this checks
553
+ deltas rather than registries. The second entry loosens nothing and tightens
554
+ nothing a well-formed record could notice: a record whose keys are real ids
555
+ reads and serialises exactly as before.
556
+ `rename-target-invalid` is a new `code` value and therefore additive — no
557
+ `SCHEMA_VERSION` bump, and a consumer branching on `issues[].code` sees a code
558
+ it has not heard of exactly as it would any other new diagnostic.
559
+
560
+ - **The package shipped 54 source maps and none of the sources they point at.**
561
+ `sourceMap` and `declarationMap` have both been on since the first build, and
562
+ every map tsc emits names its origin as `../../src/*.ts` — a path outside
563
+ everything the `files` allowlist carried, which was `dist`, `bin` and this
564
+ document. So each map in an installed copy resolved to nothing: a debugger
565
+ that will not step into the engine, and a *Go to Definition* that stops at the
566
+ `.d.ts` instead of the declaration behind it. Nothing errored, which is how it
567
+ survived four minors — every tool that reads a map ignores an unresolvable one
568
+ rather than failing on it, so this was a feature quietly not working rather
569
+ than a thing anyone could notice breaking.
570
+ **The maps are now excluded from the package rather than repaired.** `files`
571
+ grows a negation (`!dist/**/*.map`); they stay in `dist/` for this repo's own
572
+ use, where `src/` is present and they resolve, and the tarball drops to
573
+ 119.7 kB across 59 files — smaller than the 147 kB that shipped broken.
574
+ *Why not the other two shapes.* Adding `src` to `files` also works and was
575
+ the first fix written: it makes both maps resolve, at 195 kB and 148 files, to
576
+ serve stepping into the engine from an adopter's `node_modules` — which
577
+ nothing has asked for, and which anyone auditing the reader would do from the
578
+ repository instead. This file has rejected `init --check`, `divergent-param`
579
+ and countable obligations for costing more than a demonstrated need, and that
580
+ standard does not bend for a packaging decision. Turning the two compiler
581
+ options off is the cleanest published output, since it leaves no dangling
582
+ `sourceMappingURL` comment, but it also removes the maps from `dist/` here,
583
+ where `verify self` runs through the built CLI and they are the only thing
584
+ that maps a crash back to a line of source. (`inlineSources` is not a third
585
+ option: it embeds text into the `.js.map` and does nothing for the
586
+ `.d.ts.map`.)
587
+ *What is gated, and where.* `tests/consumer.spec.ts` asserts the installed
588
+ tree carries no `*.map` and no `.ts`. The pairing is the thing that can come
589
+ apart silently — drop the negation, and the package is back to shipping maps
590
+ that resolve to nothing — and the installed tree is the only place the
591
+ question exists, since in-repo `src/` is always there. Reversing the decision
592
+ means inverting that assertion, not deleting it, which the test says in
593
+ place.
594
+
595
+ - **`build` never removed `dist/`, so a module deleted from `src/` kept being
596
+ emitted.** `tsc` overwrites its outputs and deletes nothing, so a renamed or
597
+ removed module leaves its last emit behind indefinitely. Found by the check
598
+ above rather than by looking: `dist/cli/version.js` and `dist/core/guide.js`
599
+ were still present, both orphaned by `[0.4.1]`, one of them by the
600
+ `packageVersion()` move that release records.
601
+ **What it was not is a correctness hazard, and the distinction is worth
602
+ keeping.** `dist/` mirrors the import graph of `src/`, and a dangling import
603
+ in `src/` fails `tsc`, so an orphan is unreachable by construction — dead
604
+ weight rather than something `bin/attest.js` could load. No published tarball
605
+ carried one either: `dist/` is gitignored and the publish job builds from a
606
+ fresh checkout. What is left is a working tree where `dist/` is not the output
607
+ of any single build, which is worth one line to remove given that every
608
+ `attest …` command in this repo runs from it, and `CLAUDE.md` already has to
609
+ warn that those commands reflect the last build rather than `src/`.
610
+ `build` now runs a `clean` script first. `tsc -b --clean` was tried and does
611
+ not do this job — it deletes the outputs it can derive from the current
612
+ sources, which is every file except the orphan. The remaining shape without a
613
+ new dependency is a `node -e` one-liner; `rimraf` would read better in the
614
+ manifest and pulls a transitive tree in exchange, which is the trade the
615
+ `audit` job and the `brace-expansion` override exist to keep an eye on.
616
+
16
617
  ## [0.4.1] - 2026-07-29
17
618
 
18
619
  Every diagnostic now links to a section that explains it, and the README is a
@@ -1428,6 +2029,124 @@ Initial release.
1428
2029
  (MIT), whose four-stage engine and diff-first change model Attest's
1429
2030
  architecture is adapted from (re-implemented from scratch, no source copied).
1430
2031
 
2032
+ ## Planned
2033
+
2034
+ Decisions **to** build something, not yet built. The mirror of `Considered and
2035
+ rejected` at the foot of this file, and the three sections from here down live
2036
+ together for the same two reasons: nothing in them has shipped, so nothing in
2037
+ them belongs to a release, and a block pinned above the version history sinks
2038
+ one release deeper every time one is cut. They run by decreasing commitment —
2039
+ decided yes, undecided, decided no. An entry leaves this one by being
2040
+ implemented — at which point it is rewritten as an `Added` bullet under its
2041
+ version — or by being rejected, at which point it moves down with what killed
2042
+ it. Neither move is a deletion.
2043
+
2044
+ This is not the issue tracker and does not replace it. `[0.1.6]` records that
2045
+ feedback reports are triaged into issues or `Unreleased` and deliberately not
2046
+ kept in this tree; that rule stands, and holds for anything with a reporter, a
2047
+ repro, or a state to track. What is kept here is the narrower thing this file
2048
+ already keeps one section of: a **decision with an argument attached**, where
2049
+ losing the argument would cost more than losing the task. The last below came out
2050
+ of reading another repository against this one — `msw`. The first came out of
2051
+ building something queued here, which is the other way an entry arrives: a gate
2052
+ that finds a defect on its first run files the defect.
2053
+
2054
+ - **A diagnosable refusal when the resolved compiler has no AST API.**
2055
+ Discovered by building the `typescript` matrix (see `[0.4.2]`), which is
2056
+ what a matrix is for. `typescript@7` is the native port: its npm package's main
2057
+ export is `{ version, versionMajorMinor }`, and the AST moved behind
2058
+ `typescript/unstable/ast*` — so `ts.createSourceFile` and every `ts.isX` guard
2059
+ the two readers call is `undefined`. Measured: `attest check` on it dies with
2060
+ `TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
2061
+ stack, on the crash path this release just finished sanitising. No issue code,
2062
+ no fix hint, and nothing saying which compiler it wanted.
2063
+ **The range excludes 7, and that is not the same as being safe from it.** A
2064
+ caret is a request, not a constraint the adopter cannot override: `pnpm.overrides`
2065
+ and Yarn `resolutions` both pin a transitive dependency across the whole tree,
2066
+ and a repo standardising on one compiler is exactly the repo that uses them —
2067
+ this one has an `overrides` block of its own. So the reachable case is a
2068
+ deliberate, reasonable act by an adopter, answered with a stack trace.
2069
+ What is planned is a check of the API surface where the readers are entered,
2070
+ reported as an ERROR naming the resolved version and the supported range. Cheap,
2071
+ and it converts the one dependency failure this tool cannot survive from a crash
2072
+ into a diagnosis. *Deliberately not folded into the matrix commit*: a new ERROR
2073
+ is a behaviour change, it needs an issue code, a troubleshooting section in both
2074
+ languages and an `ATX-n` with a scenario, and none of that belongs in a commit
2075
+ whose whole claim is that nothing about behaviour moved.
2076
+ *What it cannot do:* make Attest work on TypeScript 7. That is a migration to
2077
+ `typescript/unstable/ast`, against an API whose own name says not to depend on
2078
+ it yet — a separate decision, with the honest answer for now being that the
2079
+ supported range has a ceiling and says so.
2080
+
2081
+ - **Region-marked samples, so a partial quote can be bound too.** The whole-file
2082
+ half of this shipped in `[0.4.3]`: the README's two getting-started blocks
2083
+ are now byte-equal to `fixtures/consumer/`. That buys 2 of the 23 fenced `ts`
2084
+ blocks in the README and `docs/en/**`, and the ceiling is structural rather
2085
+ than lazy — the other 21 are elided (`{ /* … */ }`), annotated (`// ✓ the single
2086
+ source`, `// ✗ a literal that can drift`), or deliberately rejected by the engine
2087
+ (`params: { maxMb: MAX_MB }`), and none of those can equal a runnable file.
2088
+ **What raises it is marking spans, not a second mechanism.** A
2089
+ `// #region readme:registry` and its close inside the fixture, with the
2090
+ assertion running block against dedented span, lets one file feed several
2091
+ passages and makes a partial quote bindable by construction. Where that
2092
+ actually reaches, counted rather than hoped: `troubleshooting.md`'s
2093
+ single-source pair is two verbatim lines of `fixtures/consumer/session.spec.ts`
2094
+ and its `AUTH-3` block is the registry entry at a different indent — so a
2095
+ handful more, not the remainder. Anything carrying a `✓`/`✗` is authored
2096
+ commentary and stays a fragment; that is what it is for.
2097
+ *The ✗ blocks are a separate question, and worth not conflating.* A sample
2098
+ showing input the engine must reject could be bound to a fixture whose
2099
+ *rejection* is asserted — which is what `fixtures/eval-invalid-registry`
2100
+ already is. Binding those is the more valuable half, because a counterexample
2101
+ that stopped being a counterexample is the one kind of stale sample that
2102
+ actively teaches the wrong thing. It is also the one that needs a fixture per
2103
+ diagnostic, so it is not free the way the regions are.
2104
+ *The mirror rule still caps what can be claimed on the Chinese side.*
2105
+ `docs/zh/**` matches `docs/en/**` down to the comments inside its code samples,
2106
+ so byte-equality holds on one side only and the zh blocks stay translated prose
2107
+ around the same code — the parity gate in `[0.4.3]` is what covers them now,
2108
+ and it covers structure, not meaning. Unchanged from the whole-file half: the residual
2109
+ risk recorded with it in `[0.4.3]` — that the gate and the run proving the
2110
+ fixture still works live in different suites — applies here identically.
2111
+
2112
+ ## Under consideration
2113
+
2114
+ Candidates with **no decision yet**, between the two sections either side of it
2115
+ and at the foot of the file for the reason both of those are: nothing here has
2116
+ shipped, so nothing here belongs to a release, and an open question filed under
2117
+ a version reads as something that version settled. An entry leaves by being
2118
+ decided — up into `Planned` and then into an `Added` bullet under its version,
2119
+ or down into `Considered and rejected` with what killed it. Neither move is a
2120
+ deletion. What each entry has to carry is the evidence currently blocking the
2121
+ decision, because that is what a re-proposal a year from now would otherwise
2122
+ have to gather again.
2123
+
2124
+ - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
2125
+ project with no concurrency limit: `findFiles` recurses through
2126
+ `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
2127
+ once, and `parseSpecs` does `Promise.all(files.map(readFile))`, which reads
2128
+ every spec file into memory before parsing any of them. Neither is bounded by
2129
+ anything, and `check` is the command this repository tells people to run first
2130
+ in a pipeline on an untrusted fork MR — so the input size is not ours to
2131
+ assume.
2132
+ *The evidence, and why it is not enough to act on.* Measured on a synthetic
2133
+ tree of 6000 spec files: the fan-out is real and unbounded — peak in-flight
2134
+ `readFile` calls came back as exactly 6000 — and `attest check` finished in
2135
+ 1.99 s without erroring. It could not be made to fail. Windows is why: Node
2136
+ uses Win32 handles there rather than POSIX descriptors, so the `ulimit -n` of
2137
+ 3200 that the shell reports does not govern the process, and the one platform
2138
+ where descriptor exhaustion is plausible is the one CI runs and this
2139
+ measurement could not reach (`CLAUDE.md`'s two-platform rule, pointing the
2140
+ unusual way round). What *is* portable is the memory: `parseSpecs` holds every
2141
+ source at once, which at a realistic 8 KiB per spec file is ~47 MiB at this
2142
+ count — noticeable, not fatal.
2143
+ So the fix is known and cheap — a concurrency limit of ~32 at both sites, and
2144
+ parsing each source as it arrives rather than after all of them, which makes
2145
+ the peak constant — and it is held back by this file's own standard: no report,
2146
+ and no demonstrated failure on any platform. Recorded here so that the day
2147
+ someone hits an `EMFILE` from `attest check`, the diagnosis is already written
2148
+ down rather than rediscovered.
2149
+
1431
2150
  ## Considered and rejected
1432
2151
 
1433
2152
  Decisions **not** to build something, kept where they can be found before the
@@ -1438,231 +2157,269 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
1438
2157
  sinking one version deeper each time a release was cut above it, and a rejection
1439
2158
  filed under a version reads as belonging to it.
1440
2159
 
1441
- - **A requirement's id prefix must match its registry file's name.** The other
1442
- half of the rule that shipped as `duplicate-prefix` (see `[0.4.0]`), and
1443
- the half that does not pay for itself. What killed it is that the corpus
1444
- already conforms: every fixture registry matches (`auth.reqs.ts` `AUTH-`,
1445
- `core.reqs.ts` `CORE-`), so the rule would detect nothing anywhere it was
1446
- measured except one file `self/requirements/attest.reqs.ts`, which holds
1447
- `ATX-*`. Its first act would therefore be to rename this repo's own registry
1448
- and every `self/*.spec.ts` that imports it, and its first act in an adopter's
1449
- repository would be the same thing to theirs. That is rejecting input valid
1450
- today, a minor bump under this file's Versioning rule, in exchange for making
1451
- the prefix *guessable from the path* — which matters only to a reader already
1452
- looking at the file, and who could have read the ids in it. The collision rule
1453
- delivers what the gap is actually about, which is two owners of one space, and
1454
- it delivers it without a rename because it fires on a relationship between
1455
- files rather than on a spelling. Reconsider only with evidence that a reader or
1456
- a tool needs the prefix before opening the file a naming *convention* stays
1457
- the right shape for this, and the convention is what `init` and the docs
1458
- already teach.
1459
- What is not a reason to revisit it: the objection that stalled the collision
1460
- rule while both were queued together that it guards a convention nothing had
1461
- yet violated was answered by shipping it, since a rule that costs no
1462
- migration and no concept is worth having before the first collision rather
1463
- than after it. That argument does not transfer here, because this rule's cost
1464
- is a migration by construction.
1465
-
1466
- - **Two gaps in the intent layer: nothing resists a bloated requirement, and
1467
- nothing resists a duplicated one.** The gaps themselves are described in design
1468
- §11, which is where a permanent property of the design belongs. What is kept
1469
- here is the list of mechanisms tried and what killed each, so the same three
1470
- are not proposed again without new evidence. All three were scored against
1471
- every registry this repo ships *before* any was written into the enginethe
1472
- order `divergent-param` was originally designed in the wrong way round. The
1473
- measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
1474
- when a requirement is added without a hand-labelled obligation count, because
1475
- the reach figure once quoted here went stale exactly that way. The guard makes
1476
- the *suite* re-count; it cannot make this file re-read, so every figure below
1477
- is a quotation of that snapshot and is only ever as fresh as the last edit
1478
- here read the snapshot when the number is what matters.
1479
- *`compound-requirement` (WARNING) rejected.* More than one RFC-2119 keyword
1480
- in one `statement` is objectively more than one obligation. Measured (figures
1481
- re-read from the suite, which is the only place they are not prose): of the 8
1482
- requirements in the corpus carrying more than one, it flags 3 (`ATX-10`,
1483
- `ATX-21`, `ATX-26`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17`
1484
- every one that packs clauses under a single keyword, which is the form the
1485
- coverage incentive actually rewards. A wider variant (keyword plus a
1486
- coordinating `and`/`while`/`;`) reaches 7 of 8, at 3 wrong hits (`ATX-6`,
1487
- `ATX-22`, `ATX-23`) rather than the 1 recorded when it was first scored — so
1488
- reach is not the deciding argument, and the precision it trades away grows
1489
- with the registry.
1490
- This is: both variants are silenced by deleting the word that triggered them,
1491
- and neither can tell that deletion from a real split. `SHALL do A and MUST do
1492
- B` clears the warning as `SHALL do A and do B` same two obligations, one
1493
- fewer normative keyword. A rule whose cheapest fix degrades the artifact it
1494
- protects does not ship.
1495
- *`divergent-param` (WARNING) rejected.* One param name declared with
1496
- different values in two requirements sharing an id prefix. Measured: it fires
1497
- twice on this repo and both firings are wrong `idleTimeoutMin` (`15` in
1498
- `ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
1499
- (`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
1500
- `rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
1501
- agree. The second arrived on its own when `ATX-18` was added and grew a third
1502
- member on its own again when `ATX-21` was, then `ATX-25`, then `ATX-26`, which
1503
- is the more damning half: a generic param name collides more often as a
1504
- registry grows, so the false-positive rate rises with adoption the opposite
1505
- of what a shippable rule does. `ATX-26` added the sharper version of the same
1506
- point: it names the *same* diagnostic as `ATX-17`, so the two now agree by
1507
- coincidence and `ATX-17` drops out of the set the rule reports — a rule that
1508
- reads agreement as safety falls silent on a pair it had been flagging, for a
1509
- reason that has nothing to do with either requirement.
1510
- **A second param name now collides, and that is what closes the last defence
1511
- of the rule.** `file` names a path Attest writes — the agent skill in `ATX-27`,
1512
- a change's first-run record in `ATX-30` two unrelated constants, a third
1513
- wrong firing. Until it appeared, every false positive had been on the single
1514
- name `code`, which left "pick a less generic param name" open as a repo-side
1515
- workaround and therefore left the rule arguably salvageable. It is not the
1516
- word: two requirements owning unrelated constants of the same *kind* is the
1517
- structure, and any registry large enough to be worth linting has it.
1518
- Still zero true positives, now across a 34-entry corpus (the `corpus` figure in
1519
- the suite's snapshot, which counts a fixture id once per registry file that
1520
- declares it).
1521
- The escape hatch it was blocked on has since been designed
1522
- and does not rescue it: with no config file the only workable shape is an
1523
- additive `independentParams?: string[]` on the requirement (a CLI flag is
1524
- per-invocation for what is a permanent property of two requirements; changing
1525
- the `params` shape rejects every existing registry). Viable, but it is
1526
- permanent schema surface for a rule with no demonstrated true positive, and in
1527
- practice it would be written *after* the warning fires — a one-line silencer.
1528
- *Countable obligations (breaking) rejected.* An `obligations: string[]` per
1529
- requirement, with coverage counted per obligation, removing the lumping
1530
- incentive at the source instead of detecting it afterwards. Rejected because
1531
- the **id is already that unit**: splitting into two ids costs two scenarios and
1532
- buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
1533
- split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
1534
- and the `--json` schema, and it relocates the incentive rather than removing
1535
- itnothing can force the array to be complete.
1536
- *Not considered:* similarity matching between statements (token overlap,
1537
- embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
1538
- real semantic duplication needs judgement and belongs to human review at the
1539
- propose stage.
1540
-
1541
- - **A freshness gate on the file `attest init` writes (`init --check`).**
1542
- `render --check` is the obvious precedent and is the opposite case. A rendering
1543
- is a function of the *user's registry*, so its staleness means the user moved
1544
- intent without regenerating a real signal, with the fix in their hands. The
1545
- skill is a function of the *installed version*, so a gate on it turns red on a
1546
- change the user did not make, and is cleared by a commit containing no
1547
- decision. That is a chore wearing a diagnostic's clothes, and a gate that
1548
- cannot be about anything the reader did is worse than no gate the same
1549
- standard 0.1.6 set when it gave `render` no formatting options at all.
1550
- An earlier draft of `attest init` spent a second command, this gate and a
1551
- second self-requirement on it. What that would have bought is immunity to one
1552
- honest and small exposure: an old file describes an old workflow to an agent
1553
- that trusts it. The backstop is that every mistake it can cause is already a
1554
- diagnostic with a fix hint a registry written the old way is
1555
- `registry-not-static` so the agent corrects itself from the report, and
1556
- `init` is idempotent, which makes re-running it the entire update mechanism.
1557
- Recorded here rather than left in the 0.3.0 entry that shipped `init`, because
1558
- the queue above depends on it: a gate is the first thing proposed each time a
1559
- new agent target is considered, and the answer must not need re-deciding per
1560
- target.
1561
-
1562
- - **A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`.** The cross-tool
1563
- convention Codex, Cursor and Copilot read, and the reason it looks like the
1564
- obvious next target after the skill is that it reaches the most agents per unit
1565
- of work. Rejected on three counts, none of which is about effort.
1566
- **It is the only file `init` would write that Attest does not own.**
1567
- `.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
1568
- exists because the *project* does, and what is in it is prose its maintainer
1569
- wrote and curated. Writing a block into it means an idempotent
1570
- read-modify-write on someone else's document find the markers, preserve
1571
- everything outside them, and then handle the markers being absent, duplicated,
1572
- hand-edited, moved, the file missing, the file being `CLAUDE.md` instead, or
1573
- both existing. That is a merge tool, and each of its failure modes is
1574
- destructive on a file the user cannot regenerate.
1575
- **It would remove the property that makes `init` safe to automate.**
1576
- `skillContent()` takes no argument the content is a pure function of nothing
1577
- at all — so `init` is one atomic write and its idempotence holds *by
1578
- construction*, which is what lets "re-run it after upgrading" be the whole
1579
- update mechanism and be safe in a script. A marked block makes the output a
1580
- function of the file already there, demoting idempotence from a property of the
1581
- design to a behaviour that needs test coverage, on the one path where being
1582
- wrong destroys input.
1583
- **It is a worse carrier for the same text.** An `AGENTS.md` block is
1584
- unconditionally resident in the agent's context, where a skill body is loaded
1585
- when its description matches so every unrelated request in that repo pays for
1586
- a workflow it will not use. It was never a port of the skill; it is a second,
1587
- weaker capability wearing the same words.
1588
- The interaction with the entry above is what settles it: a user-edited block
1589
- cannot be replaced on upgrade without discarding the edit, so this shape needs
1590
- either the freshness gate rejected above or a three-way merge. *What replaces
1591
- it*, for an adopter whose agent reads `AGENTS.md`: one line they add
1592
- themselves, pointing at the skill file. A pointer is cheap enough to be
1593
- resident, it is their edit in their file, and it cannot go stale — the file it
1594
- names is the one `init` keeps current.
1595
-
1596
- - **A Codex target for `attest init`.** Evaluated when `init` grew `--target` and
1597
- shipped for Claude Code, Cursor and Copilot; rejected because the tool offers
1598
- nothing to write, not because of what writing it would cost. Codex's
1599
- project-scoped instruction surface is `AGENTS.md`, which the entry above
1600
- rejects permanently, and its custom prompts live in `~/.codex/prompts` — per
1601
- machine, outside the project root `init` is pointed at, and not a thing a
1602
- project can commit or a CI checkout can carry. That leaves no path that is both
1603
- **Attest's own** and **read by the tool**, so the two rules a target has to
1604
- satisfy are not merely unmet here, they are unmeetable with what exists: any
1605
- Codex-shaped implementation would be either the rejected merge or a file
1606
- nothing reads, and the second is worse, because it looks like coverage.
1607
- Filed here rather than queued as a candidate, since nothing about the effort
1608
- or the evidence is what is holding it — the pointer line above is the whole
1609
- answer for a Codex user, and this reopens only if Codex grows a project-scoped
1610
- path of its own.
1611
-
1612
- - **A committed snapshot of the params, to give `check` detection power over a
1613
- value that moves.** Design §11 records the measurement this answers: editing a
1614
- param (`'main'` `'trunk'`, one entry dropped from a nine-element list) leaves
1615
- `attest check` at `✓ No issues`, because the single source makes a value
1616
- impossible to *diverge*, which is not the claim that it is flagged when it
1617
- *moves*. The candidate was the shape `render --check` already proves a
1618
- `params.lock` beside the registry, or `check --params-check` and the
1619
- mechanism would have worked.
1620
- *What rejected it: the precedent is the objection.* §9 states when a freshness
1621
- gate earns its place, and a rendering earns it because it is a function of the
1622
- *user's registry*, so staleness means the user moved intent without
1623
- regenerating a real signal with the fix in their hands. A params snapshot is
1624
- a function of the registry too, but the population it fires on is different:
1625
- **every legitimate param edit turns it red, and the fix is a commit containing
1626
- no decision.** That is precisely the chore §9 declined to ship for `init`, and
1627
- it is worse here because of frequency intent is rendered rarely, while params
1628
- move whenever a value moves, which is the entire reason they are params. The
1629
- gate would spend its whole life red about correct edits, and a gate that is
1630
- usually wrong about staleness is the failure mode §9 names as worse than no
1631
- gate.
1632
- *What the gap keeps instead.* §6's mechanism 3 already exists to make this
1633
- exact absence visible: `possible-drift` fires when a requirement owns params
1634
- that no covering scenario reads, which is the only condition under which a
1635
- moved param goes unnoticed. The mechanism that guards a param is a scenario
1636
- reading it, not a file recording it. This reopens only on an adoption report
1637
- where a param moved, no scenario caught it, and `possible-drift` was **not**
1638
- already printingbecause if it was printing, what failed was reading the
1639
- report, and a second gate does not fix that.
1640
-
1641
- - **Grouping `render`'s output by prefix, so a reviewer sees contradicting
1642
- requirements together.** The *detection* half of this is already rejected above
1643
- (every automatic candidate either saw almost nothing or was a similarity
1644
- matcher, which is what §0 exists to remove), leaving human review at propose
1645
- (§7) as the answer and the idea was to help that review by putting
1646
- requirements about one subject side by side in the one document a human reads
1647
- end to end.
1648
- *What rejected it: the ordering already ships, and the adjacency it buys is not
1649
- where the gap is.* `compareIds` (`render.ts`) has ordered the document by
1650
- prefix first and number second since `render` shipped, so same-prefix
1651
- requirements are **already** adjacent; the candidate was never an ordering
1652
- change, only a heading over an ordering that already holds. And §11's own
1653
- example of the gap is `AUTH-3` expiring a session against `SESS-7` saying
1654
- sessions never expire a contradiction **across** prefixes, which prefix
1655
- grouping pushes further apart rather than closer. What remains is a reader aid
1656
- for same-prefix contradictions, which are the pairs already adjacent and
1657
- therefore the ones review is least likely to miss, bought at the price of
1658
- changing the bytes of every committed rendering and turning `render --check`
1659
- red across every adopting repo on upgrade for a change the user did not make.
1660
- *The gap itself stays open* and stays where it belongs, in design §11: nothing
1661
- resists a duplicated or contradictory requirement, and human review at propose
1662
- is still the whole answer. What is rejected is grouping as a way to assist it,
1663
- and this reopens only on a contradiction that grouping would have caught.
1664
-
1665
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.1...main
2160
+ ### `typescript` as a peer dependency instead of a bundled one
2161
+
2162
+ The open question the matrix entry was queued to settle (see `[0.4.2]`), and
2163
+ it is worth keeping because the reasoning that made it attractive is sound and
2164
+ still wrong. A runtime `dependencies` on a caret is a guarantee the resolver does
2165
+ not honour a package manager may dedupe it against the adopter's own compiler,
2166
+ so the version that actually parses their registry is not this repo's to choose.
2167
+ A peer makes that explicit instead of pretending otherwise, which is the shape
2168
+ this repository chose for `vite` and `vitest` in `[0.4.0]` for exactly that
2169
+ reason.
2170
+
2171
+ **What killed it is that the API a peer would supply does not exist.**
2172
+ TypeScript's `latest` is 7.x, whose npm package is the native port: the main
2173
+ export is `{ version, versionMajorMinor }` and the compiler API moved behind
2174
+ `typescript/unstable/ast*`. So a peer range has two spellings and both are worse
2175
+ than bundling. Admit 7 and the peer is satisfiable by a package with no
2176
+ `createSourceFile` at all the dependency declaration would state a requirement
2177
+ it cannot check, and the failure lands as a crash inside the reader that decides a
2178
+ security property. Exclude 7 and every adopter on the current TypeScript release
2179
+ gets a peer warning telling them to install an older compiler *explicitly*, in
2180
+ service of a tool that could have carried its own which is the ceiling-hurting-
2181
+ adopters failure `msw` reached the no-upper-bound answer from, arriving here from
2182
+ the opposite direction.
2183
+
2184
+ The asymmetry with `vite`/`vitest` is the whole of it, and it is not about
2185
+ stability: those two are peers because Attest must run *inside the adopter's own
2186
+ runner* a second copy would be a second test run, which is not a thing that
2187
+ works. Nothing about reading an AST needs the adopter's compiler; the reader wants
2188
+ *a* parser for TypeScript syntax, not *theirs*. A bundled one is therefore the
2189
+ only shape that can guarantee the API exists, and the duplicate install on a
2190
+ TypeScript 7 project is a real cost paid knowingly, not an oversightone extra
2191
+ `node_modules` entry, against a tool that otherwise cannot start.
2192
+
2193
+ Reconsider only if the compiler API becomes something a peer range can honestly
2194
+ describe again: `typescript/unstable/ast` reaching stability under a name that is
2195
+ not `unstable`, with a version range across which the AST shape is contractual.
2196
+ Until then the ceiling is the honest statement, and the range is run at both ends
2197
+ rather than asserted. *Not a reason to revisit:* an adopter reporting two copies of
2198
+ `typescript` in their tree. That is the decision working, and the README says so
2199
+ under Prerequisites.
2200
+
2201
+ ### A requirement's id prefix must match its registry file's name
2202
+
2203
+ The other half of the rule that shipped as `duplicate-prefix` (see `[0.4.0]`),
2204
+ and the half that does not pay for itself. What killed it is that the corpus
2205
+ already conforms: every fixture registry matches (`auth.reqs.ts` `AUTH-`,
2206
+ `core.reqs.ts` `CORE-`), so the rule would detect nothing anywhere it was
2207
+ measured except one file `self/requirements/attest.reqs.ts`, which holds
2208
+ `ATX-*`. Its first act would therefore be to rename this repo's own registry and
2209
+ every `self/*.spec.ts` that imports it, and its first act in an adopter's
2210
+ repository would be the same thing to theirs. That is rejecting input valid
2211
+ today, a minor bump under this file's Versioning rule, in exchange for making
2212
+ the prefix *guessable from the path* which matters only to a reader already
2213
+ looking at the file, and who could have read the ids in it. The collision rule
2214
+ delivers what the gap is actually about, which is two owners of one space, and
2215
+ it delivers it without a rename because it fires on a relationship between files
2216
+ rather than on a spelling. Reconsider only with evidence that a reader or a tool
2217
+ needs the prefix before opening the file a naming *convention* stays the right
2218
+ shape for this, and the convention is what `init` and the docs already teach.
2219
+ What is not a reason to revisit it: the objection that stalled the collision
2220
+ rule while both were queued together that it guards a convention nothing had
2221
+ yet violated was answered by shipping it, since a rule that costs no migration
2222
+ and no concept is worth having before the first collision rather than after it.
2223
+ That argument does not transfer here, because this rule's cost is a migration by
2224
+ construction.
2225
+
2226
+ ### Nothing resists a bloated requirement, and nothing resists a duplicated one
2227
+
2228
+ These two gaps in the intent layer are themselves described in design §11, which
2229
+ is where a permanent property of the design belongs. What is kept here is the
2230
+ list of mechanisms tried and what killed each, so the same three are not
2231
+ proposed again without new evidence. All three were scored against every
2232
+ registry this repo ships *before* any was written into the engine the order
2233
+ `divergent-param` was originally designed in the wrong way round. The
2234
+ measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails when
2235
+ a requirement is added without a hand-labelled obligation count, because the
2236
+ reach figure once quoted here went stale exactly that way. The guard makes the
2237
+ *suite* re-count; it cannot make this file re-read, so every figure below is a
2238
+ quotation of that snapshot and is only ever as fresh as the last edit here —
2239
+ read the snapshot when the number is what matters. *`compound-requirement`
2240
+ (WARNING) rejected.* More than one RFC-2119 keyword in one `statement` is
2241
+ objectively more than one obligation. Measured (figures re-read from the suite,
2242
+ which is the only place they are not prose): of the 8 requirements in the corpus
2243
+ carrying more than one, it flags 3 (`ATX-10`, `ATX-21`, `ATX-26`) and misses
2244
+ `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17` every one that packs clauses
2245
+ under a single keyword, which is the form the coverage incentive actually
2246
+ rewards. A wider variant (keyword plus a coordinating `and`/`while`/`;`) reaches
2247
+ 7 of 8, at 3 wrong hits (`ATX-6`, `ATX-22`, `ATX-23`) rather than the 1 recorded
2248
+ when it was first scored — so reach is not the deciding argument, and the
2249
+ precision it trades away grows with the registry. This is: both variants are
2250
+ silenced by deleting the word that triggered them, and neither can tell that
2251
+ deletion from a real split. `SHALL do A and MUST do B` clears the warning as
2252
+ `SHALL do A and do B` — same two obligations, one fewer normative keyword. A
2253
+ rule whose cheapest fix degrades the artifact it protects does not ship.
2254
+ *`divergent-param` (WARNING) rejected.* One param name declared with different
2255
+ values in two requirements sharing an id prefix. Measured: it fires twice on
2256
+ this repo and both firings are wrong `idleTimeoutMin` (`15` in `ATX-3`, `30`
2257
+ in `ATX-10`), unrelated illustrative fixtures, and `code` (`registry-not-static`
2258
+ in `ATX-17`, `empty-spec` in `ATX-18`, `rationale-placeholder` in `ATX-21`),
2259
+ diagnostics that could not possibly agree. The second arrived on its own when
2260
+ `ATX-18` was added and grew a third member on its own again when `ATX-21` was,
2261
+ then `ATX-25`, then `ATX-26`, which is the more damning half: a generic param
2262
+ name collides more often as a registry grows, so the false-positive rate rises
2263
+ with adoptionthe opposite of what a shippable rule does. `ATX-26` added the
2264
+ sharper version of the same point: it names the *same* diagnostic as `ATX-17`,
2265
+ so the two now agree by coincidence and `ATX-17` drops out of the set the rule
2266
+ reports a rule that reads agreement as safety falls silent on a pair it had
2267
+ been flagging, for a reason that has nothing to do with either requirement. **A
2268
+ second param name now collides, and that is what closes the last defence of the
2269
+ rule.** `file` names a path Attest writes the agent skill in `ATX-27`, a
2270
+ change's first-run record in `ATX-30` two unrelated constants, a third wrong
2271
+ firing. Until it appeared, every false positive had been on the single name
2272
+ `code`, which left "pick a less generic param name" open as a repo-side
2273
+ workaround and therefore left the rule arguably salvageable. It is not the word:
2274
+ two requirements owning unrelated constants of the same *kind* is the structure,
2275
+ and any registry large enough to be worth linting has it. Still zero true
2276
+ positives, now across a 34-entry corpus (the `corpus` figure in the suite's
2277
+ snapshot, which counts a fixture id once per registry file that declares it).
2278
+ The escape hatch it was blocked on has since been designed and does not rescue
2279
+ it: with no config file the only workable shape is an additive
2280
+ `independentParams?: string[]` on the requirement (a CLI flag is per-invocation
2281
+ for what is a permanent property of two requirements; changing the `params`
2282
+ shape rejects every existing registry). Viable, but it is permanent schema
2283
+ surface for a rule with no demonstrated true positive, and in practice it would
2284
+ be written *after* the warning fires a one-line silencer. *Countable
2285
+ obligations (breaking) rejected.* An `obligations: string[]` per requirement,
2286
+ with coverage counted per obligation, removing the lumping incentive at the
2287
+ source instead of detecting it afterwards. Rejected because the **id is already
2288
+ that unit**: splitting into two ids costs two scenarios and buys the same
2289
+ incentive with no new concept (`ATX-11`/`ATX-12` are exactly that split). It
2290
+ would touch the parser, coverage, `render`, `SPEC.md`, delta apply and the
2291
+ `--json` schema, and it relocates the incentive rather than removing it —
2292
+ nothing can force the array to be complete. *Not considered:* similarity
2293
+ matching between statements (token overlap, embeddings). That reintroduces the
2294
+ fuzzy comparison design §0 exists to remove; real semantic duplication needs
2295
+ judgement and belongs to human review at the propose stage.
2296
+
2297
+ ### A freshness gate on the file `attest init` writes (`init --check`)
2298
+
2299
+ `render --check` is the obvious precedent and is the opposite case. A rendering
2300
+ is a function of the *user's registry*, so its staleness means the user moved
2301
+ intent without regenerating — a real signal, with the fix in their hands. The
2302
+ skill is a function of the *installed version*, so a gate on it turns red on a
2303
+ change the user did not make, and is cleared by a commit containing no decision.
2304
+ That is a chore wearing a diagnostic's clothes, and a gate that cannot be about
2305
+ anything the reader did is worse than no gate the same standard 0.1.6 set when
2306
+ it gave `render` no formatting options at all. An earlier draft of `attest init`
2307
+ spent a second command, this gate and a second self-requirement on it. What that
2308
+ would have bought is immunity to one honest and small exposure: an old file
2309
+ describes an old workflow to an agent that trusts it. The backstop is that every
2310
+ mistake it can cause is already a diagnostic with a fix hint — a registry
2311
+ written the old way is `registry-not-static` so the agent corrects itself from
2312
+ the report, and `init` is idempotent, which makes re-running it the entire
2313
+ update mechanism. Recorded here rather than left in the 0.3.0 entry that shipped
2314
+ `init`, because the queue above depends on it: a gate is the first thing
2315
+ proposed each time a new agent target is considered, and the answer must not
2316
+ need re-deciding per target.
2317
+
2318
+ ### A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`
2319
+
2320
+ The cross-tool convention Codex, Cursor and Copilot read, and the reason it
2321
+ looks like the obvious next target after the skill is that it reaches the most
2322
+ agents per unit of work. Rejected on three counts, none of which is about
2323
+ effort. **It is the only file `init` would write that Attest does not own.**
2324
+ `.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
2325
+ exists because the *project* does, and what is in it is prose its maintainer
2326
+ wrote and curated. Writing a block into it means an idempotent read-modify-write
2327
+ on someone else's document find the markers, preserve everything outside them,
2328
+ and then handle the markers being absent, duplicated, hand-edited, moved, the
2329
+ file missing, the file being `CLAUDE.md` instead, or both existing. That is a
2330
+ merge tool, and each of its failure modes is destructive on a file the user
2331
+ cannot regenerate. **It would remove the property that makes `init` safe to
2332
+ automate.** `skillContent()` takes no argument the content is a pure function
2333
+ of nothing at all — so `init` is one atomic write and its idempotence holds *by
2334
+ construction*, which is what lets "re-run it after upgrading" be the whole
2335
+ update mechanism and be safe in a script. A marked block makes the output a
2336
+ function of the file already there, demoting idempotence from a property of the
2337
+ design to a behaviour that needs test coverage, on the one path where being
2338
+ wrong destroys input. **It is a worse carrier for the same text.** An
2339
+ `AGENTS.md` block is unconditionally resident in the agent's context, where a
2340
+ skill body is loaded when its description matches so every unrelated request
2341
+ in that repo pays for a workflow it will not use. It was never a port of the
2342
+ skill; it is a second, weaker capability wearing the same words. The interaction
2343
+ with the entry above is what settles it: a user-edited block cannot be replaced
2344
+ on upgrade without discarding the edit, so this shape needs either the freshness
2345
+ gate rejected above or a three-way merge. *What replaces it*, for an adopter
2346
+ whose agent reads `AGENTS.md`: one line they add themselves, pointing at the
2347
+ skill file. A pointer is cheap enough to be resident, it is their edit in their
2348
+ file, and it cannot go stale the file it names is the one `init` keeps
2349
+ current.
2350
+
2351
+ ### A Codex target for `attest init`
2352
+
2353
+ Evaluated when `init` grew `--target` and shipped for Claude Code, Cursor and
2354
+ Copilot; rejected because the tool offers nothing to write, not because of what
2355
+ writing it would cost. Codex's project-scoped instruction surface is
2356
+ `AGENTS.md`, which the entry above rejects permanently, and its custom prompts
2357
+ live in `~/.codex/prompts` per machine, outside the project root `init` is
2358
+ pointed at, and not a thing a project can commit or a CI checkout can carry.
2359
+ That leaves no path that is both **Attest's own** and **read by the tool**, so
2360
+ the two rules a target has to satisfy are not merely unmet here, they are
2361
+ unmeetable with what exists: any Codex-shaped implementation would be either the
2362
+ rejected merge or a file nothing reads, and the second is worse, because it
2363
+ looks like coverage. Filed here rather than queued as a candidate, since nothing
2364
+ about the effort or the evidence is what is holding it the pointer line above
2365
+ is the whole answer for a Codex user, and this reopens only if Codex grows a
2366
+ project-scoped path of its own.
2367
+
2368
+ ### A committed snapshot of the params
2369
+
2370
+ Design §11 records the measurement this answers: editing a param (`'main'` →
2371
+ `'trunk'`, one entry dropped from a nine-element list) leaves `attest check` at
2372
+ `✓ No issues`, because the single source makes a value impossible to *diverge*,
2373
+ which is not the claim that it is flagged when it *moves*. The candidate was the
2374
+ shape `render --check` already proves a `params.lock` beside the registry, or
2375
+ `check --params-check` and the mechanism would have worked. *What rejected it:
2376
+ the precedent is the objection.* §9 states when a freshness gate earns its
2377
+ place, and a rendering earns it because it is a function of the *user's
2378
+ registry*, so staleness means the user moved intent without regenerating a
2379
+ real signal with the fix in their hands. A params snapshot is a function of the
2380
+ registry too, but the population it fires on is different: **every legitimate
2381
+ param edit turns it red, and the fix is a commit containing no decision.** That
2382
+ is precisely the chore §9 declined to ship for `init`, and it is worse here
2383
+ because of frequency — intent is rendered rarely, while params move whenever a
2384
+ value moves, which is the entire reason they are params. The gate would spend
2385
+ its whole life red about correct edits, and a gate that is usually wrong about
2386
+ staleness is the failure mode §9 names as worse than no gate. *What the gap
2387
+ keeps instead.* §6's mechanism 3 already exists to make this exact absence
2388
+ visible: `possible-drift` fires when a requirement owns params that no covering
2389
+ scenario reads, which is the only condition under which a moved param goes
2390
+ unnoticed. The mechanism that guards a param is a scenario reading it, not a
2391
+ file recording it. This reopens only on an adoption report where a param moved,
2392
+ no scenario caught it, and `possible-drift` was **not** already printing —
2393
+ because if it was printing, what failed was reading the report, and a second
2394
+ gate does not fix that.
2395
+
2396
+ ### Grouping `render`'s output by prefix
2397
+
2398
+ The *detection* half of this is already rejected above (every automatic
2399
+ candidate either saw almost nothing or was a similarity matcher, which is what
2400
+ §0 exists to remove), leaving human review at propose (§7) as the answer — and
2401
+ the idea was to help that review by putting requirements about one subject side
2402
+ by side in the one document a human reads end to end. *What rejected it: the
2403
+ ordering already ships, and the adjacency it buys is not where the gap is.*
2404
+ `compareIds` (`render.ts`) has ordered the document by prefix first and number
2405
+ second since `render` shipped, so same-prefix requirements are **already**
2406
+ adjacent; the candidate was never an ordering change, only a heading over an
2407
+ ordering that already holds. And §11's own example of the gap is `AUTH-3`
2408
+ expiring a session against `SESS-7` saying sessions never expire — a
2409
+ contradiction **across** prefixes, which prefix grouping pushes further apart
2410
+ rather than closer. What remains is a reader aid for same-prefix contradictions,
2411
+ which are the pairs already adjacent and therefore the ones review is least
2412
+ likely to miss, bought at the price of changing the bytes of every committed
2413
+ rendering and turning `render --check` red across every adopting repo on upgrade
2414
+ — for a change the user did not make. *The gap itself stays open* and stays
2415
+ where it belongs, in design §11: nothing resists a duplicated or contradictory
2416
+ requirement, and human review at propose is still the whole answer. What is
2417
+ rejected is grouping as a way to assist it, and this reopens only on a
2418
+ contradiction that grouping would have caught.
2419
+
2420
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.3...main
2421
+ [0.4.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.3
2422
+ [0.4.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.2
1666
2423
  [0.4.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.1
1667
2424
  [0.4.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.0
1668
2425
  [0.3.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.3.0