@am_shork/attest 0.4.3 → 0.5.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,209 @@ 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.5.0] - 2026-08-03
17
+
18
+ ### Changed
19
+
20
+ - **Design §11 now states what earns a `params` entry, and the README no longer
21
+ states a rule that produces the defect it warns about.** Two prose changes,
22
+ one subject, and both came out of the same adoption report.
23
+ *What earns a param:* **params for promises, plain constants for tuning.** A
24
+ value belongs in `params` when the requirement promises it — a timeout the user
25
+ is owed, a budget someone would file a bug about; a value that only tunes
26
+ behaviour stays an ordinary constant, because nothing is owed to anyone when a
27
+ tuning knob changes. `mine-capablanca` arrived at this unaided and applied it
28
+ consistently across 34 requirements (11 carrying params, 19 values), which is
29
+ the best available evidence that it is the natural rule — and it was written
30
+ down nowhere. It pushes back on the second of the two ways intent bloats, the
31
+ one §11's lumping bullet does not cover: a registry that accumulates every knob
32
+ in the project. Folded into the same paragraph, because it is the same rule
33
+ stated over the *type* rather than the value: what a param may therefore be — a
34
+ scalar, or an array of scalars. Stating those apart is what let one adopter
35
+ derive the first unaided and meet the second as `Invalid input`.
36
+ *And the rule that was half-wrong:* "Read the param inside the assertion, not
37
+ beside it" appeared in the README, the `init` skill and the `possible-drift`
38
+ section, and it is **necessary but not sufficient** — an assertion that
39
+ recomputes its expectation from the same param the code under test just read
40
+ has no independent term, so both sides move together and no edit can make it
41
+ fail. Measured, not theorised: a mine-density band moved by 3.3× with the whole
42
+ suite green, `check` silent, and the drift heuristic correctly quiet, because a
43
+ covering scenario did read the param. Every site now says what the evidence
44
+ supports — *the expectation must not be a function of the param the code under
45
+ test consumed* — and `possible-drift` carries a worked ✗/✓ pair. §11 records
46
+ the sharper statement of the hole: a tautological assertion is a scenario that
47
+ cannot fail, which the red-before-implement gate exists to catch, and it does
48
+ catch them in a change — but **changing an existing param's value goes through
49
+ no gate at all**.
50
+ No behaviour changed and no gate moved; the detector for the tautological form
51
+ stays under `Under consideration`, with all three of its blockers intact.
52
+ Documenting a trap is not the same as deciding not to detect it.
53
+
54
+ - **A change's spec files now live at the location they will be merged to,
55
+ marked `*.proposed.spec.ts`.** `changes/<name>/` keeps `proposal.md`,
56
+ `requirements.delta.ts` and `first-run.json`; the scenarios sit beside the code
57
+ they attest, and merging one is a rename in place. Reported by
58
+ `mine-capablanca`: every one of eight merges rewrote the relative imports of
59
+ the change's specs, and the imports were the only thing that moved.
60
+ **"Depth" was the wrong word for it, and finding that out changed the
61
+ design.** The entry that queued this said the specs sat at a *different depth*
62
+ from where they land. They do not — `changes/<name>/specs/` and
63
+ `lib/game/specs/` are both three levels down. What differs is the **path**, so
64
+ no layout that keeps a spec under `changes/` can make one relative specifier
65
+ resolve in both places; mirroring the destination inside the change folder
66
+ fails for the same reason, one level further out. The only fix that costs no
67
+ mechanism is to write the file where it will live, which is what shipped.
68
+ *Two jobs the folder used to do at once, now done separately.* Keeping a
69
+ proposal's specs out of the base suite is by **name**, because it has to hold
70
+ for commands that never heard of a change: those scenarios are red by
71
+ construction, and a `verify` that swept them up would call every project with
72
+ a change in flight broken. Deciding which change a spec belongs to is by
73
+ **id** — a change claims the proposed specs declaring a scenario for an id it
74
+ ADDs, renames to, or MODIFIEs — because a file name carries no change, and the
75
+ delta already enumerates what the change is about. That keeps the property the
76
+ folder had and a `specs: []` list in the delta would have lost: nothing names a
77
+ path, so nothing can fall out of step with the files. REMOVED ids claim
78
+ nothing; a file is claimed whole or not at all, because the run scope is a set
79
+ of files.
80
+ *What it cost, stated rather than netted off.* A change is no longer one
81
+ folder a reviewer can read end to end — the delta still enumerates every id and
82
+ `attest status <name>` still answers what a change contains, but the specs are
83
+ now found by their marker rather than by looking in one place. And separating
84
+ the two jobs opened a gap the folder did not have, which is the `Added` entry
85
+ below. Held by `ATX-45` (excluded by name) and `ATX-46` (claimed by id), split
86
+ because they fail independently.
87
+ *Migration, for anyone with an open change directory:* move each spec from
88
+ `changes/<name>/specs/` to where it will merge, rename it to
89
+ `*.proposed.spec.ts`, and fix its imports once. That is the rewrite the merge
90
+ was going to charge anyway, done earlier and once. `first-run.json` is
91
+ unaffected — it is keyed by requirement id and scenario name, neither of which
92
+ moves.
93
+ Gone with the folder: `changeExcludeGlobs`, the sibling-exclusion globs that
94
+ kept one proposal's specs out of another's gate run. A directory glob could not
95
+ have told two proposals apart once the specs left `changes/`, and keeping one
96
+ beside the claim check would be a second scoping rule able to disagree with it.
97
+
98
+ ### Added
99
+
100
+ - **A change delta now reads as the registry of what it adds.** `delta(…)`
101
+ returns the delta *and* a record keyed by the ids it ADDs, each carrying the
102
+ params its author wrote — so a stage-1 scenario reads a proposed param with
103
+ the expression a merged one uses:
104
+ ```ts
105
+ import reqs from '../../changes/add-2fa/requirements.delta.ts'; // ← only this line changes at merge
106
+ const window = reqs['AUTH-7'].params.totpWindowSec; // typed at 30, no cast
107
+ ```
108
+ Reported by `mine-capablanca`, which hit this on 3 of 8 changes and wrote a
109
+ throwaway accessor each time, then rewrote every import at merge.
110
+ **The reason this was a defect and not an ergonomic complaint is which stage
111
+ it landed on.** Stage 1 is where the scenario must be written and must go red —
112
+ the entire content of the red-before-implement gate — so the tool pushed the
113
+ author off `params`, the single source the rest of it exists to reward, at
114
+ precisely the moment that source is load-bearing, and then billed them the
115
+ rewrite at merge for having complied. Every hand-written accessor is a second
116
+ place the value lives, open during the only stage where the assertion is being
117
+ authored against a value nobody has implemented yet.
118
+ *Why one value can be both.* A requirement id matches `/^[A-Z]+-\d+$/`, so no
119
+ id can ever be spelled `added`, `modified`, `removed` or `renamed` — the union
120
+ is safe by grammar rather than by convention, and neither surface shadows the
121
+ other. Both readers build it through one function, so the differential suite in
122
+ `tests/static-registry.spec.ts` still finds them agreeing about what a delta is.
123
+ *Deliberately not readable this way:* MODIFIED ids and rename targets. A
124
+ modified requirement's end state is the base entry with the patch applied, the
125
+ base is not in that file, and a view of the patch alone would answer one key
126
+ and `undefined` for every other key the requirement has — a shape that reads as
127
+ the merged requirement and is not one. Still no validation either: a delta
128
+ carrying an id the registry would refuse stays the gate's verdict (`ATX-41`).
129
+ Held by `ATX-48`, attested by compiling a fixture — a type has no runtime
130
+ footprint — with the assertion written as an equality against the merged read,
131
+ because sameness is the property that makes merging a change of import.
132
+ *Behaviour change, for whom:* the object `delta()` returns now carries the
133
+ added ids as own enumerable keys, so `Object.keys(d)` and a deep-equality
134
+ assertion over a whole delta see them. Nothing in Attest reads a delta that
135
+ way — `applyDelta` and both registry readers name the four sections — but a
136
+ consumer that snapshots a delta value will see its snapshot move. Reading
137
+ `.added`, `.modified`, `.removed` and `.renamed` is unchanged.
138
+
139
+ - **`added-id-unmerged`: the gate now names why a spec file failed to load,
140
+ instead of blaming the spec.** Reported by `mine-capablanca`. `archive`
141
+ applies the delta in memory and gates the result; the suite is a child process
142
+ that imports `*.reqs.ts` from disk. So implementation code doing what the
143
+ skill's rule 3 requires — `reqs['FOG-4'].params.modestRisk` for an id the
144
+ change *adds* — throws at import, every spec transitively importing that module
145
+ fails to load, and the gate reported `tests-red` plus a `declared-not-run` per
146
+ scenario, whose message sends the reader to look for a `skip` or an `.only`
147
+ that is not there. **The change could not be made green by the documented
148
+ workflow, and the verdict pointed away from the reason.**
149
+ The gate now carries load failures out of the run alongside the coverage, and
150
+ when the change adds ids the on-disk registry lacks it reports one ERROR per
151
+ unloaded file, naming the file and the ids. The `declared-not-run` lines it
152
+ replaces are dropped — the absence has been explained, and restating it as a
153
+ guess about a skip costs the reader attention on files that are not at fault.
154
+ Only in the gate: `verify` keeps reporting them, having no diagnosis to put in
155
+ their place, and a wrong message still beats silence.
156
+ **This is the diagnosis and deliberately not the fix.** The way through is to
157
+ merge the added requirement into the registry before running the gate, which
158
+ the idempotent ADDED rule makes free — the `added:` entry stays and still
159
+ documents the intent. The alternative considered was to show the child run the
160
+ applied registry through an alias or a scratch overlay: it unblocks the
161
+ workflow and spends the property that the registry the suite ran against is the
162
+ file on disk, on the one command path that already evaluates project code. That
163
+ property cannot be given back by a later diagnostic; the convenience can.
164
+ *The condition is a conjunction and therefore a heuristic* — a module can fail
165
+ to import for reasons unrelated to any requirement id — so the message states
166
+ the two facts and the inference between them rather than asserting a cause.
167
+ Held by `ATX-49` (name the file and the ids) and `ATX-50` (withdraw the lines
168
+ it replaces), which are two requirements rather than one for the reason
169
+ `ATX-24` was split out of `ATX-23`: pushing one more issue satisfies the first
170
+ in full and leaves every misleading line sitting underneath it, so an
171
+ implementation can pass it and fail the reader exactly as before.
172
+ *Behaviour change, for whom:* no run goes from green to red — the new ERROR
173
+ only fires on a run that was already failing, because a file that did not load
174
+ is a failing run. What does move is what a machine consumer reads: for this
175
+ case `archive` now emits `added-id-unmerged` where it emitted one
176
+ `declared-not-run` per scenario. `verify` is untouched, and `declared-not-run`
177
+ is unchanged everywhere else.
178
+
179
+ - **`proposed-spec-unclaimed`: a proposed spec no change claims is an ERROR from
180
+ `attest check`.** This is the cost of the split above, made loud. A file the
181
+ base suite skips by name and every gate skips by id executes nowhere, and both
182
+ skips are individually correct, so neither command is positioned to notice.
183
+ `check` is, because it is the only one that reads the deltas without being
184
+ about a single change — and reading them is something it already owed, since a
185
+ change delta is intent and the reporting commands read intent from source
186
+ (design §5.1). Reported per file, driven off the scanned tree rather than the
187
+ parsed plan so a proposed spec declaring no scenario at all is caught too. A
188
+ delta that cannot be read is reported as itself rather than turned into
189
+ accusations against its specs: not knowing what a change claims is a different
190
+ finding from knowing it claims nothing. Held by `ATX-47`.
191
+
192
+ ### Fixed
193
+
194
+ - **`add-invalid` and `modify-invalid` now name the field that failed.** Both
195
+ quoted the schema's message and dropped the path it came from, so a
196
+ requirement whose `params` the schema refused reported `Invalid input` and
197
+ nothing else — no key, no accepted shape. Reported by an adopter who found the
198
+ failing param by reading `node_modules/@am_shork/attest/dist/core/schema.d.ts`,
199
+ which is the only place the constraint was written down.
200
+ *The form is not new, and that is the sharpest part of it.* `troubleshooting.md`
201
+ has quoted `Added requirement "AUTH-7" is invalid: rationale: Required` since
202
+ the section was written, and `RegistryValidationError` has spelled paths
203
+ `a.b.c` since the registry shipped — so the documented sample was one this
204
+ code could not produce, and the two sites that format the same zod failure
205
+ disagreed. The fix is the path, in the spelling already documented, from the
206
+ one function both callers go through. Where there is no path — the id itself,
207
+ under `introducedIdIssue` — nothing is prefixed rather than `(root)`, because
208
+ the message's own prefix has already named the thing.
209
+ Also on the way through: the `params` value union now carries its own message
210
+ instead of a bare `Invalid input`, so the refusal states what is accepted at
211
+ the moment it fires, and `registry-invalid` gains the same sentence for free.
212
+ Message text, so not a behaviour change under this file's own Versioning rule
213
+ — the codes and the accepted values are untouched, and no existing invocation
214
+ changes its exit code. Held by `ATX-44`, which states the contract over the
215
+ *path* rather than the sentence: the separator is the one property a scenario
216
+ can pin without asserting a word of the wording, which is what keeps
217
+ `Issue.message` prose while making its information content falsifiable.
218
+
16
219
  ## [0.4.3] - 2026-08-01
