@am_shork/attest 0.3.0 → 0.4.1

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 (48) hide show
  1. package/CHANGELOG.md +394 -2
  2. package/README.md +47 -264
  3. package/dist/cli/index.js +1 -1
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/json.d.ts.map +1 -1
  6. package/dist/cli/json.js +25 -1
  7. package/dist/cli/json.js.map +1 -1
  8. package/dist/cli/report.d.ts.map +1 -1
  9. package/dist/cli/report.js +78 -5
  10. package/dist/cli/report.js.map +1 -1
  11. package/dist/core/apply.d.ts.map +1 -1
  12. package/dist/core/apply.js.map +1 -1
  13. package/dist/core/docs.d.ts +23 -0
  14. package/dist/core/docs.d.ts.map +1 -0
  15. package/dist/core/docs.js +91 -0
  16. package/dist/core/docs.js.map +1 -0
  17. package/dist/core/loader.d.ts.map +1 -1
  18. package/dist/core/loader.js +18 -1
  19. package/dist/core/loader.js.map +1 -1
  20. package/dist/core/locate.d.ts +18 -1
  21. package/dist/core/locate.d.ts.map +1 -1
  22. package/dist/core/locate.js +68 -4
  23. package/dist/core/locate.js.map +1 -1
  24. package/dist/core/pipeline.d.ts +12 -3
  25. package/dist/core/pipeline.d.ts.map +1 -1
  26. package/dist/core/pipeline.js +22 -1
  27. package/dist/core/pipeline.js.map +1 -1
  28. package/dist/core/red-record.d.ts +6 -0
  29. package/dist/core/red-record.d.ts.map +1 -1
  30. package/dist/core/red-record.js +31 -2
  31. package/dist/core/red-record.js.map +1 -1
  32. package/dist/core/runner.d.ts +1 -1
  33. package/dist/core/runner.d.ts.map +1 -1
  34. package/dist/core/runner.js +20 -5
  35. package/dist/core/runner.js.map +1 -1
  36. package/dist/core/static-registry.d.ts.map +1 -1
  37. package/dist/core/static-registry.js +5 -0
  38. package/dist/core/static-registry.js.map +1 -1
  39. package/dist/core/types.d.ts +22 -1
  40. package/dist/core/types.d.ts.map +1 -1
  41. package/dist/core/types.js.map +1 -1
  42. package/dist/core/version.d.ts.map +1 -0
  43. package/dist/{cli → core}/version.js +5 -1
  44. package/dist/core/version.js.map +1 -0
  45. package/package.json +5 -5
  46. package/dist/cli/version.d.ts.map +0 -1
  47. package/dist/cli/version.js.map +0 -1
  48. /package/dist/{cli → core}/version.d.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -13,7 +13,319 @@ 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.1] - 2026-07-29
