@am_shork/attest 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +876 -62
  2. package/README.md +157 -19
  3. package/dist/cli/index.js +91 -21
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/json.d.ts +52 -5
  6. package/dist/cli/json.d.ts.map +1 -1
  7. package/dist/cli/json.js +50 -2
  8. package/dist/cli/json.js.map +1 -1
  9. package/dist/cli/report.d.ts +11 -1
  10. package/dist/cli/report.d.ts.map +1 -1
  11. package/dist/cli/report.js +114 -3
  12. package/dist/cli/report.js.map +1 -1
  13. package/dist/core/apply.d.ts +8 -0
  14. package/dist/core/apply.d.ts.map +1 -1
  15. package/dist/core/apply.js +10 -0
  16. package/dist/core/apply.js.map +1 -1
  17. package/dist/core/gate.d.ts +27 -1
  18. package/dist/core/gate.d.ts.map +1 -1
  19. package/dist/core/gate.js +48 -1
  20. package/dist/core/gate.js.map +1 -1
  21. package/dist/core/loader.d.ts +8 -0
  22. package/dist/core/loader.d.ts.map +1 -1
  23. package/dist/core/loader.js +19 -1
  24. package/dist/core/loader.js.map +1 -1
  25. package/dist/core/locate.d.ts +22 -1
  26. package/dist/core/locate.d.ts.map +1 -1
  27. package/dist/core/locate.js +76 -7
  28. package/dist/core/locate.js.map +1 -1
  29. package/dist/core/paths.d.ts +15 -0
  30. package/dist/core/paths.d.ts.map +1 -0
  31. package/dist/core/paths.js +36 -0
  32. package/dist/core/paths.js.map +1 -0
  33. package/dist/core/pipeline.d.ts +75 -5
  34. package/dist/core/pipeline.d.ts.map +1 -1
  35. package/dist/core/pipeline.js +212 -35
  36. package/dist/core/pipeline.js.map +1 -1
  37. package/dist/core/red-record.d.ts +56 -0
  38. package/dist/core/red-record.d.ts.map +1 -0
  39. package/dist/core/red-record.js +177 -0
  40. package/dist/core/red-record.js.map +1 -0
  41. package/dist/core/render.d.ts +1 -1
  42. package/dist/core/render.d.ts.map +1 -1
  43. package/dist/core/render.js +16 -2
  44. package/dist/core/render.js.map +1 -1
  45. package/dist/core/runner.d.ts +1 -1
  46. package/dist/core/runner.d.ts.map +1 -1
  47. package/dist/core/runner.js +36 -11
  48. package/dist/core/runner.js.map +1 -1
  49. package/dist/core/skill.d.ts +23 -0
  50. package/dist/core/skill.d.ts.map +1 -0
  51. package/dist/core/skill.js +276 -0
  52. package/dist/core/skill.js.map +1 -0
  53. package/dist/core/static-registry.d.ts +28 -0
  54. package/dist/core/static-registry.d.ts.map +1 -1
  55. package/dist/core/static-registry.js +91 -25
  56. package/dist/core/static-registry.js.map +1 -1
  57. package/dist/core/status.d.ts +39 -0
  58. package/dist/core/status.d.ts.map +1 -0
  59. package/dist/core/status.js +64 -0
  60. package/dist/core/status.js.map +1 -0
  61. package/dist/core/targets.d.ts +52 -0
  62. package/dist/core/targets.d.ts.map +1 -0
  63. package/dist/core/targets.js +141 -0
  64. package/dist/core/targets.js.map +1 -0
  65. package/dist/core/types.d.ts +13 -0
  66. package/dist/core/types.d.ts.map +1 -1
  67. package/dist/core/types.js.map +1 -1
  68. package/dist/core/validator.d.ts +11 -0
  69. package/dist/core/validator.d.ts.map +1 -1
  70. package/dist/core/validator.js +28 -12
  71. package/dist/core/validator.js.map +1 -1
  72. package/dist/core/write.d.ts +2 -0
  73. package/dist/core/write.d.ts.map +1 -0
  74. package/dist/core/write.js +21 -0
  75. package/dist/core/write.js.map +1 -0
  76. package/dist/runtime.d.ts +9 -1
  77. package/dist/runtime.d.ts.map +1 -1
  78. package/dist/runtime.js +10 -2
  79. package/dist/runtime.js.map +1 -1
  80. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -13,7 +13,645 @@ 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