17
220
 
18
221
  ### Added
@@ -2046,10 +2249,17 @@ feedback reports are triaged into issues or `Unreleased` and deliberately not
2046
2249
  kept in this tree; that rule stands, and holds for anything with a reporter, a
2047
2250
  repro, or a state to track. What is kept here is the narrower thing this file
2048
2251
  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.
2252
+ losing the argument would cost more than losing the task. The first below came out
2253
+ of building something queued here, which is one way an entry arrives: a gate that
2254
+ finds a defect on its first run files the defect. The second came out of reading
2255
+ another repository against this one `msw`. The last came out of an adoption
2256
+ report (`mine-capablanca`, 0.4.2, 34 requirements / 56 scenarios / greenfield),
2257
+ which is the third way: not a defect, but a shape the tool made an adopter work
2258
+ around eight times without ever erroring. Two further entries from that report
2259
+ left this section by shipping in `[0.5.0]` — a change's specs moving to their
2260
+ merged location, and the delta reading as the registry of what it adds — and
2261
+ their arguments are recorded there, including the part of the first one that
2262
+ turned out to be wrong.
2053
2263
 
2054
2264
  - **A diagnosable refusal when the resolved compiler has no AST API.**
2055
2265
  Discovered by building the `typescript` matrix (see `[0.4.2]`), which is
