@am_shork/attest 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,396 @@ input, removes/renames a public API or `--json` field, or changes a default
13
13
  runtime behavior an existing invocation relies on — diagnostic message text is
14
14
  not API.
15
15
 
16
+ ## [0.6.0] - 2026-08-03
17
+
18
+ ### Added
19
+
20
+ - **The workflow `attest init` writes now describes this release, and one line
21
+ of it was telling agents to undo correct work.** The skill is the document an
22
+ agent reads without a human in the loop, and it had drifted twice.
23
+ **The wrong line.** Its merge step said the specs are renamed in place with
24
+ *"no import changes; if you find yourself editing a specifier, the spec was not
25
+ written at its merged location and stage 1 was the place to fix that."* Sixty
26
+ lines above, the same document teaches a stage-1 scenario to read its proposed
27
+ params from the delta — `import reqs from '../../changes/<name>/requirements.delta.ts'`
28
+ — with the comment *"only this line changes at merge"*. So the file contradicted
29
+ itself, and an agent following it either left a merged spec importing a path
30
+ that step 3 had moved into `archive/`, or concluded a correctly-written spec was
31
+ wrong and moved it, producing the very rewrite the sentence claimed to prevent.
32
+ Exactly the defect `--apply` hit while being built, sitting in the document that
33
+ is trusted furthest.
34
+ The merge step is now `attest archive <name> --apply`, with the by-hand path
35
+ kept for the operations it refuses and stated correctly: one specifier changes,
36
+ and it changes because the delta moves, not because the spec was misplaced.
37
+ Two prevention lines added where they cost nothing — a spec under `changes/`
38
+ is `spec-in-change-dir`, and a proposed name whose merged form is taken is
39
+ `proposed-spec-name-taken`, which is an authoring-time choice and so belongs in
40
+ the authoring guide rather than only in a diagnostic. `compiler-unsupported` is
41
+ deliberately **not** added to the stage-2 table: that table answers "what does
42
+ this mean for my change in flight", and a toolchain failure is not something an
43
+ agent fixes by editing the change.
44
+ **The gate that was missing** is now `ATX-57`. Troubleshooting is asserted
45
+ against `ISSUE_CODES` in both languages and the CLI reference against this file,
46
+ while the skill's content was held by four `toContain` calls and otherwise by
47
+ eye — the wrong footing for the one document whose reader is not a person and
48
+ who branches on the codes in it. Every code-shaped token it names is checked
49
+ against the roster, one-directionally: the workflow has no business naming every
50
+ code, so the obligation is only that what it does name exists. `ATX-40` is the
51
+ same shape pointing the other way over the other document. Given a requirement
52
+ rather than left a plain test for the reason this project exists: a gate that is
53
+ not itself gated is a gate someone deletes, and without one over it, removing
54
+ the check is a green diff.
55
+ `attest init` is idempotent and takes no input, so re-running it after upgrading
56
+ is the whole update mechanism; nothing here changes that.
57
+
58
+ - **A compiler with no AST API is now a diagnosis rather than a crash
59
+ (`compiler-unsupported`).** Discovered by building the `typescript` matrix in
60
+ `[0.4.2]`, which is what a matrix is for. `typescript@7` is the native port: its
61
+ package resolves, imports cleanly and still answers `version`, while the AST
62
+ moved behind `typescript/unstable/ast*` — so `createSourceFile` and every
63
+ `ts.isX` guard both registry readers and the spec parser call is `undefined`,
64
+ and the first one touched throws. Measured: `attest check` died with
65
+ `TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
66
+ stack — no code, no fix hint, and nothing saying which compiler it wanted, on
67
+ the crash path `[0.4.2]` had just finished sanitising.
68
+ **The declared range excludes 7, and that is not the same as being safe from
69
+ it.** A caret is a request, not a constraint an adopter cannot override:
70
+ `pnpm.overrides` and Yarn `resolutions` both pin a transitive dependency across
71
+ a whole tree, and a repo standardising on one compiler is exactly the repo that
72
+ uses them — this one has an `overrides` block of its own. So the reachable case
73
+ is a deliberate, reasonable act answered with a stack trace.
74
+ Every AST-reading command now refuses before it opens a file, naming the version
75
+ it found and the supported range. The range lives in `ATX-56`'s `params` and its
76
+ scenario compares that against `package.json`: a diagnostic naming a range that
77
+ has drifted from the dependency it describes is worse than one naming none.
78
+ The refusal takes a compiler rather than reaching for the import, because the
79
+ condition exists only on a compiler this repository cannot install beside the
80
+ one it builds with — and a check nothing can make fail is the `never-red` shape
81
+ the gate exists to catch.
82
+ *What it does not do is make Attest work on TypeScript 7.* That is a migration
83
+ to `typescript/unstable/ast`, against an API whose own name says not to depend
84
+ on it yet. The honest answer for now is a supported ceiling that says so, and
85
+ `troubleshooting.md` carries the override-scoping escape for anyone pinning the
86
+ compiler tree-wide.
87
+ This is a behaviour change only in the sense that a crash became an exit code:
88
+ nothing that worked before reports it.
89
+
90
+ - **`attest archive <change> --apply` finishes the merge its gate approved.**
91
+ The gate proved a delta green, covered and drift-free, and a human then
92
+ transcribed it by hand with nothing checking the transcription — the verdict did
93
+ not cover the step acting on it. Reported by `mine-capablanca`: eight changes,
94
+ eight hand-written Python scripts, one of which located the delta's block by
95
+ `split('added: {', 1)`, trimmed two spaces of indent per line and re-terminated
96
+ the registry on `assert t.endswith('});')`.
97
+ Three steps, and the gate runs first in the same invocation: splice each ADDED
98
+ requirement into the registry file owning its prefix, repoint and rename the
99
+ change's proposed specs, move `changes/<name>/` to `archive/<date>-<name>/`.
100
+ Every path touched is printed. A red gate writes nothing, and there is no flag
101
+ that skips it — a merge acting on an earlier run's verdict could file an
102
+ unfinished change as done, which is the one thing this tool must not ship.
103
+ **Why this is not the `AGENTS.md` merge tool, which stays rejected.** That was
104
+ refused because "each of its failure modes is destructive on a file the user
105
+ cannot regenerate". The registry is a literal Attest defines, statically
106
+ parseable, with two readers already asserted to agree on it — so the result of an
107
+ edit is checkable by re-reading it. That clause only holds if the edit is a
108
+ **pure insertion**, so nothing renders a registry back out of a `Registry`
109
+ object: doing so would be easier and would drop every comment and layout choice
110
+ in a hand-written file while passing a re-read with flying colours, because the
111
+ values all survived. `tests/splice.spec.ts` asserts the insertion is pure by
112
+ reconstructing the original from the result, rather than by looking for the
113
+ comments — the comments are a sample of the property, not the property.
114
+ **Re-runnable rather than atomic, which is what pinned the step order.** No
115
+ primitive spans one edit, N renames and a directory move, and a scratch copy of
116
+ the project root would have to be swapped back through the same non-atomic set
117
+ again. So every step is derived from the tree as it currently is, and a partial
118
+ application is finished by running the same command again. `ATX-53` states that
119
+ and deliberately **not** the order it is bought with: an order is not observable —
120
+ every ordering agrees on the happy path — so a requirement about it could not be
121
+ falsified, which is the `never-red` shape the gate exists to catch. Resumability
122
+ can be falsified, and it fails exactly when the order is wrong: move the folder
123
+ first and the recovery has no delta left to re-derive from; rename before
124
+ splicing and the intermediate tree has merged specs reading a registry without
125
+ their ids.
126
+ **ADDED only, and refused whole otherwise** (`apply-unsupported-delta`,
127
+ `apply-no-prefix-owner`). The gate still applies all four operations in memory,
128
+ so nothing becomes unverifiable — only the write-back stops, because REMOVED
129
+ cannot say which comments belonged to the entry it deletes and MODIFIED is a
130
+ formatting-preserving edit inside an existing literal. Both are the shape the
131
+ rejection named. Whole rather than partial because an implementation that merged
132
+ the half it understood would hand back a change the author believes is done.
133
+ **The collision that would have destroyed work** ships with it as
134
+ `proposed-spec-name-taken`, reported by `check`. `isSpecFile` and
135
+ `isProposedSpecFile` are disjoint, so `session.spec.ts` and
136
+ `session.proposed.spec.ts` coexist with nothing objecting — and the rename would
137
+ overwrite the first. Not a corner: a proposed spec is written beside the code it
138
+ attests, so a change to an already-tested module reaches for exactly that name.
139
+ Held in `check` rather than only refused at merge time, so the fix is a rename
140
+ while renaming is still free.
141
+ **One clause of this entry's own argument was wrong, and building it is what
142
+ found out.** It recorded that `[0.5.0]` had removed the import-rewriting blocker
143
+ outright, because a proposed spec sits at its merged location and merging it is
144
+ "a rename in place". True of its *location* — but `[0.5.0]` also made a delta
145
+ read as the registry it proposes, so a stage-1 scenario imports the delta, and
146
+ step 3 moves the delta away. The first working version of this command moved all
147
+ three things correctly and left every merged spec importing a path that no longer
148
+ existed; `check` was silent, and only running the merged suite showed it. So one
149
+ specifier is repointed after all — located by AST, replaced as text, with the
150
+ expression around it untouched because `reqs['AUTH-7'].params.x` reads the same
151
+ on both sides. Far smaller than "rewriting arbitrary TypeScript", but not the
152
+ zero this file claimed. `ATX-52` therefore attests that the *merged project
153
+ verifies clean*, not that three files moved: the broken version would have passed
154
+ the second.
155
+ **It does not re-render a committed `SPEC.md`,** and cannot: nothing in a project
156
+ records where its rendering lives, so the entry listing that as a fourth step was
157
+ describing something with no input. `render --out` afterwards, which a pipeline
158
+ running `render --check` already asks for.
159
+ `ATX-52`–`ATX-55`, with eight scenarios. `--apply` is additive — no exit code
160
+ moves for anyone not passing it — but `proposed-spec-name-taken` is a new ERROR
161
+ on `check` and **will turn some CI red**: a layout that passed before now fails,
162
+ and everything it fails would have been destroyed by the first merge.
163
+
164
+ - **`check` now names a spec left under `changes/`, where nothing walks it
165
+ (`spec-in-change-dir`).** `[0.5.0]` moved a change's specs to their merged
166
+ location and told anyone with an open change directory to move each file,
167
+ rename it `*.proposed.spec.ts` and fix its imports once. It did not account for
168
+ the state *before* that is carried out. `changes` is in the project walk's
169
+ skip list, and `[0.5.0]` removed `changeExcludeGlobs` — the mechanism by which
170
+ `attest archive <name>` used to include `changes/<name>/specs/` explicitly.
171
+ Both halves are right on their own; together they mean the old location is
172
+ walked by no command.
173
+ **Measured before it was fixed**, on a copy of `fixtures/e2e-archive` with its
174
+ proposed spec put back at `changes/add-totp/specs/totp.spec.ts` and its two
175
+ specifiers re-depthed: `attest check` exited **0** with `✓ No issues.`, and the
176
+ only command that spoke was the gate — `uncovered-requirement`, verbatim
177
+ *"Requirement "AUTH-7" has no scenario attesting it. Add a scenario, or mark it
178
+ deferred."* The author had written two, and both were correct. So the one
179
+ diagnostic available told them to write what they had written, or to defer a
180
+ requirement they had finished. That is the misdiagnosis shape `ATX-49` records,
181
+ arriving from the other direction — there the verdict pointed away from a load
182
+ failure, here away from a path — and in both cases it spends the reader's
183
+ attention on files that are fine.
184
+ In `check` rather than the gate: the gate at least fails, while `check` is both
185
+ the command a pipeline runs first and the one that said nothing at all. Both
186
+ spellings are reported, because a `*.proposed.spec.ts` under `changes/` is a
187
+ migration done halfway — the right marker at the wrong path — and no more
188
+ reachable than one never begun. An ERROR rather than a warning for `ATX-47`'s
189
+ reason: a spec that runs nowhere is a total failure, not a misplaced one.
190
+ *It is not transitional and does not expire.* After `[0.5.0]` a spec under
191
+ `changes/` is wrong permanently; catching an unfinished upgrade is what it is
192
+ worth first, not what it is for.
193
+ **This is a behaviour change and turns some CI red** — a layout that passed
194
+ `check` in `[0.5.0]` now fails it. That is the point, and everything it fails
195
+ was already running nowhere. `ATX-51`, with three scenarios: the pre-move
196
+ spelling, the half-migrated one, and a proposed spec at its merged location
197
+ that must *not* be reported, because a check firing on the documented workflow
198
+ would be §9's "usually wrong about staleness" failure again.
199
+ Found while asking whether a *different* proposed check — the merged-name
200
+ collision under `attest archive --apply`, still in `Planned` — would help
201
+ anyone upgrading. It would not; this does.
202
+
203
+ ## [0.5.0] - 2026-08-03
204
+
205
+ ### Changed
206
+
207
+ - **Design §11 now states what earns a `params` entry, and the README no longer
208
+ states a rule that produces the defect it warns about.** Two prose changes,
209
+ one subject, and both came out of the same adoption report.
210
+ *What earns a param:* **params for promises, plain constants for tuning.** A
211
+ value belongs in `params` when the requirement promises it — a timeout the user
212
+ is owed, a budget someone would file a bug about; a value that only tunes
213
+ behaviour stays an ordinary constant, because nothing is owed to anyone when a
214
+ tuning knob changes. `mine-capablanca` arrived at this unaided and applied it
215
+ consistently across 34 requirements (11 carrying params, 19 values), which is
216
+ the best available evidence that it is the natural rule — and it was written
217
+ down nowhere. It pushes back on the second of the two ways intent bloats, the
218
+ one §11's lumping bullet does not cover: a registry that accumulates every knob
219
+ in the project. Folded into the same paragraph, because it is the same rule
220
+ stated over the *type* rather than the value: what a param may therefore be — a
221
+ scalar, or an array of scalars. Stating those apart is what let one adopter
222
+ derive the first unaided and meet the second as `Invalid input`.
223
+ *And the rule that was half-wrong:* "Read the param inside the assertion, not
224
+ beside it" appeared in the README, the `init` skill and the `possible-drift`
225
+ section, and it is **necessary but not sufficient** — an assertion that
226
+ recomputes its expectation from the same param the code under test just read
227
+ has no independent term, so both sides move together and no edit can make it
228
+ fail. Measured, not theorised: a mine-density band moved by 3.3× with the whole
229
+ suite green, `check` silent, and the drift heuristic correctly quiet, because a
230
+ covering scenario did read the param. Every site now says what the evidence
231
+ supports — *the expectation must not be a function of the param the code under
232
+ test consumed* — and `possible-drift` carries a worked ✗/✓ pair. §11 records
233
+ the sharper statement of the hole: a tautological assertion is a scenario that
234
+ cannot fail, which the red-before-implement gate exists to catch, and it does
235
+ catch them in a change — but **changing an existing param's value goes through
236
+ no gate at all**.
237
+ No behaviour changed and no gate moved; the detector for the tautological form
238
+ stays under `Under consideration`, with all three of its blockers intact.
239
+ Documenting a trap is not the same as deciding not to detect it.
240
+
241
+ - **A change's spec files now live at the location they will be merged to,
242
+ marked `*.proposed.spec.ts`.** `changes/<name>/` keeps `proposal.md`,
243
+ `requirements.delta.ts` and `first-run.json`; the scenarios sit beside the code
244
+ they attest, and merging one is a rename in place. Reported by
245
+ `mine-capablanca`: every one of eight merges rewrote the relative imports of
246
+ the change's specs, and the imports were the only thing that moved.
247
+ **"Depth" was the wrong word for it, and finding that out changed the
248
+ design.** The entry that queued this said the specs sat at a *different depth*
249
+ from where they land. They do not — `changes/<name>/specs/` and
250
+ `lib/game/specs/` are both three levels down. What differs is the **path**, so
251
+ no layout that keeps a spec under `changes/` can make one relative specifier
252
+ resolve in both places; mirroring the destination inside the change folder
253
+ fails for the same reason, one level further out. The only fix that costs no
254
+ mechanism is to write the file where it will live, which is what shipped.
255
+ *Two jobs the folder used to do at once, now done separately.* Keeping a
256
+ proposal's specs out of the base suite is by **name**, because it has to hold
257
+ for commands that never heard of a change: those scenarios are red by
258
+ construction, and a `verify` that swept them up would call every project with
259
+ a change in flight broken. Deciding which change a spec belongs to is by
260
+ **id** — a change claims the proposed specs declaring a scenario for an id it
261
+ ADDs, renames to, or MODIFIEs — because a file name carries no change, and the
262
+ delta already enumerates what the change is about. That keeps the property the
263
+ folder had and a `specs: []` list in the delta would have lost: nothing names a
264
+ path, so nothing can fall out of step with the files. REMOVED ids claim
265
+ nothing; a file is claimed whole or not at all, because the run scope is a set
266
+ of files.
267
+ *What it cost, stated rather than netted off.* A change is no longer one
268
+ folder a reviewer can read end to end — the delta still enumerates every id and
269
+ `attest status <name>` still answers what a change contains, but the specs are
270
+ now found by their marker rather than by looking in one place. And separating
271
+ the two jobs opened a gap the folder did not have, which is the `Added` entry
272
+ below. Held by `ATX-45` (excluded by name) and `ATX-46` (claimed by id), split
273
+ because they fail independently.
274
+ *Migration, for anyone with an open change directory:* move each spec from
275
+ `changes/<name>/specs/` to where it will merge, rename it to
276
+ `*.proposed.spec.ts`, and fix its imports once. That is the rewrite the merge
277
+ was going to charge anyway, done earlier and once. `first-run.json` is
278
+ unaffected — it is keyed by requirement id and scenario name, neither of which
279
+ moves.
280
+ Gone with the folder: `changeExcludeGlobs`, the sibling-exclusion globs that
281
+ kept one proposal's specs out of another's gate run. A directory glob could not
282
+ have told two proposals apart once the specs left `changes/`, and keeping one
283
+ beside the claim check would be a second scoping rule able to disagree with it.
284
+
285
+ ### Added
286
+
287
+ - **A change delta now reads as the registry of what it adds.** `delta(…)`
288
+ returns the delta *and* a record keyed by the ids it ADDs, each carrying the
289
+ params its author wrote — so a stage-1 scenario reads a proposed param with
290
+ the expression a merged one uses:
291
+ ```ts
292
+ import reqs from '../../changes/add-2fa/requirements.delta.ts'; // ← only this line changes at merge
293
+ const window = reqs['AUTH-7'].params.totpWindowSec; // typed at 30, no cast
294
+ ```
295
+ Reported by `mine-capablanca`, which hit this on 3 of 8 changes and wrote a
296
+ throwaway accessor each time, then rewrote every import at merge.
297
+ **The reason this was a defect and not an ergonomic complaint is which stage
298
+ it landed on.** Stage 1 is where the scenario must be written and must go red —
299
+ the entire content of the red-before-implement gate — so the tool pushed the
300
+ author off `params`, the single source the rest of it exists to reward, at
301
+ precisely the moment that source is load-bearing, and then billed them the
302
+ rewrite at merge for having complied. Every hand-written accessor is a second
303
+ place the value lives, open during the only stage where the assertion is being
304
+ authored against a value nobody has implemented yet.
305
+ *Why one value can be both.* A requirement id matches `/^[A-Z]+-\d+$/`, so no
306
+ id can ever be spelled `added`, `modified`, `removed` or `renamed` — the union
307
+ is safe by grammar rather than by convention, and neither surface shadows the
308
+ other. Both readers build it through one function, so the differential suite in
309
+ `tests/static-registry.spec.ts` still finds them agreeing about what a delta is.
310
+ *Deliberately not readable this way:* MODIFIED ids and rename targets. A
311
+ modified requirement's end state is the base entry with the patch applied, the
312
+ base is not in that file, and a view of the patch alone would answer one key
313
+ and `undefined` for every other key the requirement has — a shape that reads as
314
+ the merged requirement and is not one. Still no validation either: a delta
315
+ carrying an id the registry would refuse stays the gate's verdict (`ATX-41`).
316
+ Held by `ATX-48`, attested by compiling a fixture — a type has no runtime
317
+ footprint — with the assertion written as an equality against the merged read,
318
+ because sameness is the property that makes merging a change of import.
319
+ *Behaviour change, for whom:* the object `delta()` returns now carries the
320
+ added ids as own enumerable keys, so `Object.keys(d)` and a deep-equality
321
+ assertion over a whole delta see them. Nothing in Attest reads a delta that
322
+ way — `applyDelta` and both registry readers name the four sections — but a
323
+ consumer that snapshots a delta value will see its snapshot move. Reading
324
+ `.added`, `.modified`, `.removed` and `.renamed` is unchanged.
325
+
326
+ - **`added-id-unmerged`: the gate now names why a spec file failed to load,
327
+ instead of blaming the spec.** Reported by `mine-capablanca`. `archive`
328
+ applies the delta in memory and gates the result; the suite is a child process
329
+ that imports `*.reqs.ts` from disk. So implementation code doing what the
330
+ skill's rule 3 requires — `reqs['FOG-4'].params.modestRisk` for an id the
331
+ change *adds* — throws at import, every spec transitively importing that module
332
+ fails to load, and the gate reported `tests-red` plus a `declared-not-run` per
333
+ scenario, whose message sends the reader to look for a `skip` or an `.only`
334
+ that is not there. **The change could not be made green by the documented
335
+ workflow, and the verdict pointed away from the reason.**
336
+ The gate now carries load failures out of the run alongside the coverage, and
337
+ when the change adds ids the on-disk registry lacks it reports one ERROR per
338
+ unloaded file, naming the file and the ids. The `declared-not-run` lines it
339
+ replaces are dropped — the absence has been explained, and restating it as a
340
+ guess about a skip costs the reader attention on files that are not at fault.
341
+ Only in the gate: `verify` keeps reporting them, having no diagnosis to put in
342
+ their place, and a wrong message still beats silence.
343
+ **This is the diagnosis and deliberately not the fix.** The way through is to
344
+ merge the added requirement into the registry before running the gate, which
345
+ the idempotent ADDED rule makes free — the `added:` entry stays and still
346
+ documents the intent. The alternative considered was to show the child run the
347
+ applied registry through an alias or a scratch overlay: it unblocks the
348
+ workflow and spends the property that the registry the suite ran against is the
349
+ file on disk, on the one command path that already evaluates project code. That
350
+ property cannot be given back by a later diagnostic; the convenience can.
351
+ *The condition is a conjunction and therefore a heuristic* — a module can fail
352
+ to import for reasons unrelated to any requirement id — so the message states
353
+ the two facts and the inference between them rather than asserting a cause.
354
+ Held by `ATX-49` (name the file and the ids) and `ATX-50` (withdraw the lines
355
+ it replaces), which are two requirements rather than one for the reason
356
+ `ATX-24` was split out of `ATX-23`: pushing one more issue satisfies the first
357
+ in full and leaves every misleading line sitting underneath it, so an
358
+ implementation can pass it and fail the reader exactly as before.
359
+ *Behaviour change, for whom:* no run goes from green to red — the new ERROR
360
+ only fires on a run that was already failing, because a file that did not load
361
+ is a failing run. What does move is what a machine consumer reads: for this
362
+ case `archive` now emits `added-id-unmerged` where it emitted one
363
+ `declared-not-run` per scenario. `verify` is untouched, and `declared-not-run`
364
+ is unchanged everywhere else.
365
+
366
+ - **`proposed-spec-unclaimed`: a proposed spec no change claims is an ERROR from
367
+ `attest check`.** This is the cost of the split above, made loud. A file the
368
+ base suite skips by name and every gate skips by id executes nowhere, and both
369
+ skips are individually correct, so neither command is positioned to notice.
370
+ `check` is, because it is the only one that reads the deltas without being
371
+ about a single change — and reading them is something it already owed, since a
372
+ change delta is intent and the reporting commands read intent from source
373
+ (design §5.1). Reported per file, driven off the scanned tree rather than the
374
+ parsed plan so a proposed spec declaring no scenario at all is caught too. A
375
+ delta that cannot be read is reported as itself rather than turned into
376
+ accusations against its specs: not knowing what a change claims is a different
377
+ finding from knowing it claims nothing. Held by `ATX-47`.
378
+
379
+ ### Fixed
380
+
381
+ - **`add-invalid` and `modify-invalid` now name the field that failed.** Both
382
+ quoted the schema's message and dropped the path it came from, so a
383
+ requirement whose `params` the schema refused reported `Invalid input` and
384
+ nothing else — no key, no accepted shape. Reported by an adopter who found the
385
+ failing param by reading `node_modules/@am_shork/attest/dist/core/schema.d.ts`,
386
+ which is the only place the constraint was written down.
387
+ *The form is not new, and that is the sharpest part of it.* `troubleshooting.md`
388
+ has quoted `Added requirement "AUTH-7" is invalid: rationale: Required` since
389
+ the section was written, and `RegistryValidationError` has spelled paths
390
+ `a.b.c` since the registry shipped — so the documented sample was one this
391
+ code could not produce, and the two sites that format the same zod failure
392
+ disagreed. The fix is the path, in the spelling already documented, from the
393
+ one function both callers go through. Where there is no path — the id itself,
394
+ under `introducedIdIssue` — nothing is prefixed rather than `(root)`, because
395
+ the message's own prefix has already named the thing.
396
+ Also on the way through: the `params` value union now carries its own message
397
+ instead of a bare `Invalid input`, so the refusal states what is accepted at
398
+ the moment it fires, and `registry-invalid` gains the same sentence for free.
399
+ Message text, so not a behaviour change under this file's own Versioning rule
400
+ — the codes and the accepted values are untouched, and no existing invocation
401
+ changes its exit code. Held by `ATX-44`, which states the contract over the
402
+ *path* rather than the sentence: the separator is the one property a scenario
403
+ can pin without asserting a word of the wording, which is what keeps
404
+ `Issue.message` prose while making its information content falsifiable.
405
+
16
406
  ## [0.4.3] - 2026-08-01
17
407
 
18
408
  ### Added
@@ -2046,37 +2436,26 @@ feedback reports are triaged into issues or `Unreleased` and deliberately not
2046
2436
  kept in this tree; that rule stands, and holds for anything with a reporter, a
2047
2437
  repro, or a state to track. What is kept here is the narrower thing this file
2048
2438
  already keeps one section of: a **decision with an argument attached**, where
2049
- losing the argument would cost more than losing the task. The last below came out
2050
- of reading another repository against this one — `msw`. The first came out of
2051
- building something queued here, which is the other way an entry arrives: a gate
2052
- that finds a defect on its first run files the defect.
2053
-
2054
- - **A diagnosable refusal when the resolved compiler has no AST API.**
2055
- Discovered by building the `typescript` matrix (see `[0.4.2]`), which is
2056
- what a matrix is for. `typescript@7` is the native port: its npm package's main
2057
- export is `{ version, versionMajorMinor }`, and the AST moved behind
2058
- `typescript/unstable/ast*`so `ts.createSourceFile` and every `ts.isX` guard
2059
- the two readers call is `undefined`. Measured: `attest check` on it dies with
2060
- `TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
2061
- stack, on the crash path this release just finished sanitising. No issue code,
2062
- no fix hint, and nothing saying which compiler it wanted.
2063
- **The range excludes 7, and that is not the same as being safe from it.** A
2064
- caret is a request, not a constraint the adopter cannot override: `pnpm.overrides`
2065
- and Yarn `resolutions` both pin a transitive dependency across the whole tree,
2066
- and a repo standardising on one compiler is exactly the repo that uses them —
2067
- this one has an `overrides` block of its own. So the reachable case is a
2068
- deliberate, reasonable act by an adopter, answered with a stack trace.
2069
- What is planned is a check of the API surface where the readers are entered,
2070
- reported as an ERROR naming the resolved version and the supported range. Cheap,
2071
- and it converts the one dependency failure this tool cannot survive from a crash
2072
- into a diagnosis. *Deliberately not folded into the matrix commit*: a new ERROR
2073
- is a behaviour change, it needs an issue code, a troubleshooting section in both
2074
- languages and an `ATX-n` with a scenario, and none of that belongs in a commit
2075
- whose whole claim is that nothing about behaviour moved.
2076
- *What it cannot do:* make Attest work on TypeScript 7. That is a migration to
2077
- `typescript/unstable/ast`, against an API whose own name says not to depend on
2078
- it yet — a separate decision, with the honest answer for now being that the
2079
- supported range has a ceiling and says so.
2439
+ losing the argument would cost more than losing the task. The one below came out
2440
+ of reading another repository against this one — `msw`.
2441
+
2442
+ Five entries have left by being built, and the last three left together in
2443
+ `[0.6.0]`. `compiler-unsupported` came from building something queued here,
2444
+ which is one way an entry arrives: a gate that finds a defect on its first run
2445
+ files the defect, and the `typescript` matrix `[0.4.2]` added is what found this
2446
+ one.
2447
+ `--apply` came from an adoption report (`mine-capablanca`, 0.4.2, 34 requirements
2448
+ / 56 scenarios / greenfield) a third way an entry arrives: not a defect, but a
2449
+ shape the tool made an adopter work around eight times without ever erroring. It
2450
+ took `spec-in-change-dir` out with it, by a fourth way worth naming: designing one
2451
+ of `--apply`'s checks surfaced a different one, against a migration this file had
2452
+ itself shipped. **What a release costs its adopters is not fully known on the day
2453
+ it is cut** and building the entry is what finds out, which is also how
2454
+ `--apply` discovered that one clause of its own argument here was wrong. Both are
2455
+ recorded under `[0.6.0]`, and two earlier entries from the same report left
2456
+ by shipping in `[0.5.0]` a change's specs moving to their merged location, and
2457
+ the delta reading as the registry of what it adds their arguments recorded
2458
+ there, including the part of the first one that turned out to be wrong.
2080
2459
 
