@am_shork/attest 0.7.0 → 0.7.2

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,338 @@ 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.7.2] - 2026-08-07
17
+
18
+ ### Added
19
+
20
+ - **Releases carry a provenance attestation, and the publish job proves it was
21
+ served rather than trusting its own exit code.** `npm publish --provenance`
22
+ exchanges a GitLab OIDC token for a Sigstore certificate and records, in the
23
+ public transparency log, that this tarball was built by this pipeline from
24
+ this commit — checkable by anyone with `npm audit signatures` or the package
25
+ page, without trusting us. Nothing about the package's *contents* changes:
26
+ same `files`, same tarball, same `prepack`/`prepublishOnly` gate.
27
+ *It is `npm` in a repository whose every other job is `pnpm`, and that is not
28
+ drift.* pnpm's publish has no provenance support at all — checked against the
29
+ pinned 10.28.0, there is no such flag — so the choice was npm for this one
30
+ command or no attestation. Two consequences worth writing down. `--no-git-checks`
31
+ disappears with it: that was a pnpm flag, needed only because pnpm inspects
32
+ the branch and CI builds tags in detached HEAD. And `COREPACK_ENABLE_STRICT=0`
33
+ is now required to call `npm` at all — `corepack enable` shims npm too, and
34
+ `packageManager` pins pnpm, so a bare `npm` aborts with "this project is
35
+ configured to use pnpm". Job-local rather than narrowing the shim in `.base`,
36
+ because every other job wants exactly that strictness.
37
+ *The check after the publish, and the one that looked right and was worthless.*
38
+ The obvious step is `npm audit signatures`, and here it would have been
39
+ decoration of precisely the kind the `audit` job's comment refuses: run in this
40
+ repository it audits *our own installed dependency tree*, which never contains
41
+ `@am_shork/attest` — green while attesting nothing. What runs instead reads the
42
+ registry's attestation endpoint for the version just published and fails unless
43
+ a `slsa.dev/provenance/` predicate is actually there. That is the consumer's
44
+ view, and it is what separates "npm accepted the tarball" from "the statement
45
+ is served". It retries for propagation, because it runs *after* an
46
+ irreversible step: the publish has happened, and a rerun cannot clear a red
47
+ since the republish fails `EPUBLISHCONFLICT` on a version that now exists — so
48
+ a registry that had not caught up would pin the release job red with nothing
49
+ wrong. A served response missing the predicate fails immediately; that answer
50
+ will not change. Both paths were run against the live registry — green on a
51
+ package known to carry provenance, red on `0.7.1`, which does not.
52
+ *Three preconditions, and npm rejects the publish rather than quietly shipping
53
+ unsigned if any is missing.* An `id_tokens` audience of exactly `sigstore`; a
54
+ `repository.url` matching `CI_PROJECT_URL`, so a repository rename fails here
55
+ first; and a public package on registry.npmjs.org, which
56
+ `publishConfig.access=public` already settles. The token is still an npm
57
+ automation token — the OIDC exchange signs the release, it does not authorise
58
+ it (see *Under consideration*).
59
+ *What is unmeasured, stated rather than implied.* There is no dry run for
60
+ provenance — `--dry-run` mints no token exchange — so none of this has executed
61
+ against a real tag pipeline. The next release is its first run. The commit SHA,
62
+ tag, pipeline URL and repository path become public record; all four are
63
+ already public here.
64
+
65
+ ### Fixed
66
+
67
+ - **One unreadable file took the whole of `check` down with it.** A `params`
68
+ value nested 20,000 deep exhausts the call stack inside `ts.createSourceFile`
69
+ — TypeScript's own recursive-descent parser, so `literalValue`'s recursion is
70
+ never reached and hardening it would have changed nothing. Measured: `attest
71
+ check` answered a single `internal-error`, exit 1. It failed *closed*, which
72
+ is why this is a repair and not an emergency — but `check` is the command this
73
+ project tells people to run first, in a pipeline, on a merge request whose
74
+ contents they do not control, and one file an attacker owns could reduce the
75
+ entire report to that one line, permanently. Breadth is what `check` trades
76
+ for running no project code; a report of one crash is not a degraded `check`,
77
+ it is a different command.
78
+ Now a file that cannot be parsed is reported as **`unreadable-file`** and
79
+ skipped, and everything else in the run is still reported.
80
+ *Two sites, and a guard on one of them fixes nothing.* `static-registry.ts`
81
+ and `parser.ts` both open with the same call, so the spec parse takes it too —
82
+ measured separately, and a registry-only guard leaves `attest check` dying on
83
+ a hostile `*.spec.ts` exactly as before. The registry half sits at the loop
84
+ that reads the files rather than inside either reader, because what is being
85
+ kept is a property of the *run* — one file's failure is not the run's failure
86
+ — and both readers need it; `readRegistry` folds them with `Promise.all`,
87
+ where one rejection ends the command.
88
+ *Its own code, not `registry-invalid`.* A file that blew the parser's stack
89
+ did not fail schema validation, and a diagnostic saying it did sends the
90
+ reader to the wrong page. The archive gate refuses to run at all when a spec
91
+ is unreadable, rather than grading a change against a plan known to be short.
92
+ *A third read site turned up while reading the diff, and it was answering
93
+ wrongly rather than crashing.* An unreadable **proposed** spec declares no
94
+ scenarios, so the unclaimed check found it claimed by nothing and reported
95
+ `proposed-spec-unclaimed` — a true sentence about a file whose actual problem
96
+ is that it could not be read, and a fix hint pointing at work that must not be
97
+ done. That is the shape `[0.7.0]` fixed for `orphan-test` after a registry
98
+ fails to load, arriving again at a different site. It now reports
99
+ `unreadable-file` and stands down.
100
+ `parseSpecs` now returns its issues alongside the plan; `parseAllSpecFiles`
101
+ was deleted in passing, having had no callers.
102
+ **No `SCHEMA_VERSION` bump.** A new `code` value is additive to the `--json`
103
+ envelope, and a consumer that branches on codes it knows is unaffected — the
104
+ rule this project already applies to every new diagnostic.
105
+
106
+ - **`attest status` printed one line the sanitiser never saw.** The `Change:`
107
+ header was built and printed in the CLI shell, three lines above a closing
108
+ line that put the same value through `inline()` — so a change name holding
109
+ control characters repainted the terminal from the header while the footer was
110
+ clean. A change name is a directory name in the repository under test, and the
111
+ workflow `init` writes tells an agent to run `attest status <name>`.
112
+ The header moved into `formatStatus`, which is a fix rather than a tidy-up: a
113
+ write that lives in `cli/index.ts` cannot be attested at all, because that
114
+ module runs the CLI at import — so the site was not merely unsanitised, it was
115
+ unreachable by any scenario. `ATX-37`'s site list gained the kind that had
116
+ been missing from it, **a write in the shell**, and the two `--out` echoes are
117
+ the remainder: sanitised now, and still carried as unattested, because the
118
+ trigger needs a filename holding a control character — which NTFS refuses and
119
+ Linux permits, so that half is unmeasured here.
120
+
121
+ - **A param that is a scenario's *domain* could be shortened, and the run just
122
+ covered less.** Nothing in this repo executes here — it is the self-registry
123
+ and its measurement suite — but three of the holes were real, and one of them
124
+ was a security check. Found by re-measuring the tautology entry below after
125
+ `[0.7.1]`: twelve mutations of this registry, each followed by the whole gate.
126
+ Four went green, and three of those were **not** the tautology that experiment
127
+ was looking for. When a scenario loops over a list it read from `params`, that
128
+ list is the set of cases the run covers, so removing a member removes a case
129
+ and every assertion inside the loop still passes over what is left. The
130
+ expectation stays perfectly independent; what moved is the quantifier's
131
+ domain, which is why the rule shipped in `[0.5.0]` — *the expectation must not
132
+ be a function of the param the code under test consumed* — does not cover it.
133
+ What each one cost, measured: dropping `../../evil` from `ATX-13.rejectedNames`
134
+ stopped testing the change-name guard against path traversal; `ATX-40.languages: 1`
135
+ stopped checking `docs/zh/troubleshooting.md` **entirely**, which is the
136
+ mirror rule `CLAUDE.md` calls mandatory losing its only gate; and
137
+ `ATX-64.childRunCommands: ['verify']` stopped checking `archive` for the
138
+ stdout hole `ATX-64` exists to close — filed in the same release, three
139
+ scenarios deep, and silent.
140
+ *The repo had already invented the defence and not named it.* `ATX-16` asserts
141
+ its list against a literal and its comment says why — "so the spec cannot
142
+ quietly cover fewer of them than the intent promises" — and it was one of the
143
+ two lists that went red. A literal is the only independent term available when
144
+ what is at risk is the **size** of the set rather than a value in it, which is
145
+ also why this reads as a violation of "do not hardcode a value that lives in
146
+ `params`" and is not one: the pin asserts what the intent *is*, not what the
147
+ system does.
148
+ Every iterated list now carries that line. `ATX-40` needed both halves and
149
+ that is the instructive one: `toHaveLength(languages)` already caught the
150
+ count going *up* without a document, and could never catch it going down,
151
+ because the slice shrinks with it and the assertion stays true.
152
+ *The counter-pressure, and one thing it cannot reach.* A `DOMAINS` table in
153
+ `tests/intent-rule-candidates.spec.ts` now judges every list-valued param as
154
+ `iterated` or `inert`, with completeness swept from the registry so a new list
155
+ fails until someone decides, and each `iterated` row naming the pin that
156
+ guards it. The `QUANTIFIED` taxonomy gained the distinction this turned on:
157
+ a `roster` owned by the **code** grows with the code and is safe by
158
+ construction, a roster owned by the **registry** is data an author can shorten
159
+ — `ATX-64` was filed as the former and was the latter. What the sweep does not
160
+ reach is `ATX-40`, whose domain size is a *scalar*; it is judged by hand, and
161
+ a future scalar used that way would not be forced into the table by anything.
162
+ *The rot check needed a second attempt, which is worth recording.* Written
163
+ first as "the pin appears somewhere in `self/`", it passed with `ATX-64`'s pin
164
+ deleted — two requirements pin a list read into a local named `commands`, and
165
+ the grep matched the other one. It asserts **exactly one** match now. A check
166
+ satisfiable by an unrelated line is the failure it exists to prevent.
167
+
168
+ - **A project under test could put its own verdict on stdout ahead of Attest's,
169
+ under `--json`.** `runAndCollect` starts Vitest with the parent's fd 1 in
170
+ play, and `quiet: true` does not close it: `silent: true` suppresses Vitest's
171
+ *console interception*, and a bare `process.stdout.write` in a spec never
172
+ enters it — measured at collection time and inside a scenario body, both. So
173
+ a spec writing `{"ok":true,…}` put that document **first** on the stream: a
174
+ consumer calling `JSON.parse` on the whole of stdout got a parse error at
175
+ position 46, and one reading line-wise or taking the first object got the
176
+ attacker's verdict instead of the report. `check`, `cover`, `render` and
177
+ `status` were never exposed — no Attest code was on the path at all, which is
178
+ precisely why `core/terminal.ts` and `sanitisedLogger` did not help.
179
+ *Why this is worse than the terminal half of the same hole.* Repainting a
180
+ terminal — `ESC[2K CR`, a forged `ERROR` header at column 0, an `OSC 0` window
181
+ retitle, all of which also arrive intact — is not a capability `verify`
182
+ lacked, because it executes project code by design. Forging the *machine*
183
+ verdict is different in kind: it inverts what the report is for, since the
184
+ commands carrying the risk are exactly the ones that run the repository being
185
+ gated. §9.1 recorded the machine surface as never exposed because
186
+ `JSON.stringify` escapes every C0 character — true, and about the bytes Attest
187
+ writes; it says nothing about bytes Attest does not write.
188
+ Under `--json`, stdout is now diverted to stderr for the duration of the
189
+ command and released before the report is rendered. Diverted rather than
190
+ dropped: a human debugging a red pipeline still needs the run output, and
191
+ stderr is the stream no consumer parses. Without `--json` nothing changes —
192
+ the promise is the flag's, and diverting unconditionally would take the child
193
+ run's output away from the reader it exists for.
194
+ *The fix is `runAction`'s, not `runner.ts`'s* — the same argument that put
195
+ `formatCrash` in `cli/report.ts` rather than a copy in each `catch`.
196
+ `runAction` is already the single convergence point for one output and one
197
+ exit code; `runner.ts` would have been made to hold a contract it does not
198
+ own. It moved to **`src/cli/action.ts`** to be testable at all: `cli/index.ts`
199
+ calls `program.parseAsync()` at import time, so importing it to cross the seam
200
+ would run the CLI. No behaviour of `index.ts` moved with it.
201
+ *What the regression test had to be, because the first one was wrong.* The
202
+ scenarios under **`ATX-64`** spawn the real CLI and read its pipes. The
203
+ in-process version looks equivalent and is not: they run inside a Vitest
204
+ worker, which patches `console` and re-routes worker output, so intercepting
205
+ `process.stdout.write` there observed neither the report nor the pollution and
206
+ went green against the unfixed code for three of three scenarios. What the
207
+ requirement is about is the bytes on the pipe, and only a real pipe has them.
208
+ The statement names its commands in `params` rather than quantifying over
209
+ "every command that executes project code" — a `roster` row rather than a
210
+ `sites` one, so a third such command cannot join without joining the
211
+ assertion.
212
+
213
+ ## [0.7.1] - 2026-08-05
214
+
215
+ ### Added
216
+
217
+ - **`params` takes any JSON value, and the constraint moved to the place it was
218
+ actually protecting.** A param was a scalar or a list of scalars. The argument
219
+ for admitting lists — the values most likely to drift are the composite ones,
220
+ and rejecting them left exactly those outside the one mechanism built to pin
221
+ them — does not stop at a list: a `kind -> payload` table drifts harder than a
222
+ flat list of names, and it was the one shape still excluded. `null` comes with
223
+ it, because refusing it forces a sentinel (`''`, `-1`) that means something
224
+ else to every later reader.
225
+ What the old union was defending was not the registry but the **rendering**:
226
+ `{payloadKinds}` interpolated into a statement as `[object Object]`, in a
227
+ document reviewers and audit read as the system's promise. That is a property
228
+ of the interpolation point, so it is stated there — a new
229
+ **`non-scalar-interpolation`** ERROR from `check` and `verify`. Narrower and
230
+ truer: it refuses what actually breaks and admits the data a scenario reads,
231
+ which the schema rule could not tell apart. What the schema keeps is what does
232
+ not survive being written down — a function, a `Date`, a class instance, an
233
+ object whose prototype was swapped.
234
+ *The part that is not a schema edit.* Depth. Four obligations had exactly one
235
+ level of reach, which is correct only while a param cannot nest, and none of
236
+ them fails loudly: the canonical form behind `add-conflict` (an identical copy
237
+ written with its inner keys in another order would have conflicted with
238
+ itself), the ATX-58 control-character stripping (nested strings would have
239
+ gone unsanitised, reopening the hole `[0.7.0]` closed), the key order behind a
240
+ byte-stable rendering, and `splice.ts`, which writes a requirement back out as
241
+ TypeScript source and would have committed `[object Object]` into a
242
+ `*.reqs.ts` through `archive --apply`. Three of the four are ordering, and are
243
+ now one shared `sortDeep` in `order.ts` rather than three sorts a level deep —
244
+ they wanted the same guarantee for the same reason, which is what makes it one
245
+ function and not a helper each.
246
+ *Two divergences between the readers, which is what the differential suite is
247
+ for.* `null` is a keyword rather than a literal node, so the AST reader fell
248
+ off the end of `literalValue` and reported `registry-not-static` for the whole
249
+ file while the evaluating reader accepted it. And `{ __proto__: … }` swaps the
250
+ prototype instead of creating a key, so `z.record` copies own keys into a fresh
251
+ object and the taint is invisible one step later — the static reader refuses
252
+ that source at any depth, and the schema now refuses the object the evaluator
253
+ builds from it, both spellings (a literal `__proto__:` and the own key
254
+ `JSON.parse` produces).
255
+ *Rendering.* A structured param gets a fenced JSON block below the params
256
+ table, keys sorted at every depth (ATX-10: the same registry renders the same
257
+ bytes) with the fence measured against backtick runs in the value, as `code()`
258
+ already did. It is below the table rather than in it because a fence cannot
259
+ live in a table cell. And `render` defends the document on its own: it reads
260
+ the registry with no spec parse, so no `AttestPlan` and no `validateStructure`
261
+ — `attest render` runs to completion on a registry `check` refuses. Leaning on
262
+ the diagnostic would have left the document defended by a command nobody is
263
+ obliged to run first.
264
+ *The writing side of the `__proto__` rule.* `splice.ts` turns a requirement
265
+ back into TypeScript source for `--apply`, and it is the only site that does.
266
+ Extending it to depth meant it would have emitted a `__proto__` key as a bare
267
+ property — text whose *evaluation* swaps a prototype rather than being the
268
+ value it was handed. Unreachable through any command (the schema rejects a
269
+ nested `__proto__` and `z.record` drops a top-level one), and refused anyway
270
+ with a new `UnwritableValue`, reported as `internal-error` by the merge with
271
+ its account of what it had already written intact. Quoting is not the repair —
272
+ a quoted `__proto__` swaps the prototype in a literal exactly as the bare form
273
+ does, and the bracketed form that would create an own property is a computed
274
+ key no static reader will read. The reader's guard is worth nothing if the
275
+ writer can produce the file it exists to refuse.
276
+ *Not breaking, with one edge worth naming.* No previously-green registry goes
277
+ red — a structured param was `registry-invalid` before, so nothing that
278
+ compiled and passed now fails — and an all-scalar registry renders
279
+ byte-identical, so a committed `render --check` stays green across the
280
+ upgrade. What did widen is the exported type in a **read** position:
281
+ `Requirement['params'][string]` is now the recursive JSON value. The const
282
+ generic is untouched, so `reqs['AUTH-3'].params.idleTimeoutMin` is still the
283
+ literal `30` and every anti-drift test keeps its narrow type. A consumer only
284
+ notices if they assign an *undeclared* key to the old scalar-or-list union
285
+ (`Type 'null' is not assignable`), or wrote their own handler over that type
286
+ and does `String(v)` in it — which is the `[object Object]` defect this entry
287
+ fixes in `render` and `splice`, seen from outside.
288
+ *This overturns a standing rejection, and not on its stated terms.* *Nested
289
+ objects in `params`* sat under **Considered and rejected** since `[0.4.2]`,
290
+ raised by `mine-capablanca`, and that entry is removed by this release. Its
291
+ argument was that a param has two properties a table lacks: rendered as a value
292
+ a human checks at a glance, and read by a scenario as the one place the value
293
+ lives. The first is answered rather than waived — `non-scalar-interpolation`
294
+ keeps a table out of the *sentence*, so what a reader takes in at a glance is
295
+ still glance-sized, and the table is rendered below it as a block. The second
296
+ is answered by the criterion that admitted arrays in the first place: a
297
+ `kind -> payload` table drifts *harder* than a flat list, so the reason arrays
298
+ were let in is the reason this shape should have been.
299
+ **The reopening condition it named was never met, and that is worth stating
300
+ plainly.** The entry said it reopens on "a report where the value is genuinely
301
+ promised — a table an adopter owes their users"; none has been seen. This
302
+ shipped on the strength of the argument alone. What changed is the recognition
303
+ that the schema was answering the wrong question: *params for promises, plain
304
+ constants for tuning* is the rule, and a type can only ever check shape, so it
305
+ refused a promised table while admitting a list of tuning knobs. Design §11
306
+ now carries that as a judgement rather than a type, which is the permanent half
307
+ — and it is weaker than what it replaces, because nothing enforces a judgement.
308
+ New requirements: `ATX-7` rewritten, `ATX-63` added.
309
+
310
+ ### Changed
311
+
312
+ - **Rationale that only a comment held now has a design section, and the comment
313
+ cites it.** Measured over the diffs in three eras split at `[0.3.0]` and
314
+ `[0.5.0]`: comment blocks got better by every specificity signal — naming a
315
+ symbol 50 → 60%, a measured number 3 → 13%, a causal argument 26 → 54% — and
316
+ roughly doubled per line of code, while the share citing a `§` went 15 → 12 →
317
+ 11% and the design document stopped growing (sixteen sections since `[0.3.0]`,
318
+ 105 lines against `src/`'s 2,862). **The anchoring did not fall; it never
319
+ scaled**, and a `§` is the only part of a comment any gate reads.
320
+ Nineteen blocks written since `[0.5.0]` carried the cost — ten lines or more,
321
+ fully argued, unanchored — and they split three ways. *Six were properties a
322
+ rewrite would have to preserve*, and now sit in the document: a new **§9.1**
323
+ for the artifacts Attest writes, an extension to **§8** (`--apply` acts on this
324
+ run's verdict), and one to **§5.4** (the two readers of one plan must descend
325
+ the same way). *Four were history* — a quadratic backtrack, a 27–84x constant
326
+ factor, a 47 MiB peak, an indexing cost filed as invisible — and were
327
+ compressed to the invariant a maintainer would otherwise re-break; every
328
+ measurement in them was already under `[0.7.0]`, so this deleted duplicates
329
+ rather than moving anything. *Ten were correct as they stood and were left
330
+ alone*, which is the half worth naming: `§` is low because the design document
331
+ stalled, not because comments are too long, so a pass that shortened comments
332
+ and left it at one in nine would have moved the wrong quantity and called it
333
+ done. Net `src/`: 29 lines removed, all of them comments and no code touched,
334
+ which is also how the deletion was checked.
335
+ **One of the six corrected the document rather than extending it.** §8
336
+ summarised a merge as renaming a proposed spec "in place", which is true of its
337
+ location and not of its imports — the delta it reads its proposed params from
338
+ is what moves to `archive/`, so the sentence hid the one specifier the merge
339
+ has to repoint.
340
+ *What the measurement cost.* A first pass reported the `§` share as halving,
341
+ 25 → 12%, an artefact of bucketing by `git blame` over the current tree: that
342
+ sees only surviving lines, and `§`-citing comments survive better, being tied
343
+ to sections that do not move, so the early era was measured on its most durable
344
+ residue. Re-measuring from the diffs before building is what caught it.
345
+ The routing test this produced is in `pre-commit-check` §6, and the obligation
346
+ a new diagnostic code owes the CLI reference is now in §2.
347
+
16
348
  ## [0.7.0] - 2026-08-04
17
349
 
18
350
  ### Added
@@ -3022,82 +3354,46 @@ feedback reports are triaged into issues or `Unreleased` and deliberately not
3022
3354
  kept in this tree; that rule stands, and holds for anything with a reporter, a
3023
3355
  repro, or a state to track. What is kept here is the narrower thing this file
3024
3356
  already keeps one section of: a **decision with an argument attached**, where
3025
- losing the argument would cost more than losing the task. The one entry left
3026
- below came out of reading another repository against this one — `msw`.
3027
-
3028
- Eleven entries have left by being built: three together in `[0.6.0]`, and six
3029
- under `[0.7.0]` the verdict predicates, the nested `describe`, the runner
3030
- the static commands loaded and could not start, the diagnosis for a spec file
3031
- that failed to load, the six comments describing a property the code lacked, and
3032
- the sketches now bound to the files they name — which are the first to leave by
3033
- being **read** rather than by being reported or gated. The last of those left
3034
- from `Under consideration` rather than from here, which is the move this file
3035
- describes as going up before going out, and it went up on the strength of one
3036
- thing: of its three candidates, exactly one could be measured against instances
3037
- that had already happened.
3038
- The second of those left *partially*: one of its three parts came back here as an
3039
- entry of its own, because implementing the other two showed the estimate on it
3040
- was wrong. An entry leaving is not evidence that all of it shipped, and this is
3041
- the first time that has mattered. That returned part has now shipped as well, and
3042
- it closed the way it said it would: the estimate was wrong because the fix was a
3043
- new `Issue` code rather than a suppression, and what made it so was a decision
3044
- recorded three lines from where the suppression would have gone. **The thing that
3045
- cost the most was the thing already written down** — which is an argument for
3046
- this section, since the note that corrected the estimate survived only because
3047
- someone had filed the reasoning next to the code.
3048
- `compiler-unsupported` came from building something queued here,
3049
- which is one way an entry arrives: a gate that finds a defect on its first run
3050
- files the defect, and the `typescript` matrix `[0.4.2]` added is what found this
3051
- one.
3052
- `--apply` came from an adoption report (`mine-capablanca`, 0.4.2, 34 requirements
3053
- / 56 scenarios / greenfield) a third way an entry arrives: not a defect, but a
3054
- shape the tool made an adopter work around eight times without ever erroring. It
3055
- took `spec-in-change-dir` out with it, by a fourth way worth naming: designing one
3056
- of `--apply`'s checks surfaced a different one, against a migration this file had
3057
- itself shipped. **What a release costs its adopters is not fully known on the day
3058
- it is cut** — and building the entry is what finds out, which is also how
3059
- `--apply` discovered that one clause of its own argument here was wrong. Both are
3060
- recorded under `[0.6.0]`, and two earlier entries from the same report left
3061
- by shipping in `[0.5.0]` a change's specs moving to their merged location, and
3062
- the delta reading as the registry of what it adds their arguments recorded
3063
- there, including the part of the first one that turned out to be wrong.
3064
- The entries added after `[0.6.0]` arrived a **fifth** way, and it is the
3065
- one with no external input at all: a strict read of `src/` with nothing failing.
3066
- Every gate in this repository was green while all three were true, which is the
3067
- whole of what that way is for — a defect no test is shaped to ask about is
3068
- reachable only by someone reading for it. **All three have now shipped under
3069
- `[0.7.0]`**, and each closed having found something a strict read is
3070
- apparently good for and a gate is not. The runner one: the gate that would have
3071
- had to catch it *already* looked for the shape, so what the read bought was not a
3072
- missing gate but a cost nobody had priced. The load-failure diagnosis: every gate
3073
- was green because the report was *complete* — three ERRORs on a red run — and
3074
- what was wrong was which three, a judgement no assertion in this repository is
3075
- shaped to make. The third, eight items under one heading: six comments naming a
3076
- property the code lacked and two costs beside them.
3077
-
3078
- **One entry has now left this section the other way — by being rejected** — and
3079
- it is the first, which is why it is worth a line. The region-marked samples were
3080
- decided *yes*, and building the count they rested on is what killed them: the two
3081
- spans the entry named are not spans, and both carry a mark the entry's own rule
3082
- excludes. It moved down with that measurement attached. The lesson is not about
3083
- regions. **An entry can sit in `Planned` for releases on the strength of a figure
3084
- nobody has checked**, because the section's discipline is aimed at keeping the
3085
- *argument* rather than at re-testing the evidence under it — so the first act of
3086
- building one should be re-measuring what it claims, not implementing what it
3087
- proposes. That is what happened here, and it cost an hour instead of a mechanism.
3088
-
3089
- *What the fifth way cost and returned, now that all of it is spent.* Sixteen
3090
- items, no external input, and nothing in the suite shaped to catch any of them.
3091
- The returns were not evenly distributed and the split is the useful part: two
3092
- defects a user could hit today (a report that misdiagnosed a load failure, a
3093
- merge crossing midnight), two costs worth a second each on a real command
3094
- (`check --eval` at 1 + N Vite servers, `control` at 27–84x), one fixed toll on
3095
- every static invocation (~1 s), and the rest true-but-unobservable — identical
3096
- output either way. Only the last group is genuinely optional, and it is also the
3097
- group that would never have been found any other way, because *no observable
3098
- symptom* is precisely the definition of what a gate cannot see. The transferable
3099
- lesson is the batching: individually none of the unobservable ones justifies a
3100
- commit, and together they cost about an hour.
3357
+ losing the argument would cost more than losing the task.
3358
+
3359
+ **Where an entry went is recorded under the version that shipped it.** This
3360
+ section keeps no ledger of its own departures one was maintained here by hand
3361
+ for several releases, and a count kept in prose with nothing to check it is a
3362
+ claim that goes wrong quietly. What is worth keeping is what the section learned
3363
+ about itself, which is three things.
3364
+
3365
+ **An entry can sit here for releases on the strength of a figure nobody has
3366
+ checked.** The discipline aims at keeping the *argument*, not at re-testing the
3367
+ evidence under it, so the first act of building one is re-measuring what it
3368
+ claims rather than implementing what it proposes. That has paid twice: once
3369
+ killing an entry outright, when the spans it rested on turned out not to be
3370
+ spans; once correcting an entry's headline number before a line of it was
3371
+ written. **What a release costs its adopters is not known on the day it is cut**
3372
+ several entries arrived from one adoption report, about shapes the tool made
3373
+ someone work around without ever erroring. And **the fixes with no observable
3374
+ symptom are worth batching**: individually none justifies a commit, together they
3375
+ cost about an hour, and they are the group no gate could have found, since having
3376
+ no symptom is the definition of what a gate cannot see.
3377
+
3378
+ - **Telling adopters about the domain trap, which the shipped guidance
3379
+ currently walks them into.** The repo half landed under `[Unreleased]`; the
3380
+ user-facing half has not, and it is not merely absent. `skill.ts`'s "Four
3381
+ things you must not do" says **"Do not hardcode a value that lives in
3382
+ `params`"**, and the pin every fix in that entry adds is, read literally,
3383
+ exactly that. An adopter who writes a roster param — which the design
3384
+ actively pushes them toward, since a quantifier a scenario can iterate is the
3385
+ shape `QUANTIFIED` recommendsgets an instruction that forbids the only
3386
+ defence against shortening it. The distinction is real and one sentence long:
3387
+ the rule is about the **expectation** the system is measured against, and the
3388
+ pin asserts what the **intent** is. Nothing shipped draws it.
3389
+ *Why it is filed rather than done in the same pass.* It reaches README §"killer
3390
+ move", the `init` skill, `possible-drift`'s troubleshooting section and design
3391
+ §11, in both languages the same surface `[0.5.0]` rewrote, and that entry
3392
+ records the lesson: rewording the central claim of the tool is not a line
3393
+ appended, and it was held back once precisely so it could be done deliberately
3394
+ and in one pass. The measurement it needs is already taken; what it needs next
3395
+ is the pass. *Not* a `possible-drift` change: nothing here is detectable by
3396
+ the engine, for the reason the entry below gives about prose judgement.
3101
3397
 
3102
3398
  - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3103
3399
  showing input the engine must *reject* can be bound the way a runnable one is,
@@ -3117,6 +3413,43 @@ commit, and together they cost about an hour.
3117
3413
  `[0.4.3]` — that the gate and the run proving the fixture still works live in
3118
3414
  different suites — applies here identically.
3119
3415
 
3416
+ - **`render`'s document can carry raw HTML and a forged requirement section.**
3417
+ `sanitised` is the entry §9.1 names and it strips control characters and
3418
+ nothing else, so `statement` and `rationale` reach the Markdown by
3419
+ concatenation exactly as written. Measured: a `rationale` containing newlines
3420
+ and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
3421
+ for a requirement no registry holds, sitting between two real ones and worded
3422
+ identically; `<script>` and `<img onerror>` survive the same way. `check` and
3423
+ `cover` see only the real id, and `render --check` cannot object, because that
3424
+ *is* what the registry renders to now.
3425
+ *Not an accepted risk, which is the part worth pinning.* §9.1 and the
3426
+ reference both scope their guarantee to control characters, and the
3427
+ reference's heading over that paragraph is "Safe to read, and safe to keep",
3428
+ with "a site generator" named in the threat it describes. What *is* a decision
3429
+ is that prose may be marked up — `plain()` escapes a param value precisely
3430
+ because it is data while the statement around it is not — and that decision
3431
+ has never been separated from allowing raw HTML or a heading. So the entry is
3432
+ as much about stating which of the three is intended as about the escaping,
3433
+ and if the answer is that Markdown stays and HTML and headings go, that is a
3434
+ fourth obligation in §9.1 rather than a patch at an emitter.
3435
+
3436
+ - **`attest init` follows a symlink out of the project root.** `runInit` does
3437
+ `mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
3438
+ neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
3439
+ planted in the repository as a link to anywhere the user can write is followed
3440
+ by both: measured on Windows with a junction, a file at the target holding
3441
+ `USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
3442
+ prints the relative path, so nothing on screen says where it went.
3443
+ *`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
3444
+ flag defends the *temporary* path against a planted link and does exactly
3445
+ that; the destination's directory was never in its scope. And `workflowBody()`
3446
+ is a pure function of nothing, so no byte of the checked project reaches the
3447
+ file — the exposure is the path, not the content, which is `targets.ts`'s
3448
+ first rule ("Attest owns the path") being an assumption rather than a check.
3449
+ The check belongs beside `resolveTargets`, which is already all-or-nothing for
3450
+ the same reason: a name known to be unwritable must not leave half a set
3451
+ behind.
3452
+
3120
3453
  ## Under consideration
3121
3454
 
3122
3455
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3129,6 +3462,25 @@ deletion. What each entry has to carry is the evidence currently blocking the
3129
3462
  decision, because that is what a re-proposal a year from now would otherwise
3130
3463
  have to gather again.
3131
3464
 
3465
+ - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
3466
+ The provenance work in `[Unreleased]` signs the release but does not change
3467
+ what authorises it: a long-lived automation token, masked and protected, that
3468
+ publishes as us to anyone holding it. npm's trusted publishing exchanges the
3469
+ same OIDC identity the attestation already uses for a short-lived credential,
3470
+ which would remove the stored secret entirely and grant provenance as a
3471
+ property of the flow rather than a flag on the command. The deletion test
3472
+ points the right way for once: what it removes is the only long-lived
3473
+ credential this repository has.
3474
+ *What blocks the decision, and it is evidence rather than principle.* Whether
3475
+ npm's trusted publishers accept a **gitlab.com-hosted** project on the plan
3476
+ this package publishes under has not been verified against this project — it
3477
+ was not checked when the provenance job was written, and an entry claiming it
3478
+ works would be exactly the unverified confidence the rest of this file avoids.
3479
+ It also cannot be tested except by publishing: as with provenance, there is no
3480
+ dry run, so a failed experiment is a release. The cheap next step is reading
3481
+ npm's current trusted-publisher documentation for GitLab support before
3482
+ writing any YAML.
3483
+
3132
3484
  - **A static sweep for the two properties claimed over the whole of `src/`.**
3133
3485
  The quantifier table's first strict read left three rows wanting the same
3134
3486
  thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
@@ -3267,6 +3619,103 @@ have to gather again.
3267
3619
  assertion is still green, still silent, and still reachable — so this stays
3268
3620
  here rather than moving to `Considered and rejected`: documenting a trap is
3269
3621
  not the same as deciding not to detect it.
3622
+ **Re-measured after `[0.7.1]` widened `params`, on this registry rather than
3623
+ the reporter's — the first time this experiment has been run here.** Twelve
3624
+ mutations, each followed by the whole gate. Two results matter, and they point
3625
+ opposite ways.
3626
+ *The shape is now reachable here, and the widening is why.* `self/render.spec.ts`
3627
+ asserts `expect(md).toContain(JSON.stringify(kinds))` over the same
3628
+ `payloadKinds` it feeds to the renderer — the expectation is a function of the
3629
+ value under test, verbatim row 3, and it could not have been written before
3630
+ the widening because the param could not be an object. The mechanism is
3631
+ sharper than a frequency count and is the strongest thing this entry has
3632
+ gained: **the prescribed remedy costs in proportion to the value's
3633
+ complexity.** Same repo, adjacent files, same value shape — `ATX-7` pins two
3634
+ numbers with literals and goes red; `ATX-63` would have had to hand-write a
3635
+ rendered JSON block, so it echoed. Depth raises the defect rate and the cost
3636
+ of avoiding it along one axis.
3637
+ *And its cost here is nil, which is the honest other half.* Both composite
3638
+ params are fixtures for the renderer and the validator; the numbers stand for
3639
+ nothing, so the green is arguably correct. Blocker **(1)** is therefore only
3640
+ half retired — the shape is reproduced in a second, mature repo, and the count
3641
+ of *harmful* instances is still zero. Blocker **(2)** is reinforced rather than
3642
+ weakened: all four green mutations were on params `src/` never reads, so the
3643
+ reporter's "read from both a spec and a non-spec file" detector would have
3644
+ flagged none of them. Blocker **(3)** is untouched.
3645
+ *The frequency is no longer unmeasured, and it points away from building
3646
+ anything.* Blocker **(1)** rested on 8 commits of one greenfield repo. Parsed
3647
+ at every one of the **38** revisions that have touched this registry — all 38
3648
+ readable by the current static reader, so nothing was skipped — the history is
3649
+ **57 param keys added, 0 removed, and exactly 1 value changed**: `ATX-16.staticCommands`
3650
+ gaining `status`. That single edit is a roster *widening*, the safe direction,
3651
+ and it is on the one list that was already pinned, so it could not have been
3652
+ made without updating the pin. Two independent codebases, one greenfield and
3653
+ one at 64 requirements, and the event this whole entry is about has happened
3654
+ zero times in either. What that argues is not that the hole is unreal — it is
3655
+ reachable, demonstrated above — but that it is **rare and silent**, which is
3656
+ the profile `divergent-param` was rejected on rather than the profile that
3657
+ justifies an engine rule.
3658
+ *The same number says the split-out fix below has also never been triggered.*
3659
+ 0 removals ever means no domain has ever been shortened here. Those pins close
3660
+ a live *exposure*, not a live *loss*, and the case for them is that they cost
3661
+ one line each against a failure with no symptom — not that anything was
3662
+ currently broken.
3663
+ *Blocker **(3)** assumed mutation testing was the only separator. A cheaper
3664
+ candidate existed, has now been scored, and does not survive it.* The idea was
3665
+ a **syntactic** discriminator over one test body — flag an assertion whose
3666
+ expected value derives from a param that also reached the arrange/act part —
3667
+ which is exactly the difference between the two cases the widening produced:
3668
+ `renderMarkdown({params: {payloadKinds: kinds}})` … `toContain(JSON.stringify(kinds))`
3669
+ against `expect(kinds.json.maxBytes).toBe(1_048_576)`. Scored the way
3670
+ `divergent-param` should have been and was not: implemented in
3671
+ `tests/intent-rule-candidates.spec.ts`, run over the specs that attest this
3672
+ repo, and then **every param it fires on mutated with the whole gate run —
3673
+ fifteen of them, one gate run each. Four went green; eleven went red.**
3674
+ *Then the detector was found to be wrong, which is the more useful half.* Its
3675
+ first implementation counted an identifier in any position as a read of a
3676
+ local with that name, so `i.code` in `issues.map((i) => i.code)` looked like a
3677
+ use of a param named `code` — and `code` is this repo's commonest param name.
3678
+ Corrected to count reference positions only, it flags **8, not 15**, and the
3679
+ seven it drops are exactly seven the mutations had already shown to be pinned
3680
+ by `src/`. The honest score is therefore **4 of 8, not 4 of 15** — precision
3681
+ 50%, not 27%. The conclusion does not move, because it never rested on the
3682
+ precision figure; what moves is how much of the original number was the
3683
+ candidate failing and how much was the measurement being wrong, and that is
3684
+ worth writing down rather than quietly restating.
3685
+ The remaining false positives are not near-misses. They are independent terms
3686
+ the detector cannot see: **the runtime is the term** — `ATX-14`'s
3687
+ `inheritedKey` is `'toString'`, and a mutated spelling is not a prototype key
3688
+ at all, so the behaviour under test changes; **`src/` is the term** —
3689
+ `ATX-51`'s `code` is a second copy of a string the engine emits as a literal;
3690
+ and **arithmetic is the term** — `threshold + 3` builds a derived expectation
3691
+ that mutation breaks.
3692
+ And the four true positives fail the second test, which is the one that
3693
+ decides it: **none is harmful.** `ATX-10` and `ATX-3` assert that a value was
3694
+ substituted or applied, where the identity of the number is not the
3695
+ obligation; `ATX-51`'s is a path prefix and `ATX-63`'s a rendering fixture.
3696
+ Telling those from a mine-density band that must not move is a judgement about
3697
+ whether the value *means* anything — the ground truth §0 refuses to compute
3698
+ for someone else's registry. **50% precision on the shape, 0% on the harm.**
3699
+ Two limits on that number, stated because a score with an unstated method is
3700
+ what this file exists to prevent. The mutation has **one direction per kind**
3701
+ — numbers +1, lists drop-last, strings suffixed — and direction can decide the
3702
+ verdict: `ATX-40.languages` is red at 2→3 and was green at 2→1 before it was
3703
+ pinned, so the true-positive count is a floor. And **recall is unmeasured**:
3704
+ the eight params mutated earlier that this detector does not flag all went
3705
+ red, which is eight correct negatives and not a recall figure.
3706
+ The detector and its verdict table are kept in the measurement suite, with the
3707
+ reach recomputed on every run and the table asserted to cover exactly what
3708
+ fires — because the reach figure quoted for `compound-requirement` went stale
3709
+ in this very file for want of that.
3710
+ *A scoping limit on all of the above.* This repo cannot settle blocker **(2)**.
3711
+ `src/` reads no param at all — every `reqs[…].params` in it is a comment or a
3712
+ doc string — so params here are test vocabulary, while in the reporter's repo
3713
+ they are application constants the app itself consumes. The reporter's
3714
+ detector keys on exactly that difference, so it is inert here and this corpus
3715
+ is evidence neither for nor against it.
3716
+ *What the re-measurement did find is a different defect, and it has been split
3717
+ out below* — the entry is kept whole here because the two were measured in one
3718
+ pass and separating them is the finding.
3270
3719
 
3271
3720
  - **Whether `check` and `verify` should refuse a half-loaded registry the way
3272
3721
  `cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
@@ -3388,6 +3837,41 @@ have to gather again.
3388
3837
  because a project whose requirement count only rises should have decided in
3389
3838
  advance what would make one leave.
3390
3839
 
3840
+ - **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
3841
+ several.** `shellArg` asserts the argument holds no `"` and then quotes only
3842
+ when it holds whitespace, so an argument with none goes to the shell bare —
3843
+ and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
3844
+ does not name. The fix is one character class, which is why the entry is not
3845
+ about the fix.
3846
+ *What blocks the decision is that no input reaches it.* Both arguments are
3847
+ either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
3848
+ and CI is Linux, and the one path an attacker could plausibly influence — the
3849
+ checkout directory on a fork MR pipeline — comes from a GitLab project path,
3850
+ whose character set excludes every metacharacter above. So this is an
3851
+ observation with no attack path, and the honest question is not whether to
3852
+ widen the class but whether the comment above it should stop claiming more
3853
+ than the assertion covers: it says "the assertion keeps that true" about
3854
+ arguments generally, where what it checks is one character. Widening the class
3855
+ and leaving the prose is the change that would look like a fix and settle
3856
+ nothing.
3857
+
3858
+ - **A loader that fails to start leaves its scratch directory behind.**
3859
+ `createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
3860
+ and the only `rm` of that directory is inside the `close` of the object
3861
+ `createServer` returns — so a throw from it leaks one `attest-loader-*` per
3862
+ invocation, which on a CI runner is one per build. That is the exact failure
3863
+ shape the comment on `close` records as already fixed, surviving on the other
3864
+ path.
3865
+ *What blocks it is that nothing has been made to throw there.* With
3866
+ `configFile: false` Vite still reads the `package.json` at the root to decide
3867
+ `type`, so a malformed one in the checked project is the obvious candidate and
3868
+ it is the project's to write — but it was not tried, and an entry claiming a
3869
+ trigger it has not seen is the thing this section exists to not accumulate.
3870
+ The repair is three lines (a `try` around the two calls that `rm`s and
3871
+ rethrows) and is not what the decision turns on; measuring whether the throw
3872
+ is reachable at all is, because if it is not, this is a leak with no input
3873
+ that produces it.
3874
+
3391
3875
  ## Considered and rejected
3392
3876
 
3393
3877
  Decisions **not** to build something, kept where they can be found before the
@@ -3712,42 +4196,8 @@ requirement, and human review at propose is still the whole answer. What is
3712
4196
  rejected is grouping as a way to assist it, and this reopens only on a
3713
4197
  contradiction that grouping would have caught.
3714
4198
 
3715
- ### Nested objects in `params`, so a lookup table can be registry-owned
3716
-
3717
- Raised by `mine-capablanca` (0.4.2) and explicitly not as a request, which is
3718
- why it is recorded with its own answer rather than left as a complaint about a
3719
- message. A `params` value is a scalar or a list of scalars; a table — piece kind
3720
- to weight, tier to limit — is refused, and the only way to register one is to
3721
- split it across parallel arrays, which reads worse in the rendered spec than it
3722
- buys. *What rejected it: the two properties a param has, and a table has
3723
- neither.* A param is **rendered into `SPEC.md` as a value a human checks at a
3724
- glance**, and it is **read by a scenario as the one place that value lives**. A
3725
- nested object fails the first by construction — there is no reading of a
3726
- five-row table that a reviewer verifies the way they verify `30`. It fails the
3727
- second more quietly: a table has many readers by definition, so "the one place"
3728
- becomes "the one file", which is what an ordinary module already is. The
3729
- schema's own comment records where the line was drawn and why arrays were let
3730
- in — list constants are the most drift-prone values, and an array still has one
3731
- owner read by one scenario. A table does not clear that bar; the reason arrays
3732
- did is exactly the reason it does not.
3733
- *The reporter's own resolution is the argument's strongest form.* The table went
3734
- to a shared module, is not registry-owned, and **nothing was lost** — because a
3735
- piece-value table is tuning, not a promise. That is the rule design §11 now
3736
- states — *params for promises, plain constants for tuning* — derived
3737
- independently by the same adopter before it was written anywhere. Under it this
3738
- candidate is not a limitation of `params` at all: it is a value that was never a
3739
- requirement's to hold, and §11 carries this rejection's own argument in the same
3740
- paragraph, as the rule stated over the type rather than over the value.
3741
- *What ships instead*, in `[0.5.0]`: the refusal now says what is accepted
3742
- instead of `Invalid input`, and `troubleshooting.md` states the constraint and
3743
- where such a value belongs — the reporter's actual ask, which was for the
3744
- decision to be visible rather than discovered by reading the shipped `.d.ts`.
3745
- The permanent half goes to design §11 with the params rule, not here.
3746
- *This reopens on* a report where the value is genuinely promised — a table an
3747
- adopter owes their users, whose rows a reviewer would want in the rendered spec.
3748
- None has been seen; the one that raised it was tuning.
3749
-
3750
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.0...main
4199
+ [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
4200
+ [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
3751
4201
  [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
3752
4202
  [0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
3753
4203
  [0.5.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.5.0