17
+
18
+ Every diagnostic now links to a section that explains it, and the README is a
19
+ README again — the reference it had grown into has a page of its own, in both
20
+ languages, saying which version each part of it arrived in. No behaviour a CI
21
+ step branches on changed; the human report gains one line per issue.
22
+
23
+ ### Added
24
+
25
+ - **A troubleshooting document, with one section per issue code — and every
26
+ diagnostic links to its own.** A diagnostic names a rule and states a fix in
27
+ one line, which is the right size for a reader who already knows the rule and
28
+ the wrong size for the one who does not — and the second reader is the one
29
+ adopting Attest, who meets six codes at once on their first run. The knowledge
30
+ was not missing so much as filed where nobody looks during a failure: the
31
+ cross-platform traps (a backslash is an escape in a glob; Vitest's 5-second
32
+ default against a scenario that starts a subprocess) lived in `CLAUDE.md`,
33
+ which ships with no release, and the rationale lived in a 450-line README
34
+ nobody greps mid-failure. Both languages, as always
35
+ (`docs/{en,zh}/troubleshooting.md`).
36
+
37
+ What makes this cheap to keep true is that `code` was already the
38
+ machine-readable name of a rule, so the section heading is the code verbatim
39
+ and the link is *derived*: there is no second string to keep in step, and so
40
+ no way to ship a diagnostic pointing at the wrong section. The remaining
41
+ failure mode — a section that does not exist — is closed from the other end:
42
+ `Issue.code` is narrowed from `string` to the roster in `core/docs.ts`, which
43
+ the suite asserts against both documents. An undocumented diagnostic is now a
44
+ compile error. (That narrowing found two families of codes a regex over the
45
+ source had missed, because they are built through a helper rather than written
46
+ at the issue site.) The two readers that restate a *subset* of the roster —
47
+ `StaticReadCode`, `DeltaReadCode` — are now asserted to be subsets of it in
48
+ `tests/issue-code-types.spec-d.ts`. That was already caught, but downstream
49
+ and by accident: the failure surfaced where the code is assigned into an
50
+ `Issue`, several files from the two lines that disagree.
51
+
52
+ Links are pinned to `v<version>` — the tag of the build that printed the
53
+ diagnostic — not to `main`. The two failure modes are not the same size. An
54
+ unresolved tag exists only between a release commit and its push, which is a
55
+ version nobody has installed, so no build in anyone's `node_modules` prints
56
+ those links. An unpinned link is dereferenced by every installed copy forever,
57
+ and the first breaking change to a code turns years of shipped builds into
58
+ links that miss — or worse, land on a section confidently describing a rule
59
+ that has since been redefined. (`main` remains the fallback for the one case
60
+ where pinning is impossible rather than imprecise: an unreadable manifest.)
61
+
62
+ - **`docsUrl` on every issue in `--json`.** Additive, so no `schemaVersion`
63
+ bump. It is attached in the one envelope every report is built through —
64
+ including the `internal-error` crash path — rather than at the ~26 sites that
65
+ construct an `Issue`, for the same reason `hasError` is one function. It is
66
+ for the human reading a CI log; a consumer should still branch on `code`,
67
+ which is the more precise form of the same information.
68
+
69
+ ### Changed
70
+
71
+ - **Every issue in the human report gains a line: `→ <docs url>`, dim, under the
72
+ message.** The one behaviour change an existing invocation can notice. No exit
73
+ code moves, no verdict moves, no input that passed now fails, and `--json` is
74
+ untouched by it — but a snapshot test over `attest`'s stdout will need
75
+ re-recording. That report is prose for humans by the same rule that makes
76
+ `Issue.message` prose (see *Versioning* above); the machine surface is
77
+ `--json`, which is where a CI step should have been looking.
78
+
79
+ - **`Issue.code` is typed `IssueCode`, not `string`.** Breaking only for code
80
+ that *constructs* an `Issue` — reading one, which is what `--json` consumers
81
+ do, gets strictly more type information than before. Nothing in the CLI
82
+ surface, the report shape or the exit codes changes.
83
+
84
+ `SCHEMA_VERSION` stays at 1 for this too, not only for `docsUrl`: the emitted
85
+ values are the same strings they always were, so nothing on the wire changed
86
+ and no consumer can tell the difference. The narrowing is a compile-time
87
+ constraint on this repository, not a report-shape change.
88
+
89
+ - **The README is a README again; the reference moved to
90
+ `docs/{en,zh}/cli-reference.md`.** It had become four documents wearing one
91
+ hat — a pitch, a tutorial, a CLI reference and a running design argument — at
92
+ 470 lines, which is long past the point where a newcomer reads it and well
93
+ past where anyone looks up a flag in it. It is now 199: the pitch, a
94
+ five-minute start, and a table of where everything else lives. Nothing was
95
+ deleted. Per-command behaviour, `--eval`, `--vitest-config`, the run scope and
96
+ the whole `--json` shape are in the new reference; the rationale for the
97
+ diagnostics that were argued through in the README (`registry-not-static`,
98
+ `duplicate-prefix`, `empty-spec`, `stale-spec-doc`) already had a better home
99
+ in the troubleshooting document, so the reference states the rule and links to
100
+ it rather than restating the argument a third time.
101
+
102
+ Each section of the reference that has changed since it first shipped now
103
+ carries a collapsed **History** table. Attest is 0.x and moves fast enough
104
+ that a page describes the latest build to a reader who installed an earlier
105
+ one, with nothing to tell them which half applies — `--vitest-config` has
106
+ existed since 0.1.5, the run has been scoped to attesting spec files only
107
+ since 0.2.0, and the registry has had to be a literal only since 0.2.0. The
108
+ rows were written from this file. A gate checks that every version they name
109
+ is a heading in it, so a row cannot cite a version that was never cut; it
110
+ cannot check that a row is *true*, which still needs a reader with the
111
+ changelog open. `Unreleased` counts as a heading while one exists — and stops
112
+ counting when a release removes it, which is what makes renumbering those rows
113
+ part of cutting the release rather than something to remember.
114
+
115
+ The two documentation gates widened with it: they scanned `README.md` alone,
116
+ and the JSON sample they exist to catch had just moved out from under them.
117
+ They now enumerate `README.md` plus every `*.md` in both language directories,
118
+ so a page added later is covered by default rather than by remembering.
119
+
120
+ - **`packageVersion()` moved from `src/cli/version.ts` to `src/core/version.ts`.**
121
+ It answers "which build is this", which `core/docs.ts` now has to ask in order
122
+ to pin a link — not a CLI concern. Neither path is in the `exports` map, so
123
+ nothing installable can have been importing either.
124
+
125
+ ## [0.4.0] - 2026-07-28
126
+
127
+ Reading a registry no longer opens a port or lets the repository under test write
128
+ to the reader's terminal, the id prefix finally has something that notices two
129
+ files claiming it, and the peer range now says what CI actually proves rather
130
+ than what the tool was hoped to work on.
131
+
132
+ ### Added
133
+
134
+ - **Two registry files claiming one id prefix is now an ERROR.** The id grammar
135
+ is one flat space, `^[A-Z]+-\d+$`, and the prefix inside it was habit rather
136
+ than scope: nothing allocated it, reserved it, or noticed that two files had
137
+ picked the same one (design §11). Every cross-file check there was keys on the
138
+ whole id, so `AUTH-3` in `auth.reqs.ts` and `AUTH-9` in `session.reqs.ts` was
139
+ a clean `check` — two owners of one space, and the first anyone would hear of
140
+ it is the `duplicate-requirement` on the day the counters happen to meet, by
141
+ which point both files have a history. `loadRegistry` (`locate.ts`) already
142
+ held the `(file, id)` pair in the fold where `duplicate-requirement` is
143
+ raised, so the whole mechanism is a prefix→file map in that same loop, and
144
+ because it is that fold, every command that reads intent gets the diagnostic:
145
+ `check`, `cover`, `render`, `status`, `verify`, `archive`.
146
+ **What the issue carries.** `file` is the file that arrived *second* in sorted
147
+ order, matching `duplicate-requirement`, which also files against the file
148
+ being folded — and the file that claimed the prefix first is named in the
149
+ message, because a collision is a fact about a pair, an `Issue` carries one
150
+ path, and an error naming only one end leaves its reader grepping the
151
+ repository to find the other. There is **no `reqId`**: the ids involved are
152
+ individually valid and it is the space above them that has two owners, so
153
+ there is no single requirement the issue is about. It is raised **once per
154
+ colliding prefix per file**, not once per requirement — a registry with forty
155
+ ids under a taken prefix reports one sentence, not forty. Where a duplicate id
156
+ is also present both fire, which is not a restatement: sharing `A-1` implies
157
+ sharing `A-`, and the converse — the case this exists for — does not hold.
158
+ Now a requirement (ATX-39).
159
+ **Not a behaviour change for anything that passes today**, and that was
160
+ checked rather than assumed: all six `*.reqs.ts` in this repo sit in six
161
+ separate project roots holding one registry file each, so no root has two
162
+ files to collide. It is still a new ERROR on input that used to pass, so an
163
+ adopter whose registries share a prefix goes red on upgrade with nothing else
164
+ changing — deliberately, since that is the state the rule exists to report,
165
+ and the fix is a prefix rather than a migration. `SCHEMA_VERSION` stays 1: a
166
+ new `code` value is additive, and neither the envelope nor the `Issue`
167
+ interface moved.
168
+ The other half of the rule this was cut from — that a prefix must match its
169
+ file's name — was **rejected**; see "Considered and rejected".
170
+
171
+ ### Security
172
+
173
+ - **Reading a registry opened a network port.** The loader borrows Vite to
174
+ transform and evaluate a `*.reqs.ts`, and `middlewareMode` suppresses Vite's
175
+ HTTP server but not its HMR WebSocket server — so `verify`, `archive`, and any
176
+ read command under `--eval` bound port 24678 on *every* interface for the
177
+ length of the run, on developer machines and CI runners alike, for a channel
178
+ nothing here subscribes to. `server.ws` is now `false`, which is the option
179
+ that actually closes it: `hmr: false` does not, because the socket is created
180
+ before the HMR option is consulted. The visible symptom was two concurrent
181
+ runs colliding and printing `WebSocket server error: Port is already in use`
182
+ into the middle of a report, through a log level that was supposed to have
183
+ silenced the loader. Now a requirement (ATX-36), asserted as a count of
184
+ listening handles rather than a port number, because the port is Vite's to
185
+ change and the property Attest needs is that reading intent is not a network
186
+ operation.
187
+ - **The human report let the repository under test write to the reader's
188
+ terminal.** Scenario names and file paths are quoted verbatim into
189
+ `orphan-test`, `declared-not-run` and `never-red`, and the parser hands back
190
+ the *cooked* string — so an escape written in a spec file arrived at the
191
+ terminal as a real control byte. On a fork merge request the author of that
192
+ string is not the person reading the report, and the payload erases lines,
193
+ repaints a red verdict green, or retitles the window. Every control character
194
+ except the newline is now replaced with a space, in the message, the file
195
+ path, the code, and the ids and change name a `status` report prints. The
196
+ newline survives, because `registry-invalid` legitimately carries a list of
197
+ field errors — but the continuation is indented, so injected text cannot
198
+ occupy the column a genuine header does. `--json` was never affected
199
+ (serialising escapes the same characters), which is exactly why this had to be
200
+ fixed on the side people read. Now a requirement (ATX-37).
201
+ - **Dependency advisories, and a gate so the next ones are not found by hand.**
202
+ The dev toolchain moved to Vite 8 and Vitest 4, clearing a high-severity
203
+ `server.fs.deny` bypass on Windows alternate paths, an NTLMv2 hash disclosure
204
+ via UNC path handling, two moderate path-traversal/dev-server advisories, and
205
+ a critical Vitest UI advisory that this tool never reached but shipped in its
206
+ lockfile regardless. `brace-expansion` is pinned past its DoS advisory through
207
+ a `pnpm.overrides` entry, since it arrives only through ESLint. A new `audit`
208
+ CI job runs `pnpm audit --audit-level=high`, not `allow_failure`: it is the
209
+ one job here that can go red on a commit that changed nothing, which is the
210
+ point — a dependency becomes vulnerable on the day it is disclosed, not on the
211
+ day someone next edits `package.json`. This also closes a real coverage gap:
212
+ `peerDependencies` has claimed Vite 8 and Vitest 4 since 0.3.0 while CI only
213
+ ever ran Vite 5 and Vitest 2.
214
+
215
+ ### Fixed
216
+
217
+ - **`--eval` crashed on a registry the reading path diagnoses properly.** The
218
+ evaluating reader called a module's default export a registry because it was
219
+ an object — an assertion standing in for a check, on the one path where the
220
+ value has not already been through `defineRequirements`. A file that
221
+ default-exports a plain literal instead of calling it therefore arrived at the
222
+ validator unchecked, where reading `.statement` off a number threw: `--eval`
223
+ answered with an `internal-error` carrying a raw TypeError, for a file the
224
+ static reader reports with a code, a line and a fix hint — and that fix hint
225
+ is "pass `--eval`", so the documented way out of one diagnostic led into a
226
+ crash. The evaluating path now hands what it loaded to the same
227
+ `RegistrySchema`, so both readers refuse the same file. Behaviour change for
228
+ anyone branching on the code: this input used to produce `internal-error` and
229
+ now produces `registry-invalid`; both are ERRORs, so no exit code moves. It is
230
+ also a stricter read than before — a registry that `--eval` accepted only
231
+ because nothing looked at it is now rejected, and unknown fields are stripped
232
+ by the schema exactly as `defineRequirements` already strips them. Now a
233
+ requirement (ATX-38), kept apart from ATX-17: that one promises the escape
234
+ hatch exists and is honest about its cost, and a reader can satisfy it
235
+ completely while validating nothing it evaluates — which is the state this
236
+ was in.
237
+ - **A corrupt first-run record leaked a value that is not an outcome into the
238
+ report.** Reading `changes/<name>/first-run.json` asserted its contents rather
239
+ than checking them: the guard tested that `firstRun` was an object and was
240
+ *declared* to return the record type, so a hand-edited or half-written file
241
+ put an arbitrary string where `pass`/`fail` is the whole domain. It travelled
242
+ — out through `status`'s `progress[].scenarios[].firstRun`, whose declared
243
+ type is `Outcome | null`, into the `--json` contract, and into a human row
244
+ whose hint matched neither "passed first" nor "never run" and so printed
245
+ blank. The archive gate was never at risk, because `hasRecordedRed` compares
246
+ against `fail` and anything unrecognised blocks exactly like a missing record;
247
+ this closes the report, not the gate. A record that does not validate is now
248
+ discarded whole, which is the existing "no more permissive than a missing
249
+ file" rule one step further: the cost is a stage-1 run to re-observe, and
250
+ keeping the entries that happened to parse would present a partial history as
251
+ if it were the whole one. Behaviour change confined to corrupt files: that
252
+ `--json` field now reads `null` where it used to echo whatever was on disk.
253
+ - **The test verdict could fail open.** `runAndCollect` read the failure count
254
+ through `getCountOfFailedTests?.() ?? 0`, but the method is required on
255
+ Vitest's state — so the guard was dead at the type level, and had it ever
256
+ become live it defaulted the wrong way, reading a missing API as zero failures
257
+ and turning a red suite green. Latent rather than observed: no released
258
+ version reported a wrong verdict because of it. This is the one boolean in the
259
+ engine that may never fail open.
260
+ - **`archive` answered `internal-error` for a file `status` diagnoses
261
+ properly.** A `requirements.delta.ts` that loads but default-exports nothing
262
+ reached `applyDelta` as `undefined`, where reading `.renamed` off it threw a
263
+ TypeError — so the gate crashed out through the `--json` envelope's crash path
264
+ instead of reporting `change-not-found`, and the fix hint went with it.
265
+ `archive` now makes the same check `status` already made. Behaviour change for
266
+ anyone branching on the code: this input used to produce `internal-error` and
267
+ now produces `change-not-found`. Both are ERRORs, so no exit code moves.
268
+ - **A sibling change directory containing a backslash silently widened the
269
+ archive gate.** `escapeGlob` escaped every glob metacharacter except
270
+ glob's *own* escape character, so a name like `a\b` — legal on POSIX — did not
271
+ merely go unescaped: the backslash escaped the character after it, and
272
+ `**/changes/a\b/**` matched `ab` and never the directory. That proposal's
273
+ specs then joined the run of the one check that decides whether a change is
274
+ done. Covered by ATX-13, which already stated the property.
275
+ - **A self scenario that compiled a TypeScript program had no explicit
276
+ timeout**, so it fit inside Vitest's 5-second isolated-run default until the
277
+ toolchain moved and then failed as `tests-red` with no assertion behind it —
278
+ the failure mode the repo's other long scenarios already carry a timeout to
279
+ avoid.
280
+
281
+ ### Changed
282
+
283
+ - **BREAKING: the peer range is now `vite ^8` and `vitest ^4`, down from six
284
+ majors.** `^5 || ^6 || ^7 || ^8` and `^2 || ^3 || ^4` was a claim about six
285
+ combinations that no job ever ran end to end. What it looked like from inside
286
+ the repo — the security entry above says as much — was that the *top* was
287
+ untested; the fixture told a different story. `fixtures/consumer` pinned
288
+ `vitest ^2.0.0` / `vite ^5.4.0`, and the consumer job is the **only** thing in
289
+ this pipeline that resolves peers at all, so the two ends were each proven by
290
+ one job apiece and `vite ^6`/`^7` and `vitest ^3` were proven by nothing.
291
+ Advertising a version nobody runs is the same defect the `audit` job was added
292
+ to close, one layer out: the failure surfaces in an adopter's repository, not
293
+ here.
294
+ **Two of them had already stopped being merely untested.** `verify` reads
295
+ `startVitest` as `Promise<Vitest>` — the dead null guard removed above was
296
+ there for the older signature — and ATX-36 asserts a listening-handle count
297
+ against whichever Vite is installed, so on any other major the socket
298
+ guarantee is a claim rather than a measurement. Neither is a defect that would
299
+ announce itself; both fail in the direction of looking fine.
300
+ `fixtures/consumer` moves to `vitest ^4` / `vite ^8` with the range, because
301
+ npm refuses an unmet peer and the fixture exists to install the way a user
302
+ does. That does cost something real and it is worth naming: the consumer job
303
+ no longer exercises a second Vite/Vitest pair, so this trades a spot-check of
304
+ one old combination for a claim that matches the tested one. A matrix is what
305
+ buys the breadth back, and nothing here pretends this is that.
306
+ **Who this breaks:** anyone on Vite 5–7 or Vitest 2–3 fails to install on
307
+ upgrade, with no deprecation window. Under this file's Versioning rule that is
308
+ a minor, which is what 0.4.0 is. The engine may well still work on some of
309
+ them — that is precisely the claim there is no evidence for, and `0.3.x`
310
+ remains the version whose manifest makes it.
311
+
312
+ - **Type-aware linting, and `exactOptionalPropertyTypes`.** The three defects
313
+ above are one shape — an assertion standing in for a check — and the untyped
314
+ ESLint preset cannot see any of them, because deciding whether an `as` or a
315
+ `?.` is doing work needs the checker. `recommendedTypeChecked` now runs over
316
+ `src`, `tests` and `self`, with `no-unnecessary-condition` promoted out of the
317
+ strict preset; the rest of that preset is deliberately not adopted, since its
318
+ bulk is `no-non-null-assertion`, and here `!` is the counterpart of
319
+ `noUncheckedIndexedAccess` — banning it would trade a compiler-enforced
320
+ guarantee for a lint-enforced style. `fixtures/**` stays on the untyped preset
321
+ because several fixtures are invalid on purpose. Turning the rules on removed
322
+ a double cast that hid Vitest's task-tree shape from the compiler, a redundant
323
+ assertion in the delta reader that made an unvalidated value look checked, and
324
+ a dead `startVitest` null guard left over from an older Vitest signature.
325
+ `exactOptionalPropertyTypes` is now on as well: the codebase already followed
326
+ it by hand — the `...(x === undefined ? {} : { key: x })` idiom appears in
327
+ every Issue builder — so this makes an existing discipline the compiler's job
328
+ rather than review's. No `--json` shape changed, so `SCHEMA_VERSION` stays 1.
17
329
 
18
330
  ## [0.3.0] - 2026-07-27
19
331
 
@@ -1126,6 +1438,31 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
1126
1438
  sinking one version deeper each time a release was cut above it, and a rejection
1127
1439
  filed under a version reads as belonging to it.
1128
1440
 
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
+
1129
1466
  - **Two gaps in the intent layer: nothing resists a bloated requirement, and
1130
1467
  nothing resists a duplicated one.** The gaps themselves are described in design
1131
1468
  §11, which is where a permanent property of the design belongs. What is kept
@@ -1272,7 +1609,62 @@ filed under a version reads as belonging to it.
1272
1609
  answer for a Codex user, and this reopens only if Codex grows a project-scoped
1273
1610
  path of its own.
1274
1611
 
1275
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.3.0...main
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 printing — because 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
1666
+ [0.4.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.1
1667
+ [0.4.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.0
1276
1668
  [0.3.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.3.0
1277
1669
  [0.2.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.2
1278
1670
  [0.2.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.1