2081
2460
  - **Region-marked samples, so a partial quote can be bound too.** The whole-file
2082
2461
  half of this shipped in `[0.4.3]`: the README's two getting-started blocks
@@ -2147,6 +2526,84 @@ have to gather again.
2147
2526
  someone hits an `EMFILE` from `attest check`, the diagnosis is already written
2148
2527
  down rather than rediscovered.
2149
2528
 
2529
+ - **An assertion that recomputes its expectation from the param the code under
2530
+ test just read is a tautology, and nothing says so.** Reported by
2531
+ `mine-capablanca`, which probed it by mutating the registry and running the
2532
+ full gate on each mutation:
2533
+
2534
+ | mutation | who reads it | `check` | `verify` |
2535
+ |---|---|---|---|
2536
+ | `GEN-1.maxCandidatesPerPiece` 64 → 4 | scenario only | ok | **red** |
2537
+ | `BOARD-1.boardSize` 10 → 12 | app + fixtures | ok | **red** |
2538
+ | `BOARD-2.maxMines` 12 → **40** | app + assertion | ok | **green** |
2539
+ | `BOARD-2.minMines` 9 → **1** | app + assertion | ok | **green** |
2540
+
2541
+ The mine-density band — the one number deciding whether that game is playable —
2542
+ moved by 3.3× with the whole suite green, `check` silent, and `possible-drift`
2543
+ correctly not firing, because a covering scenario *does* read the param.
2544
+ **Read the third and fourth rows against the second, because that is the
2545
+ finding.** The reporter's diagnosis was "app and spec both read it", but row 2
2546
+ is app-and-spec and goes red. What distinguishes the green rows is narrower and
2547
+ more useful: the assertion **derives its expectation from the same param the
2548
+ code under test used**, so both sides move together and the comparison has no
2549
+ independent term. Row 2's expectation lives in fixtures, which do not move when
2550
+ the param does, and the mechanism works. So the failing condition is a property
2551
+ of the assertion, not of who reads the value — which matters, because it means
2552
+ the answer is a rule about how to write the assertion, and *not* the detector
2553
+ the reporter proposed.
2554
+ **What this falsifies is written in this file.** The rejection of a committed
2555
+ params snapshot, below, closes on *"the mechanism that guards a param is a
2556
+ scenario reading it, not a file recording it"* — a sufficiency claim, and rows
2557
+ 3 and 4 are counterexamples to it. A scenario reading the param is necessary
2558
+ and not sufficient. That entry stays rejected on its own argument, which was
2559
+ never about detection power, but its fallback clause is now known incomplete
2560
+ and is annotated accordingly. The README's §"killer move" has the same shape:
2561
+ it says noticing a param *moved* is `verify`'s job "only when a scenario asserts
2562
+ on the value it read from `params`" — literally a necessary condition, and read
2563
+ by an adopter as a sufficient one, with the very next line (*"Read the param
2564
+ inside the assertion, not beside it"*) being the instruction that produces the
2565
+ tautology.
2566
+ **The framing that probably resolves it: this is `never-red` wearing different
2567
+ clothes.** A tautological assertion is a scenario that cannot fail, which is
2568
+ the exact defect `archive`'s red-before-implement gate exists to catch — and it
2569
+ did catch two of them in this very repo (`CHECK-2`, `FOG-3`, both confirmed
2570
+ correct by the reporter). The reason it did not catch these is structural:
2571
+ **changing the value of an existing param goes through no gate at all.**
2572
+ `archive` gates a change; a param edit is not a change. That is a cleaner
2573
+ statement of the gap than "params can be tautological".
2574
+ *What blocks the decision — three things, all of them real.* **(1)** Every
2575
+ mutation above is synthetic. Across 8 commits touching `*.reqs.ts` in that
2576
+ repo, **no param value was ever modified** — every `params:` line in every diff
2577
+ is an addition. So the hole is proven as a logical property and its frequency is
2578
+ entirely unmeasured, from one greenfield repo. **(2)** The reporter's proposed
2579
+ mechanism — a WARNING when one param id is read from both a spec and a non-spec
2580
+ file — is the wrong shape twice over: it fires on row 2, which is correct usage
2581
+ and goes red, and it would fire on the arrangement the README actively
2582
+ recommends, which is §9's "usually wrong about staleness" failure mode again.
2583
+ It also needs a static sweep of the whole project for `reqs['X'].params.y`,
2584
+ far past the two registry readers' current surface. **(3)** No mechanism is yet
2585
+ known that separates "the assertion pins this independently" from "the assertion
2586
+ echoes it" without executing the scenario twice under different values — which
2587
+ is mutation testing, a much larger thing to own.
2588
+ **The guidance half has shipped; this entry is now only about the detector.**
2589
+ "Read the param inside the assertion" was insufficient and half-wrong as
2590
+ written, and `[0.5.0]` replaces it everywhere it appeared — README, the
2591
+ `init` skill, `possible-drift`, and design §11 — with what the evidence
2592
+ supports: **the expectation must not be a function of the param the code under
2593
+ test consumed**, pinned in a fixture, a literal, or a second independently
2594
+ derived value. Row 2 is the worked example, and the reporter's own
2595
+ `CHECK-2`/`FOG-3` fixes are the same move. It was held back once because
2596
+ rewording the central claim of the tool is not a line appended; that is what
2597
+ was then done, deliberately and in one pass.
2598
+ **What stays undecided is the mechanism**, and all three blockers above are
2599
+ untouched by the rewording: the frequency is still unmeasured from one
2600
+ greenfield repo, the reporter's proposed detector is still the wrong shape
2601
+ twice over, and nothing short of mutation testing separates an independent pin
2602
+ from an echo. What the guidance does not do is close the hole — a tautological
2603
+ assertion is still green, still silent, and still reachable — so this stays
2604
+ here rather than moving to `Considered and rejected`: documenting a trap is
2605
+ not the same as deciding not to detect it.
2606
+
2150
2607
  ## Considered and rejected
2151
2608
 
2152
2609
  Decisions **not** to build something, kept where they can be found before the
@@ -2393,6 +2850,21 @@ no scenario caught it, and `possible-drift` was **not** already printing —
2393
2850
  because if it was printing, what failed was reading the report, and a second
2394
2851
  gate does not fix that.
2395
2852
 
2853
+ *Annotated after `mine-capablanca` (0.4.2): the last two sentences above are
2854
+ wrong and the rejection is not.* "A scenario reading it" is necessary and not
2855
+ sufficient — where the assertion recomputes its expectation from the same param
2856
+ the code under test consumed, the value moved 3.3× with the suite green and
2857
+ `possible-drift` correctly silent, so "the only condition under which a moved
2858
+ param goes unnoticed" names one condition out of two. The reopen clause is
2859
+ therefore satisfied in substance while failing on its own literal terms, since it
2860
+ was written expecting the uncovered case and the demonstrated one has a covering
2861
+ scenario. **What that changes here is nothing**: this entry was rejected on the
2862
+ population a snapshot fires on — every legitimate param edit, fix a commit with
2863
+ no decision in it — and that objection is untouched by the new evidence, which
2864
+ concerns detection the snapshot was never denied having. The live question moved
2865
+ to `Under consideration` above, where it belongs, because the mechanism now in
2866
+ question is a rule about assertions rather than a file beside the registry.
2867
+
2396
2868
  ### Grouping `render`'s output by prefix
2397
2869
 
2398
2870
  The *detection* half of this is already rejected above (every automatic
@@ -2417,7 +2889,44 @@ requirement, and human review at propose is still the whole answer. What is
2417
2889
  rejected is grouping as a way to assist it, and this reopens only on a
2418
2890
  contradiction that grouping would have caught.
2419
2891
 
2420
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.3...main
2892
+ ### Nested objects in `params`, so a lookup table can be registry-owned
2893
+
2894
+ Raised by `mine-capablanca` (0.4.2) and explicitly not as a request, which is
2895
+ why it is recorded with its own answer rather than left as a complaint about a
2896
+ message. A `params` value is a scalar or a list of scalars; a table — piece kind
2897
+ to weight, tier to limit — is refused, and the only way to register one is to
2898
+ split it across parallel arrays, which reads worse in the rendered spec than it
2899
+ buys. *What rejected it: the two properties a param has, and a table has
2900
+ neither.* A param is **rendered into `SPEC.md` as a value a human checks at a
2901
+ glance**, and it is **read by a scenario as the one place that value lives**. A
2902
+ nested object fails the first by construction — there is no reading of a
2903
+ five-row table that a reviewer verifies the way they verify `30`. It fails the
2904
+ second more quietly: a table has many readers by definition, so "the one place"
2905
+ becomes "the one file", which is what an ordinary module already is. The
2906
+ schema's own comment records where the line was drawn and why arrays were let
2907
+ in — list constants are the most drift-prone values, and an array still has one
2908
+ owner read by one scenario. A table does not clear that bar; the reason arrays
2909
+ did is exactly the reason it does not.
2910
+ *The reporter's own resolution is the argument's strongest form.* The table went
2911
+ to a shared module, is not registry-owned, and **nothing was lost** — because a
2912
+ piece-value table is tuning, not a promise. That is the rule design §11 now
2913
+ states — *params for promises, plain constants for tuning* — derived
2914
+ independently by the same adopter before it was written anywhere. Under it this
2915
+ candidate is not a limitation of `params` at all: it is a value that was never a
2916
+ requirement's to hold, and §11 carries this rejection's own argument in the same
2917
+ paragraph, as the rule stated over the type rather than over the value.
2918
+ *What ships instead*, in `[0.5.0]`: the refusal now says what is accepted
2919
+ instead of `Invalid input`, and `troubleshooting.md` states the constraint and
2920
+ where such a value belongs — the reporter's actual ask, which was for the
2921
+ decision to be visible rather than discovered by reading the shipped `.d.ts`.
2922
+ The permanent half goes to design §11 with the params rule, not here.
2923
+ *This reopens on* a report where the value is genuinely promised — a table an
2924
+ adopter owes their users, whose rows a reviewer would want in the rendered spec.
2925
+ None has been seen; the one that raised it was tuning.
2926
+
2927
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.6.0...main
2928
+ [0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
2929
+ [0.5.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.5.0
2421
2930
  [0.4.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.3
2422
2931
  [0.4.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.2
2423
2932
  [0.4.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.1