- ## [Unreleased]
16
+ ## [0.4.0] - 2026-07-28
17
+
18
+ Reading a registry no longer opens a port or lets the repository under test write
19
+ to the reader's terminal, the id prefix finally has something that notices two
20
+ files claiming it, and the peer range now says what CI actually proves rather
21
+ than what the tool was hoped to work on.
22
+
23
+ ### Added
24
+
25
+ - **Two registry files claiming one id prefix is now an ERROR.** The id grammar
26
+ is one flat space, `^[A-Z]+-\d+$`, and the prefix inside it was habit rather
27
+ than scope: nothing allocated it, reserved it, or noticed that two files had
28
+ picked the same one (design §11). Every cross-file check there was keys on the
29
+ whole id, so `AUTH-3` in `auth.reqs.ts` and `AUTH-9` in `session.reqs.ts` was
30
+ a clean `check` — two owners of one space, and the first anyone would hear of
31
+ it is the `duplicate-requirement` on the day the counters happen to meet, by
32
+ which point both files have a history. `loadRegistry` (`locate.ts`) already
33
+ held the `(file, id)` pair in the fold where `duplicate-requirement` is
34
+ raised, so the whole mechanism is a prefix→file map in that same loop, and
35
+ because it is that fold, every command that reads intent gets the diagnostic:
36
+ `check`, `cover`, `render`, `status`, `verify`, `archive`.
37
+ **What the issue carries.** `file` is the file that arrived *second* in sorted
38
+ order, matching `duplicate-requirement`, which also files against the file
39
+ being folded — and the file that claimed the prefix first is named in the
40
+ message, because a collision is a fact about a pair, an `Issue` carries one
41
+ path, and an error naming only one end leaves its reader grepping the
42
+ repository to find the other. There is **no `reqId`**: the ids involved are
43
+ individually valid and it is the space above them that has two owners, so
44
+ there is no single requirement the issue is about. It is raised **once per
45
+ colliding prefix per file**, not once per requirement — a registry with forty
46
+ ids under a taken prefix reports one sentence, not forty. Where a duplicate id
47
+ is also present both fire, which is not a restatement: sharing `A-1` implies
48
+ sharing `A-`, and the converse — the case this exists for — does not hold.
49
+ Now a requirement (ATX-39).
50
+ **Not a behaviour change for anything that passes today**, and that was
51
+ checked rather than assumed: all six `*.reqs.ts` in this repo sit in six
52
+ separate project roots holding one registry file each, so no root has two
53
+ files to collide. It is still a new ERROR on input that used to pass, so an
54
+ adopter whose registries share a prefix goes red on upgrade with nothing else
55
+ changing — deliberately, since that is the state the rule exists to report,
56
+ and the fix is a prefix rather than a migration. `SCHEMA_VERSION` stays 1: a
57
+ new `code` value is additive, and neither the envelope nor the `Issue`
58
+ interface moved.
59
+ The other half of the rule this was cut from — that a prefix must match its
60
+ file's name — was **rejected**; see "Considered and rejected".
61
+
62
+ ### Security
63
+
64
+ - **Reading a registry opened a network port.** The loader borrows Vite to
65
+ transform and evaluate a `*.reqs.ts`, and `middlewareMode` suppresses Vite's
66
+ HTTP server but not its HMR WebSocket server — so `verify`, `archive`, and any
67
+ read command under `--eval` bound port 24678 on *every* interface for the
68
+ length of the run, on developer machines and CI runners alike, for a channel
69
+ nothing here subscribes to. `server.ws` is now `false`, which is the option
70
+ that actually closes it: `hmr: false` does not, because the socket is created
71
+ before the HMR option is consulted. The visible symptom was two concurrent
72
+ runs colliding and printing `WebSocket server error: Port is already in use`
73
+ into the middle of a report, through a log level that was supposed to have
74
+ silenced the loader. Now a requirement (ATX-36), asserted as a count of
75
+ listening handles rather than a port number, because the port is Vite's to
76
+ change and the property Attest needs is that reading intent is not a network
77
+ operation.
78
+ - **The human report let the repository under test write to the reader's
79
+ terminal.** Scenario names and file paths are quoted verbatim into
80
+ `orphan-test`, `declared-not-run` and `never-red`, and the parser hands back
81
+ the *cooked* string — so an escape written in a spec file arrived at the
82
+ terminal as a real control byte. On a fork merge request the author of that
83
+ string is not the person reading the report, and the payload erases lines,
84
+ repaints a red verdict green, or retitles the window. Every control character
85
+ except the newline is now replaced with a space, in the message, the file
86
+ path, the code, and the ids and change name a `status` report prints. The
87
+ newline survives, because `registry-invalid` legitimately carries a list of
88
+ field errors — but the continuation is indented, so injected text cannot
89
+ occupy the column a genuine header does. `--json` was never affected
90
+ (serialising escapes the same characters), which is exactly why this had to be
91
+ fixed on the side people read. Now a requirement (ATX-37).
92
+ - **Dependency advisories, and a gate so the next ones are not found by hand.**
93
+ The dev toolchain moved to Vite 8 and Vitest 4, clearing a high-severity
94
+ `server.fs.deny` bypass on Windows alternate paths, an NTLMv2 hash disclosure
95
+ via UNC path handling, two moderate path-traversal/dev-server advisories, and
96
+ a critical Vitest UI advisory that this tool never reached but shipped in its
97
+ lockfile regardless. `brace-expansion` is pinned past its DoS advisory through
98
+ a `pnpm.overrides` entry, since it arrives only through ESLint. A new `audit`
99
+ CI job runs `pnpm audit --audit-level=high`, not `allow_failure`: it is the
100
+ one job here that can go red on a commit that changed nothing, which is the
101
+ point — a dependency becomes vulnerable on the day it is disclosed, not on the
102
+ day someone next edits `package.json`. This also closes a real coverage gap:
103
+ `peerDependencies` has claimed Vite 8 and Vitest 4 since 0.3.0 while CI only
104
+ ever ran Vite 5 and Vitest 2.
105
+
106
+ ### Fixed
107
+
108
+ - **`--eval` crashed on a registry the reading path diagnoses properly.** The
109
+ evaluating reader called a module's default export a registry because it was
110
+ an object — an assertion standing in for a check, on the one path where the
111
+ value has not already been through `defineRequirements`. A file that
112
+ default-exports a plain literal instead of calling it therefore arrived at the
113
+ validator unchecked, where reading `.statement` off a number threw: `--eval`
114
+ answered with an `internal-error` carrying a raw TypeError, for a file the
115
+ static reader reports with a code, a line and a fix hint — and that fix hint
116
+ is "pass `--eval`", so the documented way out of one diagnostic led into a
117
+ crash. The evaluating path now hands what it loaded to the same
118
+ `RegistrySchema`, so both readers refuse the same file. Behaviour change for
119
+ anyone branching on the code: this input used to produce `internal-error` and
120
+ now produces `registry-invalid`; both are ERRORs, so no exit code moves. It is
121
+ also a stricter read than before — a registry that `--eval` accepted only
122
+ because nothing looked at it is now rejected, and unknown fields are stripped
123
+ by the schema exactly as `defineRequirements` already strips them. Now a
124
+ requirement (ATX-38), kept apart from ATX-17: that one promises the escape
125
+ hatch exists and is honest about its cost, and a reader can satisfy it
126
+ completely while validating nothing it evaluates — which is the state this
127
+ was in.
128
+ - **A corrupt first-run record leaked a value that is not an outcome into the
129
+ report.** Reading `changes/<name>/first-run.json` asserted its contents rather
130
+ than checking them: the guard tested that `firstRun` was an object and was
131
+ *declared* to return the record type, so a hand-edited or half-written file
132
+ put an arbitrary string where `pass`/`fail` is the whole domain. It travelled
133
+ — out through `status`'s `progress[].scenarios[].firstRun`, whose declared
134
+ type is `Outcome | null`, into the `--json` contract, and into a human row
135
+ whose hint matched neither "passed first" nor "never run" and so printed
136
+ blank. The archive gate was never at risk, because `hasRecordedRed` compares
137
+ against `fail` and anything unrecognised blocks exactly like a missing record;
138
+ this closes the report, not the gate. A record that does not validate is now
139
+ discarded whole, which is the existing "no more permissive than a missing
140
+ file" rule one step further: the cost is a stage-1 run to re-observe, and
141
+ keeping the entries that happened to parse would present a partial history as
142
+ if it were the whole one. Behaviour change confined to corrupt files: that
143
+ `--json` field now reads `null` where it used to echo whatever was on disk.
144
+ - **The test verdict could fail open.** `runAndCollect` read the failure count
145
+ through `getCountOfFailedTests?.() ?? 0`, but the method is required on
146
+ Vitest's state — so the guard was dead at the type level, and had it ever
147
+ become live it defaulted the wrong way, reading a missing API as zero failures
148
+ and turning a red suite green. Latent rather than observed: no released
149
+ version reported a wrong verdict because of it. This is the one boolean in the
150
+ engine that may never fail open.
151
+ - **`archive` answered `internal-error` for a file `status` diagnoses
152
+ properly.** A `requirements.delta.ts` that loads but default-exports nothing
153
+ reached `applyDelta` as `undefined`, where reading `.renamed` off it threw a
154
+ TypeError — so the gate crashed out through the `--json` envelope's crash path
155
+ instead of reporting `change-not-found`, and the fix hint went with it.
156
+ `archive` now makes the same check `status` already made. Behaviour change for
157
+ anyone branching on the code: this input used to produce `internal-error` and
158
+ now produces `change-not-found`. Both are ERRORs, so no exit code moves.
159
+ - **A sibling change directory containing a backslash silently widened the
160
+ archive gate.** `escapeGlob` escaped every glob metacharacter except
161
+ glob's *own* escape character, so a name like `a\b` — legal on POSIX — did not
162
+ merely go unescaped: the backslash escaped the character after it, and
163
+ `**/changes/a\b/**` matched `ab` and never the directory. That proposal's
164
+ specs then joined the run of the one check that decides whether a change is
165
+ done. Covered by ATX-13, which already stated the property.
166
+ - **A self scenario that compiled a TypeScript program had no explicit
167
+ timeout**, so it fit inside Vitest's 5-second isolated-run default until the
168
+ toolchain moved and then failed as `tests-red` with no assertion behind it —
169
+ the failure mode the repo's other long scenarios already carry a timeout to
170
+ avoid.
171
+
172
+ ### Changed
173
+
174
+ - **BREAKING: the peer range is now `vite ^8` and `vitest ^4`, down from six
175
+ majors.** `^5 || ^6 || ^7 || ^8` and `^2 || ^3 || ^4` was a claim about six
176
+ combinations that no job ever ran end to end. What it looked like from inside
177
+ the repo — the security entry above says as much — was that the *top* was
178
+ untested; the fixture told a different story. `fixtures/consumer` pinned
179
+ `vitest ^2.0.0` / `vite ^5.4.0`, and the consumer job is the **only** thing in
180
+ this pipeline that resolves peers at all, so the two ends were each proven by
181
+ one job apiece and `vite ^6`/`^7` and `vitest ^3` were proven by nothing.
182
+ Advertising a version nobody runs is the same defect the `audit` job was added
183
+ to close, one layer out: the failure surfaces in an adopter's repository, not
184
+ here.
185
+ **Two of them had already stopped being merely untested.** `verify` reads
186
+ `startVitest` as `Promise<Vitest>` — the dead null guard removed above was
187
+ there for the older signature — and ATX-36 asserts a listening-handle count
188
+ against whichever Vite is installed, so on any other major the socket
189
+ guarantee is a claim rather than a measurement. Neither is a defect that would
190
+ announce itself; both fail in the direction of looking fine.
191
+ `fixtures/consumer` moves to `vitest ^4` / `vite ^8` with the range, because
192
+ npm refuses an unmet peer and the fixture exists to install the way a user
193
+ does. That does cost something real and it is worth naming: the consumer job
194
+ no longer exercises a second Vite/Vitest pair, so this trades a spot-check of
195
+ one old combination for a claim that matches the tested one. A matrix is what
196
+ buys the breadth back, and nothing here pretends this is that.
197
+ **Who this breaks:** anyone on Vite 5–7 or Vitest 2–3 fails to install on
198
+ upgrade, with no deprecation window. Under this file's Versioning rule that is
199
+ a minor, which is what 0.4.0 is. The engine may well still work on some of
200
+ them — that is precisely the claim there is no evidence for, and `0.3.x`
201
+ remains the version whose manifest makes it.
202
+
203
+ - **Type-aware linting, and `exactOptionalPropertyTypes`.** The three defects
204
+ above are one shape — an assertion standing in for a check — and the untyped
205
+ ESLint preset cannot see any of them, because deciding whether an `as` or a
206
+ `?.` is doing work needs the checker. `recommendedTypeChecked` now runs over
207
+ `src`, `tests` and `self`, with `no-unnecessary-condition` promoted out of the
208
+ strict preset; the rest of that preset is deliberately not adopted, since its
209
+ bulk is `no-non-null-assertion`, and here `!` is the counterpart of
210
+ `noUncheckedIndexedAccess` — banning it would trade a compiler-enforced
211
+ guarantee for a lint-enforced style. `fixtures/**` stays on the untyped preset
212
+ because several fixtures are invalid on purpose. Turning the rules on removed
213
+ a double cast that hid Vitest's task-tree shape from the compiler, a redundant
214
+ assertion in the delta reader that made an unvalidated value look checked, and
215
+ a dead `startVitest` null guard left over from an older Vitest signature.
216
+ `exactOptionalPropertyTypes` is now on as well: the codebase already followed
217
+ it by hand — the `...(x === undefined ? {} : { key: x })` idiom appears in
218
+ every Issue builder — so this makes an existing discipline the compiler's job
219
+ rather than review's. No `--json` shape changed, so `SCHEMA_VERSION` stays 1.
220
+
221
+ ## [0.3.0] - 2026-07-27
222
+
223
+ The workflow half now reaches the agent that will read it, the archive gate can
224
+ tell a test that ever discriminated from one that never could, and a change in
225
+ flight has a reportable state between "not started" and "done". Underneath all
226
+ three: every path Attest derives from a project root now has one spelling on
227
+ every platform, which is what makes them work on Windows at all.
228
+
229
+ ### Added
230
+
231
+ - **The workflow reached one agent, and every other agent got the engine without
232
+ it.** `attest init` wrote `.claude/skills/attest/SKILL.md` and nothing else —
233
+ the gap `init` exists to close, still open for every tool but one. It now takes
234
+ `--target`, repeatable, and writes the same document for Claude Code (the
235
+ default), Cursor (`.cursor/rules/attest.mdc`) and GitHub Copilot
236
+ (`.github/instructions/attest.instructions.md`).
237
+ **Two rules decide what may be a target, and the first was already permanent.**
238
+ *Attest owns the path*: each file above exists because Attest exists, is
239
+ written whole, and reads nothing of the user's — which is why an `AGENTS.md` or
240
+ `CLAUDE.md` marked block stays rejected outright (see "Considered and
241
+ rejected"), and why the answer for an agent that reads only such a file is a
242
+ one-line pointer the user writes themselves. *The instructions load on demand*
243
+ is the second, and it was the open question when this was queued: a skill body
244
+ is fetched when its description matches, a Cursor rule when the agent asks for
245
+ it (`alwaysApply: false`, and deliberately no `globs`, which is the auto-attach
246
+ trigger and a different thing), a Copilot instructions file when the open path
247
+ matches its `applyTo`. A carrier that is unconditionally resident charges every
248
+ unrelated request in the repository for a workflow it will not use.
249
+ **One document, three frontmatters, and that split is a requirement (ATX-34).**
250
+ Each target carries the same body byte for byte; only the frontmatter differs,
251
+ because that is the part the tool parses rather than the part the agent
252
+ follows. A copy per tool would be two agents told different things about one
253
+ engine — the drift this framework exists to report, occurring where nothing
254
+ reports it, since no command reads these files back. The requirement is written
255
+ over *every* target rather than over a list of them, so adding one cannot
256
+ satisfy it by being left out. What it costs falls on the targets with no
257
+ description-triggered mode: Copilot's trigger is the path, and `applyTo` names
258
+ the registries, the specs and `changes/`, so the instructions arrive when the
259
+ agent is editing intent and not when it is editing an implementation to make a
260
+ scenario pass — which is exactly when "do not edit the scenario instead" would
261
+ be worth reading. Widening that glob to `**` buys the missing case at the price
262
+ rule 2 declines to pay.
263
+ **Naming no target writes what it wrote before; an unknown name writes nothing
264
+ at all (ATX-35).** Writing every target by default would scatter configuration
265
+ for editors nobody in the project uses. Refusing the whole set on an
266
+ unrecognised name is the ATX-13 argument under a different name: a name already
267
+ known to be unusable must not cause a single write, or a failing run leaves a
268
+ repository carrying instructions for some agents and no statement of which.
269
+ Two requirements rather than one, for the ATX-23/ATX-24 reason — an
270
+ implementation that writes every known target on every run carries one document
271
+ faithfully, and one that gets the requested set right can still fail on an
272
+ unknown name after writing half of it. `unknown-target` is a new `code` on the
273
+ existing `Issue` shape.
274
+ **Codex was evaluated and is not a target**, because the tool offers no path
275
+ Attest can own: `AGENTS.md`, or prompts under `~/.codex/prompts`, outside the
276
+ project root entirely. Recorded under "Considered and rejected", where it stays
277
+ findable once this release is history.
278
+ **It shipped without the evidence the queued candidate demanded.** That
279
+ candidate named one line in one adoption report naming a tool as its trigger,
280
+ and no such line arrived: the three targets are a judgement about which tools
281
+ adopters use, and should be read as one. What limits the cost of being wrong is
282
+ that the guess is now priced per row — the document, the writer and the CLI are
283
+ general over the table, so a target is a path, a frontmatter and a scenario —
284
+ and that the two rules above are what keep the table from growing into a
285
+ distribution tail. The measurement behind that phrase, kept because it is the
286
+ argument for where the next effort goes: OpenSpec 1.6.0 publishes 25,733 lines
287
+ of `dist/` against this package's 2,513 lines of `src/`, roughly four fifths of
288
+ it distribution rather than engine (29 per-tool adapters, 1,169 lines of
289
+ `init.js` and `update.js`, three shell-completion installers) — while its engine
290
+ is the weaker half on every axis this project was built to win: a `verify` that
291
+ asks the agent for "reasonable inference" where §8's gate takes a set
292
+ difference against the Vitest task tree, a requirement primary key that is the
293
+ Markdown header text, and a Markdown reader carrying a visible repair history
294
+ that an AST does not pay. The gap was never engine quality; it was that
295
+ `openspec init` is one command and this was a paste. Twenty-nine adapters
296
+ against three targets is what remains of that comparison, and widening it needs
297
+ a named tool with a reader blocked on it, not a count of tools that exist.
298
+ *Additive:* `attest init` with no flags writes the same bytes to the same path
299
+ as before. `--json` gains `outFiles` — every file written, since a run can now
300
+ write several — and keeps `outFile` whenever exactly one was, which the default
301
+ invocation still is, so `schemaVersion` stays `1`. The only new exit-1 path is
302
+ reached through a flag that did not exist before this release.
303
+ Two measurements moved. `divergent-param` collected a **seventh** member on
304
+ `code` (ATX-35), still with zero true positives. And `compound-requirement`'s
305
+ reach passed its blind spot for the first time: the suite asserted
306
+ `missed.length >= correct.length`, and ATX-35 falsified it by arriving, like
307
+ ATX-33, with both obligations declared under two keywords. `missed` lost no
308
+ member — the rule sees no further, and what moved is how carefully the corpus
309
+ was written. Rewriting that assertion to fit would have been the measurement
310
+ bending to the corpus, so it now asserts what actually blocks the rule (the
311
+ blind spot never empties), with the movement in the inline snapshot.
312
+
313
+ - **A change had one touchpoint, and it only ever answered "not yet".**
314
+ `attest archive <change>` decides whether a change is done; between "not
315
+ started" and "archivable" there was no reportable state at all, so asking what
316
+ remained meant reading the delta, cross-referencing the specs and inferring —
317
+ work an agent redoes on every turn and a person does badly. `attest status
318
+ <change>` reports each requirement the change ADDs as one of three states:
319
+ `no-scenario`, `unproven` (a scenario exists but has never been seen failing)
320
+ or `proven`. It runs nothing, reading the delta, the specs and
321
+ `changes/<name>/first-run.json`.
322
+ **Its blocker was that there was nothing to report, and mechanism 2 removed
323
+ it.** This was queued under "Under consideration" with exactly that objection:
324
+ Attest has no artifact graph and should not grow one, so the only progress
325
+ signal worth printing is per-id — which added requirements have a scenario, and
326
+ whether that scenario has been red — and the red half did not exist. Shipping
327
+ mechanism 2 in this same release made `first-run.json` precisely that per-id
328
+ state, which reduced the entry to a presentation question. What decided it is
329
+ that the fork the entry named is now cheap on one side: a `status` that runs the
330
+ suite is `verify` with a filter, and the one that does not is a small file read
331
+ plus a static plan.
332
+ **It is a projection of the gate, not a second gate.** Each state is named
333
+ after the issue `archive` will raise — `no-scenario` is
334
+ `uncovered-requirement`, `unproven` is `never-red` — and the decision runs
335
+ through the gate's own predicate, extracted as `hasRecordedRed`, for the reason
336
+ `declaredNotRunIssues` and `uncoveredIssues` were extracted before it:
337
+ agreement between two verdicts should be structural, not clerical. The plan and
338
+ the added-id set come from the same two functions `archive` uses, so a progress
339
+ report cannot be computed over a different spec set than the gate's.
340
+ **Progress never reaches `ok`, and that is a requirement rather than a
341
+ detail.** The cheapest implementation is to push the existing `uncovered-` and
342
+ `never-red` issues into `issues[]` — which satisfies the reporting obligation
343
+ completely while putting them in `summary.error`, where the shared `hasError`
344
+ verdict turns them into `ok: false` and exit 1. That is the normal state of
345
+ every change in flight, so a CI step or an agent loop wrapping `status` would
346
+ read "failing" for the change's whole life. Split across two self-requirements
347
+ (ATX-32, ATX-33) for the same reason ATX-23/ATX-24 were: the wrong
348
+ implementation passes the first and needs a scenario, not a clause, to catch.
349
+ The one thing that does make it fail is intent it could not read — a missing or
350
+ unreadable delta — because a command that answered ok over an empty table would
351
+ be the vacuous green ATX-18 and ATX-26 each closed elsewhere.
352
+ **What it cannot answer is `tests-red`**, and the human rendering closes by
353
+ saying so (`Not a verdict: run attest archive <name>`). A column of green ticks
354
+ here means the readable obligations are met, and a report that can be mistaken
355
+ for a verdict is worse than no report.
356
+ **It reads the delta from source, so `status` joins ATX-16's static commands.**
357
+ That requirement's `staticCommands` param grows to four and its statement now
358
+ says "the intent they report on, whether a registry or a change delta" — the
359
+ registry extractor was generalised over the authoring function's name rather
360
+ than copied, so `delta({ … })` goes through the same one walker that decides
361
+ whether a value is fixed by the source text. A delta that is not a literal is
362
+ `registry-not-static` and a file that exports no delta is `change-not-found`:
363
+ both are codes `archive` already emits, so no consumer learns new vocabulary.
364
+ `--eval` is the same named way back it is everywhere else. The exposure this
365
+ closes is worse than the registry's, not better — `changes/` holds a proposal
366
+ still under review, and a reporting command is the last thing that should
367
+ execute it.
368
+ The design document carries it in the four places that were describing the old
369
+ set rather than only in §9: §2 and §5.1 name the reading commands, §7 states
370
+ for the first time that a delta is a literal on the same terms as a registry —
371
+ an authoring rule that existed the moment `status` read one — and §8 says which
372
+ of its own four checks the projection covers, and which one only the suite can
373
+ answer. §11's "Attest's own 29 requirements" was the kind of live count that
374
+ goes stale in silence, and is now phrased so it cannot. The README carried the
375
+ same three-command list in two places and an "all three commands" that had
376
+ become four, both now corrected, and it states the delta rule where it states
377
+ the registry one.
378
+ *Additive only:* a new `command` value and the new optional `progress` /
379
+ `added` fields, so `schemaVersion` stays `1`. No existing command's output
380
+ changes. The skill `attest init` writes gains the command, which re-running
381
+ `init` adopts.
382
+ One measurement moved with this change, recorded because it is evidence about a
383
+ *rejected* rule: `divergent-param` collected a **sixth** member on `code`
384
+ (ATX-33 joining ATX-18, ATX-21, ATX-25, ATX-26 and ATX-31), still with zero
385
+ true positives, and `compound-requirement`'s reach reached parity with its
386
+ blind spot for the first time (5 correct, 5 missed) — not because the rule
387
+ improved, since `missed` lost no member, but because ATX-33 was written with
388
+ both of its obligations declared. A rule whose score rises when the corpus is
389
+ written more carefully is measuring the authors, which is the objection to
390
+ shipping it, now stated by the measurement rather than in prose.
391
+
392
+ - **The archive gate certified that a change's tests pass, never that they ever
393
+ discriminated.** §6 describes three anti-drift mechanisms; mechanism 2 —
394
+ red/green expectation tracking — was written as shipped behaviour and had
395
+ never been built (0.2.0 corrected the document and named the gap). The gate
396
+ required green at archive time and nothing else, so a scenario that asserts
397
+ nothing has been green since the commit that added it and cleared the gate
398
+ exactly like one that drove an implementation. It ships now, and the anti-drift
399
+ budget is three mechanisms rather than two.
400
+ What made it buildable is that **the observation already existed and was being
401
+ thrown away**. The workflow has stage 1 run `attest archive <name>` and *expect*
402
+ `tests-red` — the skill `attest init` writes says so, and then says "nothing in
403
+ the engine will catch that later. This paragraph is the mechanism." A paragraph
404
+ asking a reader to be diligent is the thing this framework exists to replace,
405
+ and the engine had seen every one of those reds. `archive` now records how each
406
+ of a change's scenarios ended on its first run, into
407
+ `changes/<name>/first-run.json`, and blocks with a `never-red` ERROR on any
408
+ requirement the delta ADDs whose scenarios have no recorded failing run.
409
+ **A committed generated file, which this project otherwise treats as a
410
+ liability** — the argument against `init --check`, the reason `render` takes no
411
+ formatting options. The difference is lifetime: `changes/<name>/` is temporary
412
+ by construction and goes away when the change is archived, so this file has
413
+ exactly the change's lifetime and never becomes a permanent asset that can
414
+ drift. It is committed rather than kept in a scratch directory because the gate
415
+ has to reach the same verdict on a CI runner's fresh checkout as on the
416
+ author's machine — and because it is evidence, which belongs in the diff a
417
+ reviewer reads.
418
+ **The record is monotonic toward failure, and both halves of that are
419
+ load-bearing.** A recorded failure is permanent: by the time a change is
420
+ archivable everything is green, so a record that kept the latest outcome would
421
+ say "green" about a scenario that spent the whole change red. A recorded *pass*
422
+ is deliberately not permanent — plain first-write-wins was the first design
423
+ here, and it makes the defect this mechanism exists to catch unrecoverable,
424
+ since the fix for a scenario that asserts nothing is to give it a real
425
+ assertion and watch it fail, an observation that could then never be recorded.
426
+ The only way out would have been deleting the evidence by hand, which would
427
+ equally delete a genuine red. A later failure replacing an earlier pass cannot
428
+ be abused, because producing one requires the scenario to actually fail.
429
+ **Scoped to ADDED requirements only, and §11 now records what that leaves.** A
430
+ requirement written straight into the base registry describes behaviour that
431
+ already exists — the brownfield adoption, and most of this repo — so there was
432
+ never a moment when its scenario should have failed; blocking those would make
433
+ the mechanism fire hardest on the honest case. The guarantee is therefore a
434
+ property of the `changes/` workflow, not of the registry as a whole.
435
+ *Behaviour change, for a project mid-change:* a change whose scenarios were
436
+ never observed failing flips from archivable to blocked, which is the point,
437
+ since nothing had shown its tests could fail. The remedy is one gate run before
438
+ implementing. `never-red` is a new `code` on the existing `Issue` shape, and
439
+ `--json` gains no field, so `schemaVersion` stays `1`. Two self-requirements
440
+ (ATX-30, ATX-31) rather than one: a recorder that writes a perfect history and
441
+ never blocks satisfies the first completely while leaving the gate exactly as
442
+ weak as it was, and a gate can equally block on an absent record without
443
+ recording anything.
444
+ The rejected `divergent-param` rule gained a **fifth** member on `code` and,
445
+ for the first time, a collision on a **second** param name (`file`, ATX-27's
446
+ skill path against ATX-30's record path) — see "Considered and rejected", where
447
+ that second name is what closes off "pick a less generic name" as a workaround.
448
+
449
+ - **`attest init` — the workflow half of the framework had no entry point.**
450
+ Everything the engine does has been reachable from the CLI since 0.1.0. The
451
+ workflow — agree on intent, write the delta, drive the scenarios red, implement
452
+ to green — was a document (`docs/*/using-attest.prompt.md`) the user had to
453
+ find and paste into their agent themselves. Everything downstream of that paste
454
+ worked; nothing led to it. `attest init` writes one file,
455
+ `.claude/skills/attest/SKILL.md`, carrying that workflow as a **skill** — its
456
+ description sits in the agent's context, so the agent loads it when the work
457
+ starts rather than waiting to be handed something. That is the difference that
458
+ closes the gap: a file the user must remember to invoke would only replace
459
+ "find the document" with "know what to type". `init` is idempotent — re-run it
460
+ after upgrading, which is the entire update mechanism.
461
+
462
+ **The freshness contract, which the queued entry said had to be designed rather
463
+ than assumed: there isn't one, and that is the design.** A committed
464
+ instruction file is a function of the installed version, so it does go stale on
465
+ upgrade — but staleness only *costs* anything if the tool also ships a gate to
466
+ turn red about it, and `init --check` is rejected on its own terms under
467
+ "Considered and rejected", together with the exposure that leaves and the
468
+ backstop that keeps it small. What belongs here is what the absence of a gate
469
+ bought: with none to serve there is no reason to keep the prose out of the file
470
+ either, so it lives in the skill, where an agent reads it with no extra call,
471
+ and a skill body is loaded on demand — a long document costs context only when
472
+ it is actually needed.
473
+
474
+ **`init` deliberately scaffolds nothing else:** no `requirements/*.reqs.ts`, no
475
+ `package.json` scripts, no `changes/` skeleton. A generated registry can only
476
+ be empty — which since 0.2.0 is an `empty-spec` ERROR handed to the user on day
477
+ one — or invented, and invented intent is worse than none in the one layer
478
+ whose value is that it is real. What belongs there is the agent's judgement,
479
+ with the project in front of it, which is what the skill describes.
480
+ **This is the one thing Attest does not dogfood, and saying otherwise would
481
+ have been the wrong kind of reassurance.** The skill exists for a project
482
+ *adopting* Attest — an agent meeting a registry it did not write, needing to be
483
+ told what a delta is and why its scenarios must be red. This repo is the
484
+ framework, not an adopter: its requirements describe behaviour that already
485
+ exists and go straight into the base registry, so it does not run the
486
+ `changes/` workflow the skill teaches, and a committed copy here would be a
487
+ file nothing reads. What guards the text instead is `tests/skill.spec.ts`,
488
+ which asserts the shape an agent depends on — that it parses as a skill, that
489
+ the description names `*.reqs.ts` so it loads at the right moment, and that the
490
+ body still teaches the codes a reader will actually hit. That is a weaker
491
+ guarantee than dogfooding and is named as such: nothing here proves the
492
+ workflow is followable, only that the file describing it is well-formed.
493
+ **One correction to the queued entry:** it predicted a `files` allowlist change
494
+ and therefore a `pnpm run test:consumer` obligation. There is none — the text
495
+ is a TypeScript module compiled into `dist/`, not a template file, so the
496
+ published surface is unchanged. `--json` gains `init` as a `command` value and
497
+ widens the existing `outFile` field to it; both are additive, so
498
+ `schemaVersion` stays `1`.
499
+
500
+ - **`scenario` accepts a timeout.** `scenario(name, fn, timeoutMs)`, passed
501
+ straight through to Vitest's `it`. `verify` runs the suite isolated (design
502
+ §5.4) — no `vitest.config.ts` unless `--vitest-config` names one — so every
503
+ scenario got Vitest's 5-second default and a scenario that drives a
504
+ subprocess, a container or a build had no way to say otherwise. Attest's own
505
+ suite is the demonstration: two `self/` scenarios each start four child Vitest
506
+ runs, and they fit inside 5 seconds on Linux and not on Windows. The
507
+ alternatives are both worse — raising the default for everyone hides a
508
+ genuinely hung test, and a config file sets one number for what is a property
509
+ of one scenario — the same shape of objection that rules a CLI flag out as
510
+ `divergent-param`'s escape hatch under "Considered and rejected", where a
511
+ per-invocation switch is asked to carry a permanent property of the source.
512
+ Additive: existing two-argument calls are unchanged. Deliberately **no
513
+ self-requirement**: the contract here is Vitest's
514
+ own signature and this is one argument forwarded to it, and the only scenario
515
+ that would truly discriminate — one that outlives the default — would spend
516
+ five seconds of every run proving a number Attest does not own. Its absence is
517
+ observable anyway, as the two `self/` scenarios above.
518
+
519
+ ### Changed
520
+
521
+ - **BREAKING: `cover` reported a registry it could not read as a registry with
522
+ nothing in it — and nothing is fully covered.** It was the one reader that
523
+ discarded the issues from loading the registry (`runCheck` and `runRender`
524
+ both keep them), so a `registry-not-static` file, a renamed `requirements/`
525
+ folder, or a moved root left an empty registry behind, and an empty registry
526
+ has no uncovered requirement in it. The command answered `✓`, `ok: true`,
527
+ `0 requirements, 0 uncovered`, exit `0` — on precisely the repo whose intent
528
+ layer had stopped being readable, and in the command a pipeline is most likely
529
+ to run first, because it neither runs the suite nor executes project code.
530
+ `attest check` on the same project reported the ERROR correctly, which is what
531
+ made the gap survivable and also what made it invisible: the two commands
532
+ disagreed about whether the project was in a reportable state at all.
533
+ `cover` now carries the load issues, so the verdict follows `hasError` like
534
+ every other command, and it emits **no rows at all** when the registry failed
535
+ to load. The second half is deliberate and is the reason this is one change
536
+ rather than a one-line fix: reporting the error while still printing a table
537
+ built from whichever registry files did parse would let a partial read pass
538
+ for the whole. `render` already refuses to emit a document under the same
539
+ condition, for the same reason — a report computed from a half-loaded registry
540
+ is a lie, and a lie shaped like a pass is the worst form it can take.
541
+ *Breaking:* a project whose registry does not read flips from exit `0` to exit
542
+ `1` — which is the point, since it was never coverable. The human output for
543
+ that project changes from `(the registry contains no requirements)` to the
544
+ load error itself; every project whose registry does read is byte-identical.
545
+ `--json` gains no field (`issues` and `summary` already exist), so
546
+ `schemaVersion` stays `1`.
547
+
548
+ ### Removed
549
+
550
+ - **`docs/en/using-attest.prompt.md` and `docs/zh/using-attest.prompt.md`.** The
551
+ workflow half of these is what `attest init` now writes into the project; the
552
+ adoption half is the README's Getting started, which npm publishes regardless
553
+ of the `files` allowlist and so reaches a reader who has installed nothing.
554
+ Deleted rather than left as a page pointing at either, because a stub is still
555
+ a file two languages have to keep in sync, and the delivery problem was never
556
+ the prompt's wording — it was that the prompt lived in the docs directory of a
557
+ repository the reader may never have cloned. Nothing in the package linked to
558
+ them. Documentation only: no package surface, and no `files` change (they were
559
+ never in the tarball).
560
+
561
+ ### Fixed
562
+
563
+ - **A spec file below the project root never ran on Windows.** `verify` and
564
+ `archive` derive the child run's `include` globs from the plan's file paths
565
+ (0.2.0), and those paths came from `path.relative` — so on Windows they were
566
+ spelled `deep\er\x.spec.ts`. A backslash is an **escape character** in glob
567
+ syntax, so the pattern matched nothing and the file was dropped from the run.
568
+ The scenarios in it then surfaced as `declared-not-run`, which is at least
569
+ loud rather than a false green, but the practical effect was that only a flat
570
+ layout worked at all — and a flat layout is not what a real project has.
571
+ The reason it survived this long is a sampling accident: every fixture in this
572
+ repo keeps its specs at the project root, and so does `self/`, so the relative
573
+ paths had no separator in them. The single exception —
574
+ `fixtures/e2e-archive/changes/add-totp/specs/totp.spec.ts` — was the one
575
+ failing test, which is why this read as "`archive` is broken on Windows"
576
+ rather than as what it was.
577
+ Every path derived from the project root now has one spelling (`/`) on every
578
+ platform, normalised once where it is produced rather than where it is
579
+ consumed, because the same relative path is also what a report carries: a
580
+ diagnostic that differs by operating system makes two CI runs impossible to
581
+ diff. The conversion is conditional on the host separator, never
582
+ unconditional — a backslash is a legal filename character on POSIX, and
583
+ rewriting it there would rename a file that exists.
584
+ *For anyone parsing `--json` on Windows:* `issues[].file` changes from
585
+ `deep\er\x.spec.ts` to `deep/er/x.spec.ts`. No exit code changes except the
586
+ ones that were wrong; nothing previously valid is rejected; no field is
587
+ removed. `schemaVersion` stays `1`. New fixture `fixtures/nested-spec/`, whose
588
+ only spec is two directories down, is the end-to-end guard (ATX-28).
589
+ - **`render --check` was permanently red on a Windows checkout.** `render`
590
+ writes `\n`; Git hands the file back as `\r\n` wherever `core.autocrlf` is
591
+ enabled, which is the installer default on Windows. The freshness check
592
+ compared bytes, so a fresh clone reported `stale-spec-doc` against a registry
593
+ nobody had touched, and the fix hint named a command that regenerates a file
594
+ which is already correct. Any adopter committing a rendering hit this the
595
+ first time a second machine — or a CI runner — checked the repo out.
596
+ The check now compares the *document*: a difference of line endings alone is
597
+ not staleness. This is the same standard 0.1.6 set when it gave `render` no
598
+ formatting options at all — a gate that can be wrong about staleness is worse
599
+ than no gate — and it costs nothing, because no carriage return in that file
600
+ ever came from the registry. Content drift is still reported, CRLF or not
601
+ (ATX-29). This repo also gained a `.gitattributes` pinning the working tree to
602
+ LF, so a regeneration is not a whole-file diff on one platform and no diff on
603
+ the other.
604
+ - **Three of this repo's own tests could not pass on Windows, and one could not
605
+ fail.** `tests/locate.spec.ts` took basenames with `split('/')`, which returns
606
+ the whole path when the separator is a backslash.
607
+ `tests/docs-consistency.spec.ts` filtered source files with
608
+ `path.startsWith(`${root}src/`)`, splicing a forward slash onto a native root
609
+ — it matched nothing, so the check that every `§n` citation resolves was
610
+ passing on an empty set. `self/registry.spec.ts` compared a native path
611
+ against `ts.Diagnostic.file.fileName`, which TypeScript normalises to forward
612
+ slashes, so the type-level assertion measured zero diagnostics. Same class of
613
+ bug as the two above, in the tests rather than the engine.
614
+ `tests/loader.spec.ts` was separately fragile on every platform: it counted
615
+ `attest-loader-*` directories in the OS temp dir, making its verdict a
616
+ function of every other loader alive at that moment. It now asserts against
617
+ the loader's own `scratchDir`, and a third case pins that concurrent loaders
618
+ do not share one.
619
+
620
+ - **`cover --json` could answer `ok: false` with an empty `issues` array.** Three
621
+ places state one rule for machine consumers — `src/cli/json.ts`'s header, the
622
+ README, and 0.1.2's entry below — and all three say the same thing: branch on
623
+ `ok` and `issues[].code`, never on `message`. `cover` was the one command that
624
+ broke it. Its verdict was computed from the coverage rows
625
+ (`covered === rows.length`) while its envelope carried no issue at all, so a
626
+ consumer that followed the documented rule got a failing verdict, a summary of
627
+ three zeroes, and nothing saying what to fix; recovering the reason meant
628
+ knowing to read `coverage[]` instead — the out-of-band knowledge the contract
629
+ exists to make unnecessary. An uncovered requirement now reaches the report
630
+ twice, as a row and as an `uncovered-requirement` ERROR.
631
+ **The issue comes from the function `check` already uses.** `uncoveredIssues`
632
+ is extracted from `validateStructure` and called by both, so the two commands
633
+ cannot state one fact in two shapes — the same reason `declaredNotRunIssues`
634
+ was extracted in 0.1.7, and the same argument: agreement between two verdicts
635
+ should be structural, not clerical. `cover`'s `ok` now derives from the shared
636
+ `hasError` predicate like every other command, rather than from a second rule
637
+ that happened to agree with it.
638
+ *Behaviour change, for anyone already parsing `cover --json`:* a report on a
639
+ registry with uncovered requirements gains `issues[]` entries and a non-zero
640
+ `summary.error` where both were empty. `ok` and the exit code are unchanged for
641
+ every input — an uncovered requirement is the only issue this command produces,
642
+ so the new predicate returns the boolean the old one did. The human output is
643
+ also unchanged: the coverage table already renders each uncovered requirement
644
+ as a red `✗` row, and printing the same fact again underneath it would be noise
645
+ for the one reader who can see it. No shape change either — `issues` and
646
+ `summary` are existing envelope fields — so `schemaVersion` stays `1`.
647
+ The rejected `divergent-param` rule grew a **fourth** member on this change
648
+ (`code`, shared at that point by ATX-17, ATX-18, ATX-21 and ATX-25), which is
649
+ the measurement its entry under "Considered and rejected" predicted: a generic
650
+ param name collides more often as the registry grows, so the rule's
651
+ false-positive rate rises with adoption. Still zero true positives. ATX-26,
652
+ later in this same release, then pushed ATX-17 *out* of the set by naming the
653
+ same diagnostic — the sharper version of the same point, recorded in full
654
+ under "Considered and rejected" rather than restated here.
17
655
 
18
656
  ## [0.2.2] - 2026-07-26
19
657
 
@@ -361,66 +999,6 @@ that identifies itself as attest output must carry the current `version` and
361
999
  `schemaVersion`. Neither can catch a section that describes something unbuilt —
362
1000
  that is what the inline marker is for.
363
1001
 
364
- ## Considered and rejected
365
-
366
- Decisions **not** to build something, kept where they can be found before the
367
- same candidate is proposed again. Nothing here shipped, so nothing here belongs
368
- to a release — this section is deliberately outside the version history and does
369
- not move when one is cut.
370
-
371
- - **Two gaps in the intent layer: nothing resists a bloated requirement, and
372
- nothing resists a duplicated one.** The gaps themselves are described in design
373
- §11, which is where a permanent property of the design belongs. What is kept
374
- here is the list of mechanisms tried and what killed each, so the same three
375
- are not proposed again without new evidence. All three were scored against
376
- every registry this repo ships *before* any was written into the engine — the
377
- order `divergent-param` was originally designed in the wrong way round. The
378
- measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
379
- when a requirement is added without a hand-labelled obligation count, because
380
- the reach figure once quoted here went stale exactly that way.
381
- *`compound-requirement` (WARNING) — rejected.* More than one RFC-2119 keyword
382
- in one `statement` is objectively more than one obligation. Measured: of the 7
383
- requirements in the corpus carrying more than one, it flags 2 (`ATX-10`,
384
- `ATX-21`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17` — every
385
- one that packs clauses under a single keyword, which is the form the coverage
386
- incentive actually rewards. A wider variant (keyword plus a coordinating
387
- `and`/`while`/`;`) reaches 6 of 7 with one wrong hit, so reach is not the
388
- deciding argument.
389
- This is: both variants are silenced by deleting the word that triggered them,
390
- and neither can tell that deletion from a real split. `SHALL do A and MUST do
391
- B` clears the warning as `SHALL do A and do B` — same two obligations, one
392
- fewer normative keyword. A rule whose cheapest fix degrades the artifact it
393
- protects does not ship.
394
- *`divergent-param` (WARNING) — rejected.* One param name declared with
395
- different values in two requirements sharing an id prefix. Measured: it fires
396
- twice on this repo and both firings are wrong — `idleTimeoutMin` (`15` in
397
- `ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
398
- (`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
399
- `rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
400
- agree. The second arrived on its own when `ATX-18` was added and grew a third
401
- member on its own again when `ATX-21` was, which is the more damning half: a
402
- generic param name collides more often as a registry grows, so the
403
- false-positive rate rises with adoption — the opposite of what a shippable rule
404
- does. Still zero true positives across 25 requirements. The escape hatch it was blocked on has since been designed
405
- and does not rescue it: with no config file the only workable shape is an
406
- additive `independentParams?: string[]` on the requirement (a CLI flag is
407
- per-invocation for what is a permanent property of two requirements; changing
408
- the `params` shape rejects every existing registry). Viable, but it is
409
- permanent schema surface for a rule with no demonstrated true positive, and in
410
- practice it would be written *after* the warning fires — a one-line silencer.
411
- *Countable obligations (breaking) — rejected.* An `obligations: string[]` per
412
- requirement, with coverage counted per obligation, removing the lumping
413
- incentive at the source instead of detecting it afterwards. Rejected because
414
- the **id is already that unit**: splitting into two ids costs two scenarios and
415
- buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
416
- split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
417
- and the `--json` schema, and it relocates the incentive rather than removing
418
- it — nothing can force the array to be complete.
419
- *Not considered:* similarity matching between statements (token overlap,
420
- embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
421
- real semantic duplication needs judgement and belongs to human review at the
422
- propose stage.
423
-
424
1002
  ## [0.1.7] - 2026-07-26
425
1003
 
426
1004
  A change name, a prototype key and a collation quirk each got to decide a
@@ -741,7 +1319,243 @@ Initial release.
741
1319
  (MIT), whose four-stage engine and diff-first change model Attest's
742
1320
  architecture is adapted from (re-implemented from scratch, no source copied).
743
1321
 
744
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.2.2...main
1322
+ ## Considered and rejected
1323
+
1324
+ Decisions **not** to build something, kept where they can be found before the
1325
+ same candidate is proposed again. Nothing here shipped, so nothing here belongs
1326
+ to a release — this section is deliberately outside the version history and does
1327
+ not move when one is cut. It lives at the **foot** of the file for that reason:
1328
+ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
1329
+ sinking one version deeper each time a release was cut above it, and a rejection
1330
+ filed under a version reads as belonging to it.
1331
+
1332
+ - **A requirement's id prefix must match its registry file's name.** The other
1333
+ half of the rule that shipped as `duplicate-prefix` (see `[Unreleased]`), and
1334
+ the half that does not pay for itself. What killed it is that the corpus
1335
+ already conforms: every fixture registry matches (`auth.reqs.ts` → `AUTH-`,
1336
+ `core.reqs.ts` → `CORE-`), so the rule would detect nothing anywhere it was
1337
+ measured except one file — `self/requirements/attest.reqs.ts`, which holds
1338
+ `ATX-*`. Its first act would therefore be to rename this repo's own registry
1339
+ and every `self/*.spec.ts` that imports it, and its first act in an adopter's
1340
+ repository would be the same thing to theirs. That is rejecting input valid
1341
+ today, a minor bump under this file's Versioning rule, in exchange for making
1342
+ the prefix *guessable from the path* — which matters only to a reader already
1343
+ looking at the file, and who could have read the ids in it. The collision rule
1344
+ delivers what the gap is actually about, which is two owners of one space, and
1345
+ it delivers it without a rename because it fires on a relationship between
1346
+ files rather than on a spelling. Reconsider only with evidence that a reader or
1347
+ a tool needs the prefix before opening the file — a naming *convention* stays
1348
+ the right shape for this, and the convention is what `init` and the docs
1349
+ already teach.
1350
+ What is not a reason to revisit it: the objection that stalled the collision
1351
+ rule while both were queued together — that it guards a convention nothing had
1352
+ yet violated — was answered by shipping it, since a rule that costs no
1353
+ migration and no concept is worth having before the first collision rather
1354
+ than after it. That argument does not transfer here, because this rule's cost
1355
+ is a migration by construction.
1356
+
1357
+ - **Two gaps in the intent layer: nothing resists a bloated requirement, and
1358
+ nothing resists a duplicated one.** The gaps themselves are described in design
1359
+ §11, which is where a permanent property of the design belongs. What is kept
1360
+ here is the list of mechanisms tried and what killed each, so the same three
1361
+ are not proposed again without new evidence. All three were scored against
1362
+ every registry this repo ships *before* any was written into the engine — the
1363
+ order `divergent-param` was originally designed in the wrong way round. The
1364
+ measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
1365
+ when a requirement is added without a hand-labelled obligation count, because
1366
+ the reach figure once quoted here went stale exactly that way. The guard makes
1367
+ the *suite* re-count; it cannot make this file re-read, so every figure below
1368
+ is a quotation of that snapshot and is only ever as fresh as the last edit
1369
+ here — read the snapshot when the number is what matters.
1370
+ *`compound-requirement` (WARNING) — rejected.* More than one RFC-2119 keyword
1371
+ in one `statement` is objectively more than one obligation. Measured (figures
1372
+ re-read from the suite, which is the only place they are not prose): of the 8
1373
+ requirements in the corpus carrying more than one, it flags 3 (`ATX-10`,
1374
+ `ATX-21`, `ATX-26`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17`
1375
+ — every one that packs clauses under a single keyword, which is the form the
1376
+ coverage incentive actually rewards. A wider variant (keyword plus a
1377
+ coordinating `and`/`while`/`;`) reaches 7 of 8, at 3 wrong hits (`ATX-6`,
1378
+ `ATX-22`, `ATX-23`) rather than the 1 recorded when it was first scored — so
1379
+ reach is not the deciding argument, and the precision it trades away grows
1380
+ with the registry.
1381
+ This is: both variants are silenced by deleting the word that triggered them,
1382
+ and neither can tell that deletion from a real split. `SHALL do A and MUST do
1383
+ B` clears the warning as `SHALL do A and do B` — same two obligations, one
1384
+ fewer normative keyword. A rule whose cheapest fix degrades the artifact it
1385
+ protects does not ship.
1386
+ *`divergent-param` (WARNING) — rejected.* One param name declared with
1387
+ different values in two requirements sharing an id prefix. Measured: it fires
1388
+ twice on this repo and both firings are wrong — `idleTimeoutMin` (`15` in
1389
+ `ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
1390
+ (`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
1391
+ `rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
1392
+ agree. The second arrived on its own when `ATX-18` was added and grew a third
1393
+ member on its own again when `ATX-21` was, then `ATX-25`, then `ATX-26`, which
1394
+ is the more damning half: a generic param name collides more often as a
1395
+ registry grows, so the false-positive rate rises with adoption — the opposite
1396
+ of what a shippable rule does. `ATX-26` added the sharper version of the same
1397
+ point: it names the *same* diagnostic as `ATX-17`, so the two now agree by
1398
+ coincidence and `ATX-17` drops out of the set the rule reports — a rule that
1399
+ reads agreement as safety falls silent on a pair it had been flagging, for a
1400
+ reason that has nothing to do with either requirement.
1401
+ **A second param name now collides, and that is what closes the last defence
1402
+ of the rule.** `file` names a path Attest writes — the agent skill in `ATX-27`,
1403
+ a change's first-run record in `ATX-30` — two unrelated constants, a third
1404
+ wrong firing. Until it appeared, every false positive had been on the single
1405
+ name `code`, which left "pick a less generic param name" open as a repo-side
1406
+ workaround and therefore left the rule arguably salvageable. It is not the
1407
+ word: two requirements owning unrelated constants of the same *kind* is the
1408
+ structure, and any registry large enough to be worth linting has it.
1409
+ Still zero true positives, now across a 34-entry corpus (the `corpus` figure in
1410
+ the suite's snapshot, which counts a fixture id once per registry file that
1411
+ declares it).
1412
+ The escape hatch it was blocked on has since been designed
1413
+ and does not rescue it: with no config file the only workable shape is an
1414
+ additive `independentParams?: string[]` on the requirement (a CLI flag is
1415
+ per-invocation for what is a permanent property of two requirements; changing
1416
+ the `params` shape rejects every existing registry). Viable, but it is
1417
+ permanent schema surface for a rule with no demonstrated true positive, and in
1418
+ practice it would be written *after* the warning fires — a one-line silencer.
1419
+ *Countable obligations (breaking) — rejected.* An `obligations: string[]` per
1420
+ requirement, with coverage counted per obligation, removing the lumping
1421
+ incentive at the source instead of detecting it afterwards. Rejected because
1422
+ the **id is already that unit**: splitting into two ids costs two scenarios and
1423
+ buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
1424
+ split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
1425
+ and the `--json` schema, and it relocates the incentive rather than removing
1426
+ it — nothing can force the array to be complete.
1427
+ *Not considered:* similarity matching between statements (token overlap,
1428
+ embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
1429
+ real semantic duplication needs judgement and belongs to human review at the
1430
+ propose stage.
1431
+
1432
+ - **A freshness gate on the file `attest init` writes (`init --check`).**
1433
+ `render --check` is the obvious precedent and is the opposite case. A rendering
1434
+ is a function of the *user's registry*, so its staleness means the user moved
1435
+ intent without regenerating — a real signal, with the fix in their hands. The
1436
+ skill is a function of the *installed version*, so a gate on it turns red on a
1437
+ change the user did not make, and is cleared by a commit containing no
1438
+ decision. That is a chore wearing a diagnostic's clothes, and a gate that
1439
+ cannot be about anything the reader did is worse than no gate — the same
1440
+ standard 0.1.6 set when it gave `render` no formatting options at all.
1441
+ An earlier draft of `attest init` spent a second command, this gate and a
1442
+ second self-requirement on it. What that would have bought is immunity to one
1443
+ honest and small exposure: an old file describes an old workflow to an agent
1444
+ that trusts it. The backstop is that every mistake it can cause is already a
1445
+ diagnostic with a fix hint — a registry written the old way is
1446
+ `registry-not-static` — so the agent corrects itself from the report, and
1447
+ `init` is idempotent, which makes re-running it the entire update mechanism.
1448
+ Recorded here rather than left in the 0.3.0 entry that shipped `init`, because
1449
+ the queue above depends on it: a gate is the first thing proposed each time a
1450
+ new agent target is considered, and the answer must not need re-deciding per
1451
+ target.
1452
+
1453
+ - **A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`.** The cross-tool
1454
+ convention Codex, Cursor and Copilot read, and the reason it looks like the
1455
+ obvious next target after the skill is that it reaches the most agents per unit
1456
+ of work. Rejected on three counts, none of which is about effort.
1457
+ **It is the only file `init` would write that Attest does not own.**
1458
+ `.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
1459
+ exists because the *project* does, and what is in it is prose its maintainer
1460
+ wrote and curated. Writing a block into it means an idempotent
1461
+ read-modify-write on someone else's document — find the markers, preserve
1462
+ everything outside them, and then handle the markers being absent, duplicated,
1463
+ hand-edited, moved, the file missing, the file being `CLAUDE.md` instead, or
1464
+ both existing. That is a merge tool, and each of its failure modes is
1465
+ destructive on a file the user cannot regenerate.
1466
+ **It would remove the property that makes `init` safe to automate.**
1467
+ `skillContent()` takes no argument — the content is a pure function of nothing
1468
+ at all — so `init` is one atomic write and its idempotence holds *by
1469
+ construction*, which is what lets "re-run it after upgrading" be the whole
1470
+ update mechanism and be safe in a script. A marked block makes the output a
1471
+ function of the file already there, demoting idempotence from a property of the
1472
+ design to a behaviour that needs test coverage, on the one path where being
1473
+ wrong destroys input.
1474
+ **It is a worse carrier for the same text.** An `AGENTS.md` block is
1475
+ unconditionally resident in the agent's context, where a skill body is loaded
1476
+ when its description matches — so every unrelated request in that repo pays for
1477
+ a workflow it will not use. It was never a port of the skill; it is a second,
1478
+ weaker capability wearing the same words.
1479
+ The interaction with the entry above is what settles it: a user-edited block
1480
+ cannot be replaced on upgrade without discarding the edit, so this shape needs
1481
+ either the freshness gate rejected above or a three-way merge. *What replaces
1482
+ it*, for an adopter whose agent reads `AGENTS.md`: one line they add
1483
+ themselves, pointing at the skill file. A pointer is cheap enough to be
1484
+ resident, it is their edit in their file, and it cannot go stale — the file it
1485
+ names is the one `init` keeps current.
1486
+
1487
+ - **A Codex target for `attest init`.** Evaluated when `init` grew `--target` and
1488
+ shipped for Claude Code, Cursor and Copilot; rejected because the tool offers
1489
+ nothing to write, not because of what writing it would cost. Codex's
1490
+ project-scoped instruction surface is `AGENTS.md`, which the entry above
1491
+ rejects permanently, and its custom prompts live in `~/.codex/prompts` — per
1492
+ machine, outside the project root `init` is pointed at, and not a thing a
1493
+ project can commit or a CI checkout can carry. That leaves no path that is both
1494
+ **Attest's own** and **read by the tool**, so the two rules a target has to
1495
+ satisfy are not merely unmet here, they are unmeetable with what exists: any
1496
+ Codex-shaped implementation would be either the rejected merge or a file
1497
+ nothing reads, and the second is worse, because it looks like coverage.
1498
+ Filed here rather than queued as a candidate, since nothing about the effort
1499
+ or the evidence is what is holding it — the pointer line above is the whole
1500
+ answer for a Codex user, and this reopens only if Codex grows a project-scoped
1501
+ path of its own.
1502
+
1503
+ - **A committed snapshot of the params, to give `check` detection power over a
1504
+ value that moves.** Design §11 records the measurement this answers: editing a
1505
+ param (`'main'` → `'trunk'`, one entry dropped from a nine-element list) leaves
1506
+ `attest check` at `✓ No issues`, because the single source makes a value
1507
+ impossible to *diverge*, which is not the claim that it is flagged when it
1508
+ *moves*. The candidate was the shape `render --check` already proves — a
1509
+ `params.lock` beside the registry, or `check --params-check` — and the
1510
+ mechanism would have worked.
1511
+ *What rejected it: the precedent is the objection.* §9 states when a freshness
1512
+ gate earns its place, and a rendering earns it because it is a function of the
1513
+ *user's registry*, so staleness means the user moved intent without
1514
+ regenerating — a real signal with the fix in their hands. A params snapshot is
1515
+ a function of the registry too, but the population it fires on is different:
1516
+ **every legitimate param edit turns it red, and the fix is a commit containing
1517
+ no decision.** That is precisely the chore §9 declined to ship for `init`, and
1518
+ it is worse here because of frequency — intent is rendered rarely, while params
1519
+ move whenever a value moves, which is the entire reason they are params. The
1520
+ gate would spend its whole life red about correct edits, and a gate that is
1521
+ usually wrong about staleness is the failure mode §9 names as worse than no
1522
+ gate.
1523
+ *What the gap keeps instead.* §6's mechanism 3 already exists to make this
1524
+ exact absence visible: `possible-drift` fires when a requirement owns params
1525
+ that no covering scenario reads, which is the only condition under which a
1526
+ moved param goes unnoticed. The mechanism that guards a param is a scenario
1527
+ reading it, not a file recording it. This reopens only on an adoption report
1528
+ where a param moved, no scenario caught it, and `possible-drift` was **not**
1529
+ already printing — because if it was printing, what failed was reading the
1530
+ report, and a second gate does not fix that.
1531
+
1532
+ - **Grouping `render`'s output by prefix, so a reviewer sees contradicting
1533
+ requirements together.** The *detection* half of this is already rejected above
1534
+ (every automatic candidate either saw almost nothing or was a similarity
1535
+ matcher, which is what §0 exists to remove), leaving human review at propose
1536
+ (§7) as the answer — and the idea was to help that review by putting
1537
+ requirements about one subject side by side in the one document a human reads
1538
+ end to end.
1539
+ *What rejected it: the ordering already ships, and the adjacency it buys is not
1540
+ where the gap is.* `compareIds` (`render.ts`) has ordered the document by
1541
+ prefix first and number second since `render` shipped, so same-prefix
1542
+ requirements are **already** adjacent; the candidate was never an ordering
1543
+ change, only a heading over an ordering that already holds. And §11's own
1544
+ example of the gap is `AUTH-3` expiring a session against `SESS-7` saying
1545
+ sessions never expire — a contradiction **across** prefixes, which prefix
1546
+ grouping pushes further apart rather than closer. What remains is a reader aid
1547
+ for same-prefix contradictions, which are the pairs already adjacent and
1548
+ therefore the ones review is least likely to miss, bought at the price of
1549
+ changing the bytes of every committed rendering and turning `render --check`
1550
+ red across every adopting repo on upgrade — for a change the user did not make.
1551
+ *The gap itself stays open* and stays where it belongs, in design §11: nothing
1552
+ resists a duplicated or contradictory requirement, and human review at propose
1553
+ is still the whole answer. What is rejected is grouping as a way to assist it,
1554
+ and this reopens only on a contradiction that grouping would have caught.
1555
+
1556
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.0...main
1557
+ [0.4.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.0
1558
+ [0.3.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.3.0
745
1559
  [0.2.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.2
746
1560
  [0.2.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.1
747
1561
  [0.2.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.0