@@ -2147,6 +2357,158 @@ have to gather again.
2147
2357
  someone hits an `EMFILE` from `attest check`, the diagnosis is already written
2148
2358
  down rather than rediscovered.
2149
2359
 
2360
+ - **An assertion that recomputes its expectation from the param the code under
2361
+ test just read is a tautology, and nothing says so.** Reported by
2362
+ `mine-capablanca`, which probed it by mutating the registry and running the
2363
+ full gate on each mutation:
2364
+
2365
+ | mutation | who reads it | `check` | `verify` |
2366
+ |---|---|---|---|
2367
+ | `GEN-1.maxCandidatesPerPiece` 64 → 4 | scenario only | ok | **red** |
2368
+ | `BOARD-1.boardSize` 10 → 12 | app + fixtures | ok | **red** |
2369
+ | `BOARD-2.maxMines` 12 → **40** | app + assertion | ok | **green** |
2370
+ | `BOARD-2.minMines` 9 → **1** | app + assertion | ok | **green** |
2371
+
2372
+ The mine-density band — the one number deciding whether that game is playable —
2373
+ moved by 3.3× with the whole suite green, `check` silent, and `possible-drift`
2374
+ correctly not firing, because a covering scenario *does* read the param.
2375
+ **Read the third and fourth rows against the second, because that is the
2376
+ finding.** The reporter's diagnosis was "app and spec both read it", but row 2
2377
+ is app-and-spec and goes red. What distinguishes the green rows is narrower and
2378
+ more useful: the assertion **derives its expectation from the same param the
2379
+ code under test used**, so both sides move together and the comparison has no
2380
+ independent term. Row 2's expectation lives in fixtures, which do not move when
2381
+ the param does, and the mechanism works. So the failing condition is a property
2382
+ of the assertion, not of who reads the value — which matters, because it means
2383
+ the answer is a rule about how to write the assertion, and *not* the detector
2384
+ the reporter proposed.
2385
+ **What this falsifies is written in this file.** The rejection of a committed
2386
+ params snapshot, below, closes on *"the mechanism that guards a param is a
2387
+ scenario reading it, not a file recording it"* — a sufficiency claim, and rows
2388
+ 3 and 4 are counterexamples to it. A scenario reading the param is necessary
2389
+ and not sufficient. That entry stays rejected on its own argument, which was
2390
+ never about detection power, but its fallback clause is now known incomplete
2391
+ and is annotated accordingly. The README's §"killer move" has the same shape:
2392
+ it says noticing a param *moved* is `verify`'s job "only when a scenario asserts
2393
+ on the value it read from `params`" — literally a necessary condition, and read
2394
+ by an adopter as a sufficient one, with the very next line (*"Read the param
2395
+ inside the assertion, not beside it"*) being the instruction that produces the
2396
+ tautology.
2397
+ **The framing that probably resolves it: this is `never-red` wearing different
2398
+ clothes.** A tautological assertion is a scenario that cannot fail, which is
2399
+ the exact defect `archive`'s red-before-implement gate exists to catch — and it
2400
+ did catch two of them in this very repo (`CHECK-2`, `FOG-3`, both confirmed
2401
+ correct by the reporter). The reason it did not catch these is structural:
2402
+ **changing the value of an existing param goes through no gate at all.**
2403
+ `archive` gates a change; a param edit is not a change. That is a cleaner
2404
+ statement of the gap than "params can be tautological".
2405
+ *What blocks the decision — three things, all of them real.* **(1)** Every
2406
+ mutation above is synthetic. Across 8 commits touching `*.reqs.ts` in that
2407
+ repo, **no param value was ever modified** — every `params:` line in every diff
2408
+ is an addition. So the hole is proven as a logical property and its frequency is
2409
+ entirely unmeasured, from one greenfield repo. **(2)** The reporter's proposed
2410
+ mechanism — a WARNING when one param id is read from both a spec and a non-spec
2411
+ file — is the wrong shape twice over: it fires on row 2, which is correct usage
2412
+ and goes red, and it would fire on the arrangement the README actively
2413
+ recommends, which is §9's "usually wrong about staleness" failure mode again.
2414
+ It also needs a static sweep of the whole project for `reqs['X'].params.y`,
2415
+ far past the two registry readers' current surface. **(3)** No mechanism is yet
2416
+ known that separates "the assertion pins this independently" from "the assertion
2417
+ echoes it" without executing the scenario twice under different values — which
2418
+ is mutation testing, a much larger thing to own.
2419
+ **The guidance half has shipped; this entry is now only about the detector.**
2420
+ "Read the param inside the assertion" was insufficient and half-wrong as
2421
+ written, and `[0.5.0]` replaces it everywhere it appeared — README, the
2422
+ `init` skill, `possible-drift`, and design §11 — with what the evidence
2423
+ supports: **the expectation must not be a function of the param the code under
2424
+ test consumed**, pinned in a fixture, a literal, or a second independently
2425
+ derived value. Row 2 is the worked example, and the reporter's own
2426
+ `CHECK-2`/`FOG-3` fixes are the same move. It was held back once because
2427
+ rewording the central claim of the tool is not a line appended; that is what
2428
+ was then done, deliberately and in one pass.
2429
+ **What stays undecided is the mechanism**, and all three blockers above are
2430
+ untouched by the rewording: the frequency is still unmeasured from one
2431
+ greenfield repo, the reporter's proposed detector is still the wrong shape
2432
+ twice over, and nothing short of mutation testing separates an independent pin
2433
+ from an echo. What the guidance does not do is close the hole — a tautological
2434
+ assertion is still green, still silent, and still reachable — so this stays
2435
+ here rather than moving to `Considered and rejected`: documenting a trap is
2436
+ not the same as deciding not to detect it.
2437
+
2438
+ - **`attest archive <change> --apply`, finishing the merge the gate approved.**
2439
+ `mine-capablanca`'s single loudest item: eight changes, eight hand-written
2440
+ Python scripts, and the report quotes one — it locates the delta's block by
2441
+ `split('added: {', 1)`, trims two spaces of indent per line, and re-terminates
2442
+ the registry on `assert t.endswith('});')`. After the gate goes green the
2443
+ adopter hand-splices the delta into the registry, moves specs into the suite,
2444
+ rewrites their imports, moves the folder to `archive/` and re-renders `SPEC.md`.
2445
+ **The cost is the smaller half of the argument. The hole is that the gate's
2446
+ verdict does not cover the step that acts on it** — `archive` proves the delta
2447
+ is green, covered and drift-free, and then a human transcribes it by hand with
2448
+ nothing checking the transcription. A delta can be spliced in wrong, partially,
2449
+ or twice, and the next `check` sees only whatever ended up in the file.
2450
+ **The objection that has to be answered is the `AGENTS.md` block under
2451
+ `Considered and rejected`.**
2452
+ The `AGENTS.md` block was rejected because "that is a merge tool, and each of
2453
+ its failure modes is destructive on a file the user cannot regenerate". The
2454
+ argument for why it does not transfer, which needs to be right before this
2455
+ ships: there, Attest would edit prose whose grammar it does not own; here the
2456
+ registry is a **literal** Attest defines, statically parseable, with a
2457
+ differential suite already asserting two readers agree on it — so the result of
2458
+ a splice is checkable by re-reading it, and the delta was validated by the gate
2459
+ that just ran. Idempotence is verifiable rather than assumed. That reasoning
2460
+ covers delta→registry, the folder move and the re-render. It used **not** to
2461
+ cover import rewriting, which is editing arbitrary TypeScript and is squarely
2462
+ the rejected shape.
2463
+ *That blocker is gone, and it was removed rather than solved.* Proposed specs
2464
+ now live at their merged location under `*.proposed.spec.ts` (`[0.5.0]`), so
2465
+ the merge step for a spec is a rename in place — there are no specifiers to
2466
+ rewrite, and `--apply` no longer inherits the one part of the job that could
2467
+ not be made safe. What is left for it is the delta→registry splice, the rename,
2468
+ the folder move to `archive/` and the re-render, all of which the paragraph
2469
+ above argues are checkable by re-reading the result.
2470
+ *What still blocks the decision:* two questions with no answer yet — what
2471
+ `--apply` does when the working tree is dirty (`archive` already has a position
2472
+ on this; whether a file-mutating flag takes the same one is not obvious), and
2473
+ whether a partially applied merge is recoverable or whether the flag must be
2474
+ all-or-nothing against a scratch copy.
2475
+ *The frequency blocker has moved.* This entry used to close on "one report, one
2476
+ repo: the cost evidence is strong and the frequency evidence is a single
2477
+ adopter." It is now two. This project's own maintainer proposed the same command
2478
+ independently, without the entry in front of them, having felt the same step by
2479
+ hand — which is the second data point that clause was waiting for. Not
2480
+ conclusive about frequency in the field, and it does not touch either question
2481
+ above, but it removes the reason to keep waiting for someone else to ask.
2482
+
2483
+ *A narrower shape was proposed with it and is rejected — `attest merge <change>`,
2484
+ moving the change folder to `archive/<date>-<name>/` and nothing else.* Three
2485
+ reasons, and the third is the one that decides it.
2486
+ **(1) It targets the cheap half.** The expensive step in the report is the
2487
+ delta→registry splice — the quoted Python locates the block by
2488
+ `split('added: {', 1)`, trims two spaces per line, and re-terminates on
2489
+ `assert t.endswith('});')`. The folder move is `mv`. A new command that saves a
2490
+ shell invocation and leaves the TypeScript-literal surgery is the wrong half to
2491
+ automate. The date it would stamp is real ergonomics but small: `archive/` is
2492
+ named in three places and all three are exclusions (`locate.ts`'s SKIP_DIRS,
2493
+ and the two exclude globs), so nothing reads the directory and a mis-stamped
2494
+ date has no mechanical consequence at all.
2495
+ **(2) It would make two command names mislead instead of one.** `attest archive`
2496
+ archives nothing today — it is purely the gate. Adding a second verb that does
2497
+ the archiving leaves `archive` still lying and `merge` doing its job; `--apply`
2498
+ goes the other way and makes the existing verb finally mean what it says.
2499
+ **(3) A move that does not re-run the gate can file an unfinished change as
2500
+ done.** That is the one thing this tool must not ship: the whole claim is that
2501
+ "done" has a hard definition, and a command able to mark completion around that
2502
+ definition removes it. The alternative is that `merge` re-runs the gate — at
2503
+ which point it *is* `--apply` and should be spelled that way. There is no third
2504
+ option, which is why this is a rejection of the shape rather than of the idea.
2505
+ *Recorded here rather than under `Considered and rejected`,* because nothing
2506
+ was decided against: the idea is this entry, and only the narrow spelling of it
2507
+ died. Also worth stating because it needed no release to ride — a new command
2508
+ changes no exit code, rejects no previously-valid input, and removes no API, so
2509
+ it is additive under this file's own Versioning rule and was never blocked on a
2510
+ version bump.
2511
+
2150
2512
  ## Considered and rejected
2151
2513
 
2152
2514
  Decisions **not** to build something, kept where they can be found before the
@@ -2393,6 +2755,21 @@ no scenario caught it, and `possible-drift` was **not** already printing —
2393
2755
  because if it was printing, what failed was reading the report, and a second
2394
2756
  gate does not fix that.
2395
2757
 
2758
+ *Annotated after `mine-capablanca` (0.4.2): the last two sentences above are
2759
+ wrong and the rejection is not.* "A scenario reading it" is necessary and not
2760
+ sufficient — where the assertion recomputes its expectation from the same param
2761
+ the code under test consumed, the value moved 3.3× with the suite green and
2762
+ `possible-drift` correctly silent, so "the only condition under which a moved
2763
+ param goes unnoticed" names one condition out of two. The reopen clause is
2764
+ therefore satisfied in substance while failing on its own literal terms, since it
2765
+ was written expecting the uncovered case and the demonstrated one has a covering
2766
+ scenario. **What that changes here is nothing**: this entry was rejected on the
2767
+ population a snapshot fires on — every legitimate param edit, fix a commit with
2768
+ no decision in it — and that objection is untouched by the new evidence, which
2769
+ concerns detection the snapshot was never denied having. The live question moved
2770
+ to `Under consideration` above, where it belongs, because the mechanism now in
2771
+ question is a rule about assertions rather than a file beside the registry.
2772
+
2396
2773
  ### Grouping `render`'s output by prefix
2397
2774
 
2398
2775
  The *detection* half of this is already rejected above (every automatic
@@ -2417,7 +2794,43 @@ requirement, and human review at propose is still the whole answer. What is
2417
2794
  rejected is grouping as a way to assist it, and this reopens only on a
2418
2795
  contradiction that grouping would have caught.
2419
2796
 
2420
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.3...main
2797
+ ### Nested objects in `params`, so a lookup table can be registry-owned
2798
+
2799
+ Raised by `mine-capablanca` (0.4.2) and explicitly not as a request, which is
2800
+ why it is recorded with its own answer rather than left as a complaint about a
2801
+ message. A `params` value is a scalar or a list of scalars; a table — piece kind
2802
+ to weight, tier to limit — is refused, and the only way to register one is to
2803
+ split it across parallel arrays, which reads worse in the rendered spec than it
2804
+ buys. *What rejected it: the two properties a param has, and a table has
2805
+ neither.* A param is **rendered into `SPEC.md` as a value a human checks at a
2806
+ glance**, and it is **read by a scenario as the one place that value lives**. A
2807
+ nested object fails the first by construction — there is no reading of a
2808
+ five-row table that a reviewer verifies the way they verify `30`. It fails the
2809
+ second more quietly: a table has many readers by definition, so "the one place"
2810
+ becomes "the one file", which is what an ordinary module already is. The
2811
+ schema's own comment records where the line was drawn and why arrays were let
2812
+ in — list constants are the most drift-prone values, and an array still has one
2813
+ owner read by one scenario. A table does not clear that bar; the reason arrays
2814
+ did is exactly the reason it does not.
2815
+ *The reporter's own resolution is the argument's strongest form.* The table went
2816
+ to a shared module, is not registry-owned, and **nothing was lost** — because a
2817
+ piece-value table is tuning, not a promise. That is the rule design §11 now
2818
+ states — *params for promises, plain constants for tuning* — derived
2819
+ independently by the same adopter before it was written anywhere. Under it this
2820
+ candidate is not a limitation of `params` at all: it is a value that was never a
2821
+ requirement's to hold, and §11 carries this rejection's own argument in the same
2822
+ paragraph, as the rule stated over the type rather than over the value.
2823
+ *What ships instead*, in `[0.5.0]`: the refusal now says what is accepted
2824
+ instead of `Invalid input`, and `troubleshooting.md` states the constraint and
2825
+ where such a value belongs — the reporter's actual ask, which was for the
2826
+ decision to be visible rather than discovered by reading the shipped `.d.ts`.
2827
+ The permanent half goes to design §11 with the params rule, not here.
2828
+ *This reopens on* a report where the value is genuinely promised — a table an
2829
+ adopter owes their users, whose rows a reviewer would want in the rendered spec.
2830
+ None has been seen; the one that raised it was tuning.
2831
+
2832
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.5.0...main
2833
+ [0.5.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.5.0
2421
2834
  [0.4.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.3
2422
2835
  [0.4.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.2
2423
2836
  [0.4.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.1
package/README.md CHANGED
@@ -8,18 +8,26 @@ by a stable ID and continuously detects drift.
8
8
  - **Result** — is every test green? (from the test runner)
9
9
  - **Drift** — do intent and assertions still agree? (static + runtime cross-check)
10
10
 
11
- The killer move against drift: values that change (timeouts, limits) live **once**
12
- in a requirement's `params`, and tests read them from there — so a number is
13
- physically impossible to drift between the spec and the assertion. A param may be
14
- a scalar or an array of scalars, so list-shaped constants (vendor blacklists, id
15
- sets) get the same single source as a lone number.
11
+ The killer move against drift: values a requirement **promises** (timeouts,
12
+ limits, budgets) live **once** in its `params`, and tests read them from there —
13
+ so a number is physically impossible to drift between the spec and the assertion.
14
+ Values that merely tune behaviour stay ordinary constants; nothing is owed to
15
+ anyone when a tuning knob changes. A param may be a scalar or an array of
16
+ scalars, so list-shaped constants (vendor blacklists, id sets) get the same
17
+ single source as a lone number.
16
18
 
17
19
  What that does not buy is a warning when you change the value. `check` runs
18
20
  nothing, so editing a param leaves it at `✓ No issues` — nothing became unbound,
19
21
  nothing became uncovered. The value cannot *diverge* from the assertion, which is
20
22
  the stronger property; noticing that it *moved* is `verify`'s job, and only when
21
- a scenario asserts on the value it read from `params`. Read the param inside the
22
- assertion, not beside it.
23
+ a scenario asserts on the value it read from `params`.
24
+
25
+ Reading the param is necessary and **not sufficient**, which is worth knowing
26
+ before you rely on it: an assertion that recomputes its expectation from the same
27
+ param the code under test just read has no independent term, so both sides move
28
+ together and the test stays green through any edit. Pin the expectation to
29
+ something that does not move with the param — a fixture, a literal in the test,
30
+ or a second independently derived value.
23
31
 
24
32
  ## Prerequisites
25
33
 
@@ -149,7 +157,7 @@ Every diagnostic carries a `code`, and every code has a section in
149
157
  ```
150
158
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
151
159
  Value is not a literal.
152
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.4.3/docs/en/troubleshooting.md#registry-not-static
160
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.5.0/docs/en/troubleshooting.md#registry-not-static
153
161
  ```
154
162
 
155
163
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -12,6 +12,21 @@ export interface ApplyResult {
12
12
  * than off `delta.added`, so the two can never be scoped differently.
13
13
  */
14
14
  export declare function addedIds(d: RegistryDelta): string[];
15
+ /**
16
+ * The ids a delta **claims**: what it adds, renames to, or modifies.
17
+ *
18
+ * This is how a proposed spec is attributed to a change (design §7). A spec
19
+ * sitting at its merged location carries no change name, so the delta names its
20
+ * specs the only way that cannot drift from them — by the requirements they
21
+ * declare a scenario for. Wider than `addedIds` on purpose: a delta that
22
+ * retunes a param or renames an id may need a scenario of its own, and one that
23
+ * could not be claimed would be a spec the gate never ran.
24
+ *
25
+ * REMOVED ids are absent. A scenario for a requirement the change deletes is
26
+ * not proposed behaviour, and claiming it would put a spec into the gate run
27
+ * whose requirement the same delta has just taken away.
28
+ */
29
+ export declare function claimedIds(d: RegistryDelta): string[];
15
30
  /**
16
31
  * Apply a change delta to a base registry, returning the merged registry plus
17
32
  * any apply-level issues (conflicts, missing targets, invalid results). The
@@ -14,6 +14,27 @@ import { byCodeUnit } from './order.js';
14
14
  export function addedIds(d) {
15
15
  return Object.keys(d.added ?? {});
16
16
  }
17
+ /**
18
+ * The ids a delta **claims**: what it adds, renames to, or modifies.
19
+ *
20
+ * This is how a proposed spec is attributed to a change (design §7). A spec
21
+ * sitting at its merged location carries no change name, so the delta names its
22
+ * specs the only way that cannot drift from them — by the requirements they
23
+ * declare a scenario for. Wider than `addedIds` on purpose: a delta that
24
+ * retunes a param or renames an id may need a scenario of its own, and one that
25
+ * could not be claimed would be a spec the gate never ran.
26
+ *
27
+ * REMOVED ids are absent. A scenario for a requirement the change deletes is
28
+ * not proposed behaviour, and claiming it would put a spec into the gate run
29
+ * whose requirement the same delta has just taken away.
30
+ */
31
+ export function claimedIds(d) {
32
+ return [
33
+ ...Object.keys(d.added ?? {}),
34
+ ...(d.renamed ?? []).map((r) => r.to),
35
+ ...Object.keys(d.modified ?? {}),
36
+ ].sort(byCodeUnit);
37
+ }
17
38
  /**
18
39
  * Apply a change delta to a base registry, returning the merged registry plus
19
40
  * any apply-level issues (conflicts, missing targets, invalid results). The
@@ -132,8 +153,29 @@ function introducedIdIssue(id, code, prefix) {
132
153
  function err(code, message, reqId) {
133
154
  return { level: 'ERROR', code, ...(reqId ? { reqId } : {}), message };
134
155
  }
156
+ /**
157
+ * The first schema failure, as `path: message`.
158
+ *
159
+ * The path is the half that was being dropped. A requirement carries a record —
160
+ * `params` — so "invalid" on its own does not say *which* param, and the value
161
+ * that most often fails is one the author has to find by reading the schema's
162
+ * `.d.ts`. `RegistryValidationError` has spelled a path `a.b.c` since the
163
+ * registry shipped, and `troubleshooting.md` has been quoting `rationale:
164
+ * Required` for `add-invalid` the whole time — a form this function could not
165
+ * produce. So this is the spelling matching what is already documented, not a
166
+ * new one.
167
+ *
168
+ * Omitted rather than rendered as `(root)` when the path is empty, because the
169
+ * one caller that gets an empty path is `introducedIdIssue`, whose prefix has
170
+ * already named the thing: `Added requirement "auth-7" is invalid: id must look
171
+ * like AUTH-3` reads correctly and `(root): id must look like AUTH-3` does not.
172
+ */
135
173
  function firstMessage(error) {
136
- return error.issues[0]?.message ?? 'unknown error';
174
+ const first = error.issues[0];
175
+ if (!first)
176
+ return 'unknown error';
177
+ const path = first.path.map(String).join('.');
178
+ return path ? `${path}: ${first.message}` : first.message;
137
179
  }
138
180
  /** Content equality via canonical JSON (params key order does not matter). */
139
181
  function sameRequirement(a, b) {
@@ -8,7 +8,7 @@
8
8
  * and the `##` headings of both language documents, so landing here cannot
9
9
  * produce a dead link.
10
10
  */
11
- export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
11
+ export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
12
12
  export type IssueCode = (typeof ISSUE_CODES)[number];
13
13
  /**
14
14
  * The page explaining `code`, or `undefined` when nothing explains it.
package/dist/core/docs.js CHANGED
@@ -21,6 +21,7 @@ import { packageVersion } from './version.js';
21
21
  export const ISSUE_CODES = [
22
22
  'add-conflict',
23
23
  'add-invalid',
24
+ 'added-id-unmerged',
24
25
  'change-not-found',
25
26
  'declared-not-run',
26
27
  'duplicate-prefix',
@@ -34,6 +35,7 @@ export const ISSUE_CODES = [
34
35
  'never-red',
35
36
  'orphan-test',
36
37
  'possible-drift',
38
+ 'proposed-spec-unclaimed',
37
39
  'rationale-placeholder',
38
40
  'registry-invalid',
39
41
  'registry-no-default',