@am_shork/attest 0.9.3 → 0.9.4

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,238 @@ 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.9.4] - 2026-08-19
17
+
18
+ ### Added
19
+
20
+ - **A requirement can say a value is not decided yet, as `open` (`ATX-81`,
21
+ `ATX-82`).** The registry had two states, schema-valid or an ERROR, and there
22
+ was no third — so an author who could not determine a value had nowhere to put
23
+ that fact. What they do instead is pick one, and a scenario written against an
24
+ invented number is drift no gate can see: the single source is perfectly
25
+ consistent and describes a promise nobody made, arriving through the mechanism
26
+ meant to stop it. `open` is a free-text list of what is still undecided about a
27
+ requirement, one entry per question. The static commands report each as a
28
+ `requirement-open` WARNING and keep their exit code; the archive gate refuses
29
+ with `open-unresolved` while any remains. Promoted from *Under consideration*,
30
+ where the blockers were an unmeasured frequency and the permanent surface a
31
+ schema field costs.
32
+
33
+ *The asymmetry is the design, and it is `never-red`'s.* A proposal may be
34
+ legitimately half-finished while it is being written, and the four static
35
+ commands are where an author works during that; failing there would push them
36
+ back to inventing a value, which is the behaviour being replaced. Archiving is
37
+ where "done" acquires a hard definition, and a registry carrying an unanswered
38
+ question is not done.
39
+
40
+ *Re-measuring the entry before implementing it corrected it in four places,
41
+ per this file's own discipline.* **(1)** The proposed "WARNING from `check`,
42
+ ERROR at the gate" cannot be one diagnostic at two levels: `evaluateGate`'s
43
+ structure step already filters `validateStructure` to ERROR, so every WARNING
44
+ is discarded there. It needs its own gate step, which is what `never-red`
45
+ being the stated precedent actually implies. **(2)** The scope is the whole
46
+ merged registry, not the ids the change ADDs — and that is where it parts
47
+ company with `never-red`, whose narrower scope exists because "was ever
48
+ observed failing" is a fact about history that cannot be demanded
49
+ retroactively. An open question is a claim about the present. **(3)** The
50
+ entry over-counted the static extractor, which reads any literal property and
51
+ needed nothing, and under-counted `render`: without a section there, `SPEC.md`
52
+ — the only human review surface a requirement has — would not show that a
53
+ shipped requirement is open.
54
+
55
+ ***(4)* is the one that had to ship in the same change, and it is the
56
+ question `Planned`'s preamble says to ask.** `splice.ts` carries the comment
57
+ *"Any other field is one the schema does not define, and not this module's to
58
+ rewrite or to remove"*, and that sentence is true **only while `open` is off
59
+ the schema**. Adding the field without adding the two writer branches would
60
+ mean a change that *answers* a question is approved by the gate against an end
61
+ state the registry on disk does not match — the merge commits a file still
62
+ carrying the closed question, with nothing anywhere comparing the two. Not a
63
+ path the entry's own reasoning could reach, because it reasons about the field
64
+ being added and this is about the field being removed.
65
+
66
+ *Cost, stated rather than hidden.* One unresolved question anywhere blocks
67
+ every unrelated change from archiving. That is the intended reading of a
68
+ registry that is not done, it is recoverable by answering the question, and it
69
+ is the same all-or-nothing shape `verify` already has. `SCHEMA_VERSION` is
70
+ unchanged: two new `code` values are additive to the `--json` envelope. The
71
+ type surface **does** move — `Requirement` gains a required `open`, so code
72
+ constructing one by hand rather than through `defineRequirements` needs the
73
+ field. In this repository that was 26 sites, every one of them a test literal
74
+ that already spelled `outOfScope: []`; no adopter reading requirements is
75
+ affected, and `defineRequirements` still takes it as optional.
76
+
77
+ - **This repository ran its own `changes/` → `status` → `archive` workflow for
78
+ the first time, and the change above is what went through it.** Every one of
79
+ the 80 requirements before it was written straight into the registry, so
80
+ `archive/` did not exist and `inspectRedEvidence` — shipped in `[0.9.3]` to
81
+ read it — reported all 252 declared scenarios as `unarchived`. The export had
82
+ no consumer that was not a test of itself for the second release running, and
83
+ this time the reason was not that nothing had been built on it but that the
84
+ repository had never produced the input it reads.
85
+
86
+ **The gate's first run failed on two of the five scenarios, and it was right.**
87
+ Both were the negative half of a pair — "says nothing when the list is empty",
88
+ "lets the gate through once every question is closed" — and both passed on
89
+ their first run, because an engine that has never heard of the field satisfies
90
+ "stays silent" trivially. `never-red` named them individually. The repair is
91
+ one scenario asserting the *discrimination* rather than two asserting each
92
+ side, which fails now and still refuses an implementation that warns
93
+ unconditionally later. This is the mechanism catching the exact defect class
94
+ the tautology entry under *Under consideration* is about, on the first change
95
+ it was ever pointed at, in the author's own work.
96
+
97
+ *A second defect surfaced after `--apply` and is worth recording because
98
+ nothing catches it.* The proposed spec read its params through `delta.added`
99
+ rather than by id. A delta is *also* keyed by the ids it ADDs
100
+ (`withProposedRequirements`), which is what makes the import the only line the
101
+ merge has to change — reaching through `added` loads during stage 1 and throws
102
+ once the import is repointed at the registry. The gate cannot see it, because
103
+ the gate runs before the rename; `check` cannot, because it executes nothing.
104
+ It appeared at the next `verify` as `spec-load-failed`. The workflow document
105
+ already shows the by-id accessor; what it does not say is that the other one
106
+ works until it doesn't.
107
+
108
+ *What the archive now reports*, and it is the shape the design predicts rather
109
+ than a backlog: one archived change, 5 `fail` rows, 228 `unarchived`. The two
110
+ retired scenario names remain in `first-run.json` as `pass` and produce no
111
+ rows at all, which is `inspectRedEvidence`'s documented behaviour for a record
112
+ naming a scenario no spec declares any more — measured here for the first time
113
+ rather than reasoned about.
114
+
115
+ ### Changed
116
+
117
+ - **The publish job publishes through `npm` and requests an npm OIDC token, so
118
+ trusted publishing becomes a setting rather than a rewrite.** The provenance
119
+ work in `[0.7.2]` signs the release but does not change what authorises it: a
120
+ long-lived automation token, masked and protected, that publishes as us to
121
+ anyone holding it. This is the half of removing it that lives in the
122
+ repository. Promoted from `Planned`; what remains there is narrowed to the two
123
+ npmjs.com settings that actually retire the credential.
124
+
125
+ *The one thing the entry was promoted with as unmeasured is now measured, and
126
+ it decides the rollout.* The question was whether a failed OIDC exchange falls
127
+ back to the `_authToken` already in `.npmrc` or fails the publish outright.
128
+ It falls back. Read off npm 11.10.1's `lib/utils/oidc.js` rather than inferred:
129
+ the function documents itself as intended never to throw, every failure path
130
+ logs at verbose and returns, the whole body sits inside a `try/catch` that also
131
+ returns, and `opts[authTokenKey]` is assigned only on success. So this lands
132
+ beside the existing token with nothing registered yet, and a failed exchange is
133
+ a quiet fall back rather than a failed release — which is what makes the first
134
+ tagged release a cheap test instead of an irreversible bet.
135
+
136
+ *`pnpm publish` was never going to work, and that is now an observation rather
137
+ than a strong inference.* The entry reasoned from `pnpm/pnpm#11526` that pnpm
138
+ gets out of the way and npm does the work. The call site confirms it:
139
+ `npm/lib/commands/publish.js` invokes `lib/utils/oidc.js`, so the exchange is
140
+ inside npm's publish command and no flag passed to pnpm reaches it. Hence the
141
+ client swap, which is a cost this file has already priced once in the other
142
+ direction.
143
+
144
+ *Two details the swap turned up that the entry had not counted.* `npm publish`
145
+ has no `--no-git-checks` — it warns *"Unknown cli config"* and does no git
146
+ checks at all — so the flag is dropped rather than translated. And the job now
147
+ runs `corepack enable pnpm` rather than the bare `corepack enable` plus the
148
+ `COREPACK_ENABLE_STRICT` override the entry expected to re-add: naming the shim
149
+ leaves the image's own npm alone for `npm i -g npm@^11.5.1` to upgrade, which
150
+ is the same outcome with one fewer environment variable and no refusal that can
151
+ be switched off by accident. `--provenance` stays explicit even though npm's
152
+ OIDC path can enable it by itself, because it only does so while `provenance`
153
+ is at its default — passing it keeps the setting this job's decision rather
154
+ than a side effect of whether the exchange succeeded.
155
+
156
+ *What is not claimed.* Nothing here has run. The job is exercised once per
157
+ release and cannot be tested any other way, which is why the fallback
158
+ measurement came first and why the token and its guard both stay. `NPM_TOKEN`
159
+ is still required and still what authorises the publish until a release is
160
+ observed going out without it.
161
+
162
+
163
+ - **This repository's own judgement table now reads its scenario names through
164
+ `@am_shork/attest/inspect`, and what that measured is the point of the
165
+ change.** `intent-quality.md` names one read as the rot check every hand-judged
166
+ table needs — that a scenario a row claims is one a spec really declares — and
167
+ tells an adopter to get it from `inspectProject`. This repository's table got
168
+ it from `parseSpecs` and `findFiles` directly, because it was written while
169
+ both were internal. So the export shipped in `[0.9.2]` had **no consumer that
170
+ was not a test of itself**: `self/inspect.spec.ts` attests it, and nothing
171
+ built anything on it. Passing its own tests is not evidence that an export is
172
+ sufficient for the thing it was added for, and the only party who could have
173
+ found out otherwise was an adopter.
174
+
175
+ *Equivalence was measured rather than assumed, in both directions.*
176
+ `scanProject` sorts the same `findFiles` walk with the same `isSpecFile`
177
+ predicate, so the two reads should agree — and they do, compared as sorted
178
+ `(reqId, names)` pairs over the whole repo before the old call was deleted.
179
+ Then mutation-checked, because an assertion that still passes when the read
180
+ returns nothing is not reading anything: with the map stubbed empty the
181
+ rot-binding test fails and the other nine pass, which is the blast radius the
182
+ call actually has.
183
+
184
+ **What would not go through the export is the finding, and it is one thing.**
185
+ `Inspection.registry` is merged across registry files, so an id arrives with no
186
+ record of which file declared it. `corpus()` needs that — `source` is the scope
187
+ a cross-requirement rule sees, `divergentParam` keys on it, and the DOMAINS
188
+ completeness check uses it to hold the fixture registries out — and nothing in
189
+ the public export can reconstruct it. That half still deep-imports
190
+ `readRegistrySource`, with the reason written at the call site rather than left
191
+ as an unexplained inconsistency. It is filed as a measurement and **not** as a
192
+ decision to add a field: one caller wanting per-file attribution is one
193
+ adapter, and the entry under *Under consideration* about an outward-facing
194
+ judgement schema is waiting on exactly the second one.
195
+
196
+ *Cost.* `inspectProject` reads the registry as well, which this caller does not
197
+ need — one extra static read on a tree `corpus()` already walks once per call,
198
+ seven times per run. Paid deliberately: the alternative is deep-importing the
199
+ function whose reachability from outside the package is the thing being
200
+ demonstrated.
201
+
202
+ ### Documentation
203
+
204
+ - **Design §11 now states that evidence outranks testimony, and that a
205
+ disagreement between them is a finding.** The registry and the gate's verdict
206
+ are evidence — machine-checked, recomputable, true of the tree as it stands. An
207
+ archived `proposal.md` is testimony: prose about a decision taken on one past
208
+ date that nothing has re-read since. A reader holding both holds claims in two
209
+ tenses with no stated order, and the order matters most exactly when they
210
+ conflict, because a stale proposal is more fluent than a registry and reads as
211
+ the more authoritative of the two. Decided from *Under consideration*, where it
212
+ had sat behind a blocker that was circular.
213
+
214
+ *The blocker was circular and naming the circle is what dissolved it.* The rule
215
+ was held back for describing a workflow the tool does not have — nothing read
216
+ the prose half — and the prose half was read by nothing **because no rule
217
+ existed for reading it**. Neither side moves on its own. The way out is the
218
+ split `[0.9.3]` already made for the propose-stage questions, where two of five
219
+ went to `CLAUDE.md` rather than into the document `init` writes: **this lands in
220
+ §11 only, and deliberately not in the instruction document.** §11 is a permanent
221
+ property of the design and its cost is paid once, here; the §10 document's cost
222
+ is paid in every adopting project's agent context forever, and that cost is what
223
+ the blocker was actually about. It is also the statement any later retrieval
224
+ surface would have to be built against, so writing it first is cheaper than
225
+ writing it after something depends on it.
226
+
227
+ *Re-measuring it before writing found the entry's own subject had stopped being
228
+ hypothetical, and falsified something this file recorded.* `[0.9.3]` states that
229
+ `proposal.md` in this repository "carries a link" — `CLAUDE.md` makes
230
+ `CHANGELOG.md` the single decision record, so a proposal restating an argument
231
+ would be a second store of it with nothing comparing the two — and concludes
232
+ there is nothing in it for an index to offer but its path. The very next change
233
+ to archive carries a proposal of **sixty-nine lines across five sections**, most
234
+ of its argument also present in the changelog entry. So the second store arrived
235
+ by ordinary means, in the first change that could produce one, written by an
236
+ author who knew the rule. The archived file is left as it is rather than trimmed
237
+ to a pointer: it is what the gate approved, and rewriting an archive to agree
238
+ with a claim about it is the failure this section is about, performed on itself.
239
+ §11 cites it as the measurement instead.
240
+
241
+ *What is deliberately not claimed.* The typed half cannot produce this conflict
242
+ — `first-run.json` is an `Outcome` in a schema-validated envelope, and a
243
+ scenario either is in the record or is not, so `inspectRedEvidence` reports an
244
+ absence rather than a contradiction. Nothing enforces the rule and nothing can:
245
+ reconciling prose against a registry is the ground truth §0 refuses to compute.
246
+ What §11 states is only the direction to reconcile in.
247
+
16
248
  ## [0.9.3] - 2026-08-18
17
249
 
18
250
  ### Added
@@ -5194,7 +5426,7 @@ time**.
5194
5426
 
5195
5427
  *The first time that question was asked in advance, it paid, and how it paid is
5196
5428
  worth a line because it is not what the two cases above would predict.* The
5197
- `check` `empty-spec` entry — `[Unreleased]`, the third entry to reproduce exactly
5429
+ `check` `empty-spec` entry — `[0.9.0]`, the third entry to reproduce exactly
5198
5430
  on its reported half — was not wrong about its own repair. Asking the question
5199
5431
  anyway found the defect one step to the side: the fix it proposed was correct,
5200
5432
  and the *obvious implementation of it* — make `check` agree with `verify` —
@@ -5205,82 +5437,32 @@ path the repair makes reachable; it is about **everything the repair would make
5205
5437
  `check` agree with**, and an entry that names the right fix can still be
5206
5438
  implemented wrongly by copying the half nobody has audited.
5207
5439
 
5208
- - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
5209
- The provenance work in `[0.7.2]` signs the release but does not change
5210
- what authorises it: a long-lived automation token, masked and protected, that
5211
- publishes as us to anyone holding it. npm's trusted publishing exchanges the
5212
- same OIDC identity the attestation already uses for a short-lived credential,
5213
- which would remove the stored secret entirely and grant provenance as a
5214
- property of the flow rather than a flag on the command. The deletion test
5215
- points the right way for once: what it removes is the only long-lived
5216
- credential this repository has.
5217
- **The blocker this entry was filed with is retired, and it was the wrong
5218
- question** checked against npm's documentation on 2026-08-08. It asked
5219
- whether npm's trusted publishers accept a **gitlab.com-hosted** project on the
5220
- plan this package publishes under, and both halves come back clean: GitLab
5221
- CI/CD is a first-class provider alongside GitHub Actions, and no plan
5222
- requirement is stated anywhere in the documentation. The four fields it wants
5223
- are `Pseudorca`, `attest`, the top-level CI file path (`.gitlab-ci.yml`, and it
5224
- must carry the extension) and the allowed actions; environment name is optional
5225
- and this project uses no GitLab environments. Shared runners are the only ones
5226
- supported and the publish job carries no `tags:`, so it is already on them. The
5227
- `repository.url` cross-check needs no separate verification provenance
5228
- already passes it with the same identity.
5229
- Two things the entry did not anticipate, both of which change the shape of the
5230
- change rather than blocking it. **`SIGSTORE_ID_TOKEN` is not replaced**: the
5231
- documented GitLab snippet declares `NPM_ID_TOKEN` with
5232
- `aud: "npm:registry.npmjs.org"` *beside* it, so this is an addition to the
5233
- `id_tokens` block, not a swap. And **the YAML is not what deletes the
5234
- credential** — enabling a trusted publisher leaves the automation token just as
5235
- able to publish; what retires it is `Require two-factor authentication and
5236
- disallow tokens` on the package, which npm states does not affect trusted
5237
- publishers. That setting is the one that closes the loop this job's own comment
5238
- opens, where an automation token is used *because* a granular token fails
5239
- against 2FA on writes: trusted publishing does not bypass the prompt, it stops
5240
- being a token.
5241
- *What blocks the decision now is `pnpm`, and it is not a version to wait for.*
5242
- **pnpm does not implement the OIDC exchange at all.** `pnpm/pnpm#11526` — the
5243
- fix for the pnpm 11 regression that made this look like a supported path —
5244
- shows what its "OIDC support" is: `actions/setup-node` writes
5245
- `_authToken=${NODE_AUTH_TOKEN}` into `.npmrc`, pnpm used to pass the unresolved
5246
- placeholder through verbatim, and the fix treats it as empty so that **npm's**
5247
- credential exchange can be the sole auth source. pnpm gets out of the way; npm
5248
- does the work. That matches npm's own wording, which requires publishing
5249
- through npm directly or indirectly, and it matches the reports: every account
5250
- of pnpm publishing under OIDC is GitHub Actions, and there is **no record of
5251
- pnpm + GitLab + OIDC succeeding**. Stated at the strength the evidence carries:
5252
- this is a strong negative inference, not a documented refusal.
5253
- So the real cost is a tooling swap — `pnpm publish` back to `npm publish` in
5254
- this one job — and it is a swap this file has already priced once in the other
5255
- direction. The comment above records that moving to pnpm took
5256
- `COREPACK_ENABLE_STRICT` with it, that override having existed only so a bare
5257
- `npm` could run under a `packageManager` pinning pnpm; going back re-adds it.
5258
- It also needs npm ≥ 11.5.1 where `node:22-slim` ships 10.x, so the job gains an
5259
- npm upgrade step. None of that is prohibitive. It is simply a different
5260
- decision from the one this entry was filed as, which is why it stays here.
5261
- *One correction to the entry's own risk framing.* "It cannot be tested except
5262
- by publishing" is half wrong: registering a trusted publisher on npmjs.com is
5263
- free and reversible, and `NPM_TOKEN` can stay in place while it is tried. What
5264
- decides whether this can be rolled out incrementally is whether a failed OIDC
5265
- exchange falls back to the `_authToken` already in `.npmrc` or fails the
5266
- publish outright — unmeasured, and the thing to establish before touching the
5267
- job.
5268
- *Method note, because it is the reusable part.* The pinned pnpm bundle was the
5269
- obvious place to look and was the wrong one — `pnpm publish --help` on 10.28.0
5270
- omits the flags it accepts, exactly as this job's comment already records for
5271
- `--provenance`, so neither the help text nor the shipped bundle would have
5272
- answered it. The answer was in the upstream pull request.
5273
- **Promoted from `Under consideration` on 2026-08-08**, and what moved it is
5274
- that the question it was filed as is answered. It asked whether npm's trusted
5275
- publishers would accept this project at all; they will. What is left is a
5276
- known tooling swap with a priced cost, which is a decision to build rather
5277
- than a question — the preamble's own line, *a decision with an argument
5278
- attached*. One thing stays unmeasured and is the first act of building it, per
5279
- the discipline three paragraphs up: whether a failed OIDC exchange falls back
5280
- to the `_authToken` already in `.npmrc` or fails the publish outright. That
5281
- decides whether this rolls out incrementally beside the existing token or in
5282
- one cut, and it is cheap to establish because registering a trusted publisher
5283
- is free and reversible.
5440
+ - **Retiring `NPM_TOKEN`, which is now two settings on npmjs.com rather than
5441
+ anything in this repository.** The mechanism landed under `[0.9.4]`: the
5442
+ publish job requests an npm OIDC token and publishes through `npm`, which is
5443
+ the only client that performs the exchange. What that did **not** do is remove
5444
+ the credential, and the distinction is the point — enabling a trusted publisher
5445
+ leaves an automation token just as able to publish. Two acts remain, neither of
5446
+ them a commit:
5447
+ **(1)** register the trusted publisher on the package `Pseudorca`, `attest`,
5448
+ `.gitlab-ci.yml` with the extension, the allowed actions; environment name is
5449
+ optional and this project uses none, and shared runners are the only supported
5450
+ kind, which the job already uses because it carries no `tags:`. Free and
5451
+ reversible, and safe to do at any time: a failed exchange falls back to the
5452
+ token rather than failing the publish (measured see the `[0.9.4]`
5453
+ entry). **(2)** set *Require two-factor authentication and disallow tokens* on
5454
+ the package, which npm states does not affect trusted publishers. That is the
5455
+ one that closes the loop the job's own comment opens, where an automation token
5456
+ is used *because* a granular token fails against 2FA on writes: trusted
5457
+ publishing does not bypass the prompt, it stops being a token.
5458
+ *What is unmeasured, and it is not measurable from here.* Whether the exchange
5459
+ actually succeeds against a gitlab.com project. Every documented ingredient
5460
+ checks out and no plan requirement is stated anywhere, but there is no record
5461
+ of anyone publishing from GitLab under OIDC every account is GitHub Actions.
5462
+ The first tagged release after the `[0.9.4]` change is the test, and the
5463
+ fallback is what makes running it cheap: if the exchange fails, the release
5464
+ still publishes and the job's verbose log says why. **Do not delete the token
5465
+ before a release has been observed publishing without it.**
5284
5466
 
5285
5467
  ## Under consideration
5286
5468
 
@@ -5770,55 +5952,45 @@ have to gather again.
5770
5952
  design it. If it comes back the same shape, the seam is real and the schema
5771
5953
  follows; if it comes back different, `inspect` was the whole answer and the
5772
5954
  schema never needs to exist.
5773
-
5774
- - **A registry cannot say a value is deliberately undecided, and an agent that
5775
- cannot determine one will invent it.** From a read of GitHub's spec-kit
5776
- against this design the first time another SDD toolkit has been compared
5777
- here rather than OpenSpec, and the source of this entry and the three that
5778
- followed it of which two have since been decided: the audit of this
5779
- repository's own rules is rejected below, and the propose-stage coverage scan
5780
- shipped under `[0.9.3]`, as three questions rather than nine categories.
5781
- spec-kit marks an unresolved decision inline as `[NEEDS CLARIFICATION: …]`,
5782
- greppable, with a command whose whole job is converging them. Attest's
5783
- registry has two states: schema-valid, or an ERROR. There is no third.
5784
- *Why that matters here specifically, rather than as a general nicety.* The
5785
- workflow is written for an agent to follow — that is what `init` writes — and
5786
- an agent drafting a delta that cannot determine a timeout does not leave the
5787
- field out, because the schema will not let it. It picks a number. A fabricated
5788
- `params` value, then pinned by a scenario written to match it, is exactly the
5789
- drift this project exists to prevent, arriving through the mechanism meant to
5790
- stop it: the single source is perfectly consistent and describes a promise
5791
- nobody made.
5792
- *A precedent that is not one, recorded because it reads like one.*
5793
- `rationale-placeholder` already warns about authoring sloppiness in a prose
5794
- field, and the `init` document already teaches it but it is about
5795
- interpolation syntax written into the wrong field, not about a value nobody
5796
- has decided. The engine's willingness to warn on prose is the precedent; the
5797
- diagnostic is not the feature.
5798
- *The gap is narrower than "the registry cannot express doubt".* `changes/` is
5799
- already the place where a whole proposal is undecided, and the archive gate is
5800
- what settles it. What has no expression is **one field** of an otherwise
5801
- well-formed requirement.
5802
- *Shape, if it is built:* an additive `open?: string[]`, naming the fields or
5803
- questions still unresolved; a WARNING from `check`, so a proposal may be
5804
- legitimately half-finished, and an ERROR at the archive gate, so "done" is not
5805
- diluted. The same asymmetry `never-red` already runs on.
5806
- *What blocks the decision — three things.* **(1)** The frequency is entirely
5807
- unmeasured. No adoption report has recorded an agent inventing a param; the
5808
- argument above is structural, and this project has twice been wrong about a
5809
- hazard it reasoned to rather than measured. **(2)** The cheap half already
5810
- works: writing the uncertainty into `rationale` as prose costs nothing, and
5811
- the propose stage is human-reviewed regardless. What that does not buy is a
5812
- gate, which is the whole question. **(3)** It is permanent surface — a schema
5813
- field, a code, two troubleshooting sections, a History row, an `ATX-n` and its
5814
- scenarios forever — for a hazard nobody has yet reported.
5955
+ **Nothing currently makes that table arrive, which is a separate problem from
5956
+ the three blockers and the one that gates all of them.** Raised in a product
5957
+ review of `[0.9.1]`–`[0.9.3]` on 2026-08-19. The deciding evidence is an
5958
+ artifact only an adopter can produce, and the path to producing it is: a row in
5959
+ the README's document table, then a 214-line page, then a hand pass over four
5960
+ questions, then writing the two mechanical properties from a prose
5961
+ description, then choosing to report back. Five voluntary steps with no
5962
+ scaffold at any of them, against a decision this entry has been waiting on
5963
+ since `[0.9.2]`. An entry blocked on evidence that no shipped surface asks for
5964
+ is blocked indefinitely, and the wait is not itself evidence of anything.
5965
+ *The proposal is scaffolding the mechanism while still withholding the answer,*
5966
+ which is the same split `[0.9.2]` already made and not a retreat from it: an
5967
+ `examples/` directory holding the completeness and rot-binding assertions with
5968
+ **no columns** the enumeration that forces a row, and the check that a named
5969
+ scenario is one `inspectProject` really declares and the judgement columns
5970
+ left for the adopter to add. What the page withholds is this repository's four
5971
+ columns, and that stays withheld; what it currently also withholds is the two
5972
+ properties it names as *mechanical*, and those are the half where a copy is not
5973
+ an echo, because their content is fixed and the fourth finding does not apply
5974
+ to them.
5975
+ *What blocks it.* Whether a skeleton with no columns is still a shape that
5976
+ comes back as a copy — the page's argument is that a template makes the answer
5977
+ return its own question, and it is untested whether an adopter reads an empty
5978
+ frame as an invitation or as a form to fill in. And it is a second artifact
5979
+ claiming to be runnable consumer usage of the public API, which `pre-commit-check`
5980
+ §6 already records as the category no gate reaches: the page's three samples
5981
+ are unasserted today, and a fourth in a directory that looks executable would
5982
+ be worse than a fenced block, not better, unless something runs it.
5815
5983
 
5816
5984
  - **Prefix ownership is inferred rather than declared, and the cost arrives only
5817
5985
  when adoption succeeds.** Design §11 records that the id grammar has no level
5818
5986
  above the requirement: the prefix is a convention, nothing allocates it, and
5819
5987
  `duplicate-prefix` can only report a collision that already happened.
5820
5988
  spec-kit's answer is a roadmap table — a stable id, an intent, a scope
5821
- boundary, dependencies — authored above the specs it decomposes into.
5989
+ boundary, dependencies — authored above the specs it decomposes into. That
5990
+ comparison is stated here rather than referred to, because the entry that
5991
+ introduced the spec-kit reading has since been built and now sits under
5992
+ `[0.9.4]` as the `open` field: an entry whose evidence rests on a
5993
+ neighbour is one a promotion silently empties.
5822
5994
  *Two things have changed since that reading, and they point opposite ways.*
5823
5995
  The proposal is **smaller** than it looked: the mechanism partly exists.
5824
5996
  `loadRegistry` returns `prefixOwners` — first claim in sorted file order —
@@ -5857,53 +6029,26 @@ have to gather again.
5857
6029
  rename can move is a second source with nothing comparing it against the
5858
6030
  first. A derived index has no such copy to go stale: it is recomputed from the
5859
6031
  archived deltas every time it is read.
5860
- *What blocks the decision:* the empty archive the subject of the entry that
5861
- shipped under `[0.9.3]`, whose promotion this one does **not** inherit,
5862
- because the two changes that produced that corpus were ADDED-only and
5863
- exercised no rename at allwhich makes a backlink index testable only
5864
- against fixtures; and one question the OpenViking reading
5865
- does not answer, which is whether the chain is wanted at all. §7 applies
6032
+ *What blocks the decision, restated on 2026-08-19 because half of what was
6033
+ filed here is no longer true.* This entry was blocked on the empty archive and
6034
+ on the corpus being rename-free, and only the second still holds. `archive/`
6035
+ now has a real change in it the `open` field, put through the workflow the
6036
+ same day so the store this would index exists. Its delta carries `added` and
6037
+ nothing else, measured rather than assumed, so a backlink index is still
6038
+ testable only against fixtures for the operation it is actually about. Note
6039
+ what that says about the odds of the blocker clearing on its own: `--apply`
6040
+ refuses RENAMED outright, so no change this repository archives can ever
6041
+ exercise a rename until that refusal is lifted, and lifting it is a separate
6042
+ decision with its own reason (`splice.ts` — deleting an entry has no smaller
6043
+ span to fall back to). The blocker is therefore not "waiting for a rename to
6044
+ happen"; it is downstream of a refusal nobody has proposed removing. And one
6045
+ question the OpenViking reading does not answer, which is whether the chain is
6046
+ wanted at all. §7 applies
5866
6047
  `RENAMED` to a `Map<id, Requirement>` and the old id is then simply gone —
5867
6048
  deliberately, as far as anything written says. The case for recovering it is an
5868
6049
  external reference that outlives the rename, a ticket or a commit message
5869
6050
  naming a retired id, and nobody has reported one.
5870
6051
 
5871
- - **The instruction document says how to write intent and nothing about how to
5872
- read the record back.** §10's one-document-many-targets file is entirely about
5873
- authoring, and the moment anything reads `archive/` — the entry above, and the
5874
- one that shipped under `[0.9.3]` — an
5875
- agent is holding prose from a proposal agreed on some past date against a
5876
- registry that is current, with no stated order between them. OpenViking states
5877
- one for its own store in the skill it ships to coding agents
5878
- (`agent-plugins/skills/openviking-memory/SKILL.md`): retrieved memory is
5879
- advisory, and the order is system and developer instructions, then the current
5880
- request, then current environment and tool evidence, then memory — with "prior
5881
- success never authorizes a destructive action now", and "store conclusions,
5882
- not scrollback".
5883
- *Here the same rule has a harder form, because the authoritative half is
5884
- machine-checked.* The registry and the gate's verdict are evidence; an
5885
- archived proposal is testimony about a decision taken once. Where they
5886
- disagree, the disagreement is a finding — the exact thing this tool exists to
5887
- report — and not something for a reader to reconcile quietly in favour of
5888
- whichever it read last. That is a property of the design rather than of a
5889
- file, so if it is taken it lands in design §11 first and in the instruction
5890
- document second.
5891
- *What blocks the decision:* it is a rule for reading a store nothing reads yet,
5892
- so on its own it would document a hazard that cannot currently arise. It is the
5893
- cheapest of the three and could ship first, at the price of being the only
5894
- paragraph in that file describing a workflow the tool does not have — and §10's
5895
- economics make that price the same in every adopter's agent instructions at
5896
- once.
5897
- **That blocker is retired, and what replaced it is narrower than the entry
5898
- assumed.** Something reads `archive/` as of `[0.9.3]`, so the hazard can
5899
- now arise. But what shipped reads the **typed** half — `first-run.json`, whose
5900
- every value is an `Outcome` in a schema-validated envelope — and evidence that
5901
- parses cannot disagree with the registry in the way testimony can: a scenario
5902
- either is in the record or is not. The rule this entry wants is about the prose
5903
- half, and the prose half is still read by nothing. So the store is no longer
5904
- write-only and the entry's own subject is still hypothetical, which is a
5905
- different blocker from the one it was filed with rather than none.
5906
-
5907
6052
  - **Changing the value of an existing param passes no gate, and for seven of
5908
6053
  them there is no record either.** Split out of the tautology entry at the
5909
6054
  head of this section on 2026-08-18, where it was one sentence inside the
@@ -5956,6 +6101,150 @@ have to gather again.
5956
6101
  looking at already being that page's subject, and this being a fifth of the
5957
6102
  same kind.
5958
6103
 
6104
+ - **`/inspect` returns five exported shapes and the page telling people to build
6105
+ on them says nothing about whether they move.** Filed 2026-08-19 from a product
6106
+ review of `[0.9.1]`–`[0.9.3]`. `inspect.ts` exports `Inspection`,
6107
+ `ScenarioEvidence`, `EvidenceRow`, `ArchivedChangeRef` and
6108
+ `RedEvidenceInspection`, and `intent-quality.md` carries three samples
6109
+ destructuring them. `SCHEMA_VERSION` governs the `--json` envelope and nothing
6110
+ governs these.
6111
+ *The policy is not missing — its statement is.* The Versioning section at the
6112
+ head of this file already binds them: removing or renaming a public API bumps
6113
+ the minor, and under 0.x that is the breaking bump. So a reader who finds that
6114
+ section is already told what they need. The gap is that the reader this export
6115
+ was written for arrives through `intent-quality.md`, which is a method page
6116
+ reached from the README's document table, and nothing on the path between the
6117
+ two states it.
6118
+ *Why it is worth a decision rather than a one-line edit.* The two candidate
6119
+ sentences say opposite things to the person this needs. Restating the existing
6120
+ promise reads as a stability guarantee and is the honest summary of current
6121
+ policy; saying the shape may move before 1.0 is the more useful warning and
6122
+ discourages exactly the adopter whose table would decide the judgement-schema
6123
+ entry above. Which one is right depends on the 1.0 entry below, which is why
6124
+ this is filed rather than fixed.
6125
+ *One thing already measured, in the other direction.* `pre-commit-check` §6
6126
+ records that changing what `/inspect` returns breaks three usage samples that
6127
+ no gate can see. The internal awareness of this surface's fragility therefore
6128
+ exists and is written down; the outward-facing half is what does not.
6129
+
6130
+ - **The store worth retrieving is this file, not `archive/`, and no shape has
6131
+ been proposed for either.** Filed 2026-08-19 from a product review of
6132
+ `[0.9.1]`–`[0.9.3]`, and it is the OpenViking reading turned back on the
6133
+ repository that did the reading. Everything that came out of it is about
6134
+ `archive/`, and the entries are no longer counted here — the number was wrong
6135
+ on the day it was written, was corrected on 2026-08-19, and was wrong again by
6136
+ the next commit, when the precedence rule left for design §11. Three strikes on
6137
+ one figure is the section's own preamble arriving in the first person: a count
6138
+ kept in prose with nothing to check it goes wrong quietly, and the repair is to
6139
+ stop keeping one rather than to keep fixing it. But `archive/` holds `first-run.json` — typed,
6140
+ low-information, and now read — beside a `proposal.md` that in this repository
6141
+ carries a link, for the reason `[0.9.3]` gives: `CLAUDE.md` makes this file the
6142
+ single decision record. **So the store that actually holds decisions with their
6143
+ evidence attached is `CHANGELOG.md`, at 7,100 lines with the three foot
6144
+ sections past 1,700 of them,** and the property that makes it valuable — a
6145
+ design evaluated, priced and killed, with the reasoning that killed it — is
6146
+ exactly the property that makes it unreadable in one pass. It is append-only
6147
+ with no retrieval, which is the shape the OpenViking reading was about.
6148
+ *What an agent would actually consume is a composition, not a new store.*
6149
+ Given an `ATX-n`: its statement and params, the scenarios covering it, the
6150
+ first-run evidence for each, and the paths to the change that introduced it.
6151
+ Four of the five are already computable — `inspectProject` answers two,
6152
+ `inspectRedEvidence` the third and the archived refs the fourth — and the fifth,
6153
+ the reasoning, is the one with no index and no key. That asymmetry is the
6154
+ finding: the retrieval problem is not in the half that was just made readable.
6155
+ *One thing to unmerge before deciding.* `[0.9.3]` settled a question against
6156
+ the fifth static command, and it settled it correctly and on the right
6157
+ grounds — `first-run.json` is JSON, and `ATX-16`'s subject is reading
6158
+ TypeScript without evaluating it, so joining that roster would turn a security
6159
+ promise into a list of commands that do not run the suite. But that is a
6160
+ ruling about **roster membership**, and it is being carried as though it also
6161
+ ruled on whether such a command should exist. Those are separate, and only the
6162
+ first was argued.
6163
+ *What blocks the decision, and what would cheaply unblock it.* No measurement
6164
+ exists of whether a bundle like this changes what an agent does — the one datum
6165
+ quoted in `[0.9.3]` measured demand to read the archive **as prose**, which is
6166
+ a third question again. The proposal is therefore a probe and not a build,
6167
+ per this section's own discipline of re-measuring before implementing: assemble
6168
+ the bundle by hand for one requirement, run one real change against it, and
6169
+ record whether it was used, ignored, or used wrongly. One session, and it
6170
+ decides between a command, a skill surface, and nothing. Building any of the
6171
+ three before that measurement is the order `divergent-param` was designed in
6172
+ and the order this file has already paid for once.
6173
+
6174
+ - **A stage-1 spec can read its params through `delta.added` instead of by id,
6175
+ and the merge that repoints its import cannot repoint that.** Filed 2026-08-19
6176
+ from the first change this repository put through its own `changes/` workflow,
6177
+ where it was written by the author of the workflow's own tooling — which is
6178
+ most of why it is worth an entry rather than a fix.
6179
+ `delta()` returns the delta *and* the registry of what it adds
6180
+ (`withProposedRequirements`), so `reqs['ATX-81'].params.field` reads a proposed
6181
+ requirement before the merge and a merged one after it. That is the whole
6182
+ reason merging a spec changes its import and nothing else. Reaching through
6183
+ `added` — `d.added['ATX-81'].params.field` — is equally correct during stage 1
6184
+ and throws the moment `--apply` repoints the specifier at the registry, because
6185
+ a registry has no `added`.
6186
+ **Nothing catches it, and the reason is structural rather than an oversight.**
6187
+ The archive gate runs *before* the rename, so the spec it graded is not the
6188
+ spec the merge commits; `check` executes nothing, so it never evaluates the
6189
+ accessor; and the type checker is satisfied on both sides of the merge only
6190
+ because it is run against whichever half is currently on disk. It surfaced at
6191
+ the next `verify`, as `spec-load-failed` — a correct diagnosis of the symptom
6192
+ that names the file and not the cause. The window between `--apply` and the
6193
+ next `verify` is the whole exposure, and in a workflow that ends at `--apply`
6194
+ it is unbounded.
6195
+ **The guidance already exists and did not prevent it, which is the finding.**
6196
+ `registry.ts`'s doc comment on `delta` states the intended expression and the
6197
+ rejected one, in those terms, on the function itself; the document `init`
6198
+ writes shows the by-id form in its worked example. So this is not a gap in what
6199
+ is written. It is a case of the correct form and an incorrect one being
6200
+ *indistinguishable at the moment of writing* — both compile, both pass, both go
6201
+ red for the right reason — with the difference appearing one command later.
6202
+ *Two shapes, and they point opposite ways, which is why this is filed rather
6203
+ than fixed.* **(1)** A `check` diagnostic: `parser.ts` already walks property
6204
+ accesses inside spec bodies to find `.params`, so finding `.added` on a binding
6205
+ imported from a `requirements.delta` path is the same walk and the same file.
6206
+ It has to be a WARNING, because enumerating what a change adds is a legitimate
6207
+ thing for a spec to do, and a WARNING is exactly the strength that did not stop
6208
+ the author here. **(2)** A type-level refusal: have `delta()` return a type
6209
+ without `added`, so `typecheck:all` refuses it with no new diagnostic, no
6210
+ roster entry and no runtime cost. `DefinedDelta<T> = T & ProposedRequirements<T>`
6211
+ carries `added` because `T` does, so this means `Omit<T, 'added'>` — and that
6212
+ breaks the evaluating loader, which hands the same default export to
6213
+ `applyDelta` typed as `RegistryDelta`. Whether that break is real or just needs
6214
+ the internal path to name a different type is **unmeasured, and is the first
6215
+ thing to establish**; it decides between the two shapes rather than being a
6216
+ detail of one.
6217
+ *What blocks the decision:* frequency, and it is the same blocker the `open`
6218
+ entry carried before it — one occurrence, in one repository, on the first
6219
+ change ever run through the workflow. That is a report rather than a
6220
+ hypothetical, which is more than most entries here start with, and it is still
6221
+ n=1. What would settle it cheaply is the next change: if the by-id form is
6222
+ written without prompting, the guidance is sufficient and this is an anecdote;
6223
+ if the reach-through appears again, the two shapes above are worth pricing.
6224
+
6225
+ - **Nothing is frozen, and the evidence this repository is waiting for requires
6226
+ somebody to build on something.** Filed 2026-08-19 from a product review of
6227
+ `[0.9.1]`–`[0.9.3]`. Three releases in five days each added public surface, and
6228
+ as of `[0.9.3]` the CLI, `./define`, `./inspect`, the archive layout, the
6229
+ judgement schema and any retrieval surface are all simultaneously open. Every
6230
+ entry in this section that is blocked on an adopter — the judgement table above
6231
+ being the sharpest — is asking someone to invest in a surface that is
6232
+ documented as movable. That is a coherent thing to ask under 0.x and it is also
6233
+ the reason the ask may never be answered.
6234
+ *The proposal is a line rather than a milestone:* 1.0 freezes the CLI, `./define`
6235
+ and `./inspect`, and everything currently blocked on an adopter's evidence —
6236
+ the judgement schema, the backlink index, any archive retrieval — is explicitly
6237
+ 1.x. What that buys is not a version number; it is the only condition under
6238
+ which "build your own counter-pressure on our export" is a reasonable thing to
6239
+ ask of somebody.
6240
+ *What blocks it.* Freezing `./inspect` five days after shipping it is freezing
6241
+ on one adapter, which is the objection this repository raises against every
6242
+ other premature interface and does not get to waive for its own. And the
6243
+ release-notes discipline cuts both ways here: a 1.0 is a claim about what the
6244
+ cost of adoption will be, and `Planned`'s own preamble records that **what a
6245
+ release costs its adopters is not known on the day it is cut**. There is no
6246
+ measurement to date of anyone adopting either export.
6247
+
5959
6248
  ## Considered and rejected
5960
6249
 
5961
6250
  Decisions **not** to build something, kept where they can be found before the
@@ -6138,7 +6427,7 @@ weaker signal "some registry file failed to load, so suppress all
6138
6427
  `orphan-test`" is the other candidate and is a strictly worse `check` on a
6139
6428
  multi-file registry, where the other files are fine and their orphans are real.
6140
6429
  **The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
6141
- fixed under `[Unreleased]`, and the blocker above was the wrong question.**
6430
+ fixed under `[0.7.4]`, and the blocker above was the wrong question.**
6142
6431
  The mapping this entry called for could not be recovered, and did not have to
6143
6432
  be: what a suppression needs is not the broken file's *prefix* but the ids it
6144
6433
  declares, and those are written in its source. Reading them back is a source
@@ -6164,7 +6453,7 @@ breadth argument recorded above, which points the other way for both commands.
6164
6453
  *What rejected it is that the argument for it was entirely the noise, and the
6165
6454
  noise is gone.* Refusing was never justified by the load failure itself — it was
6166
6455
  justified by the wall of derived findings burying it, and the collapse under
6167
- `[Unreleased]` removed the wall without touching either command's breadth. What
6456
+ `[0.7.4]` removed the wall without touching either command's breadth. What
6168
6457
  is left on the table is the breadth argument alone, and it points the other way
6169
6458
  for both: `check` is the command a pipeline runs first, its contract is seeing
6170
6459
  everything it can still see, and answering with three findings instead of nine
@@ -6265,7 +6554,7 @@ measurement** saying parsing costs anything here — this reader is
6265
6554
  `createSourceFile` per file with no type checking, which is the cheap half of
6266
6555
  what arktype pays for, and the fan-out measurement — filed as an entry in
6267
6556
  `Under consideration`, and now shipped as the two concurrency bounds in
6268
- `[0.7.0]` and `[Unreleased]` — clocked `attest check` over 6000 spec files at
6557
+ `[0.7.0]` and `[0.9.0]` — clocked `attest check` over 6000 spec files at
6269
6558
  1.99 s total. Worse, a cache is a correctness hazard
6270
6559
  of precisely the kind this tool exists to detect: a stale analysis makes a
6271
6560
  **drift detector** report drift that has already been fixed, or miss drift
@@ -6287,7 +6576,7 @@ to be stronger than an mtime before the trade is even worth pricing.
6287
6576
 
6288
6577
  ### Zod Mini (`zod/v4-mini`) for the registry schema
6289
6578
 
6290
- Evaluated alongside v4 (which shipped under `[Unreleased]`), on the same
6579
+ Evaluated alongside v4 (which shipped under `[0.8.0]`), on the same
6291
6580
  schema and the same 69-requirement registry, and it wins both numbers it is
6292
6581
  supposed to win:
6293
6582
  **0.249 ms** per parse against v3's 3.47 ms, and — the one v4 does not deliver —
@@ -6569,7 +6858,7 @@ current.
6569
6858
 
6570
6859
  The mechanism `ATX-37` sat under `Under consideration` waiting for, kept there
6571
6860
  on cost, and killed by measuring that cost rather than by paying it. The three
6572
- sites it was aimed at are closed under `[Unreleased]` by hand-written scenarios;
6861
+ sites it was aimed at are closed under `[0.7.4]` by hand-written scenarios;
6573
6862
  what is rejected is the roster, and the argument is that **`src/cli/report.ts`'s
6574
6863
  exports are the wrong roster in both directions at once.**
6575
6864
 
@@ -6860,6 +7149,7 @@ rather than a scorer, which ranks.
6860
7149
 
6861
7150
  ## Releases
6862
7151
 
7152
+ [0.9.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.4
6863
7153
  [0.9.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.3
6864
7154
  [0.9.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.2
6865
7155
  [0.9.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.1
package/README.md CHANGED
@@ -167,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
167
167
  ```
168
168
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
169
169
  Value is not a literal.
170
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.3/docs/en/troubleshooting.md#registry-not-static
170
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.4/docs/en/troubleshooting.md#registry-not-static
171
171
  ```
172
172
 
173
173
  The anchor **is** the code, so the link cannot point somewhere the section
package/bin/attest.js CHANGED
File without changes
@@ -97,6 +97,10 @@ export function applyDelta(base, d) {
97
97
  ...patch,
98
98
  params: { ...existing.params, ...(patch.params ?? {}) },
99
99
  outOfScope: patch.outOfScope ?? existing.outOfScope,
100
+ // Replaced wholesale rather than merged, exactly as `outOfScope` is: a
101
+ // patch that closes a question says so by writing the list that remains,
102
+ // and a union would make closing the last one impossible to express.
103
+ open: patch.open ?? existing.open,
100
104
  };
101
105
  const parsed = RequirementSchema.safeParse(merged);
102
106
  if (!parsed.success) {
@@ -209,6 +213,7 @@ function canonical(req) {
209
213
  // against itself. Code-unit order throughout, and `sortDeep` says why.
210
214
  params: sortDeep(req.params),
211
215
  outOfScope: [...req.outOfScope],
216
+ open: [...req.open],
212
217
  });
213
218
  }
214
219
  //# sourceMappingURL=apply.js.map
@@ -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", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-from-failed-registry", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
11
+ export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "open-unresolved", "orphan-from-failed-registry", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "requirement-open", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
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
@@ -37,6 +37,7 @@ export const ISSUE_CODES = [
37
37
  'modify-missing',
38
38
  'never-red',
39
39
  'non-scalar-interpolation',
40
+ 'open-unresolved',
40
41
  'orphan-from-failed-registry',
41
42
  'orphan-test',
42
43
  'possible-drift',
@@ -49,6 +50,7 @@ export const ISSUE_CODES = [
49
50
  'rename-source-missing',
50
51
  'rename-target-exists',
51
52
  'rename-target-invalid',
53
+ 'requirement-open',
52
54
  'spec-in-change-dir',
53
55
  'spec-load-failed',
54
56
  'stale-spec-doc',
@@ -120,6 +120,14 @@ export declare function notRunIssues(plan: AttestPlan, run: RunResult, specific?
120
120
  * never a moment when its scenario was supposed to fail.
121
121
  */
122
122
  export declare function neverRedIssues(plan: AttestPlan, addedIds: readonly string[], firstRun: RedRecord): Issue[];
123
+ /**
124
+ * Every requirement that still declares an open question.
125
+ *
126
+ * Exported for the same reason `neverRedIssues` is: it is one of the gate's
127
+ * obligations, and a caller projecting the gate's verdict has to reach the same
128
+ * answer from the same function rather than re-deriving it.
129
+ */
130
+ export declare function openIssues(registry: Registry): Issue[];
123
131
  /**
124
132
  * Decide whether a change may be archived. Returns the blocking issues; an
125
133
  * empty array means the gate passes (design §8). Checks, in order:
package/dist/core/gate.js CHANGED
@@ -171,6 +171,27 @@ export function neverRedIssues(plan, addedIds, firstRun) {
171
171
  }
172
172
  return issues;
173
173
  }
174
+ /**
175
+ * Every requirement that still declares an open question.
176
+ *
177
+ * Exported for the same reason `neverRedIssues` is: it is one of the gate's
178
+ * obligations, and a caller projecting the gate's verdict has to reach the same
179
+ * answer from the same function rather than re-deriving it.
180
+ */
181
+ export function openIssues(registry) {
182
+ const out = [];
183
+ for (const [id, req] of Object.entries(registry)) {
184
+ for (const question of req.open) {
185
+ out.push({
186
+ level: 'ERROR',
187
+ code: 'open-unresolved',
188
+ reqId: id,
189
+ message: `Requirement "${id}" still declares an open question, so the registry is not done: ${question} Answer it and remove the entry from open, then run the gate again.`,
190
+ });
191
+ }
192
+ }
193
+ return out;
194
+ }
174
195
  /**
175
196
  * Decide whether a change may be archived. Returns the blocking issues; an
176
197
  * empty array means the gate passes (design §8). Checks, in order:
@@ -209,6 +230,23 @@ export function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds,
209
230
  if (addedIds && addedIds.length > 0) {
210
231
  blocking.push(...neverRedIssues(plan, addedIds, firstRun ?? {}));
211
232
  }
233
+ // 6) Open questions: nothing in the end-state registry may still be undecided.
234
+ //
235
+ // **Over the whole registry rather than the ids this change adds**, which is
236
+ // where it parts company with step 5 above. `never-red` is scoped to added ids
237
+ // because "was ever observed failing" is a fact about history and cannot be
238
+ // demanded retroactively of a requirement written straight into the base
239
+ // registry. An open field is not a historical fact; it is a claim that is true
240
+ // right now, so step 1's whole-registry re-validation is the precedent this
241
+ // belongs to. The scoping is also what closes the path the narrow form would
242
+ // open: `applyDelta` merges a MODIFIED patch field-wise, so an id that
243
+ // archived long ago can acquire `open` from a later change, and an
244
+ // added-ids-only gate would never look at it.
245
+ //
246
+ // The cost is deliberate: one unresolved question anywhere blocks every
247
+ // unrelated change from archiving. That is the intended reading of a registry
248
+ // that is not done, and it is recoverable by answering the question.
249
+ blocking.push(...openIssues(registry));
212
250
  return blocking;
213
251
  }
214
252
  //# sourceMappingURL=gate.js.map
@@ -83,6 +83,7 @@ function sanitised(registry) {
83
83
  rationale: control(req.rationale),
84
84
  params: Object.fromEntries(Object.entries(req.params).map(([k, v]) => [control(k), sanitisedValue(v)])),
85
85
  outOfScope: req.outOfScope.map(control),
86
+ open: req.open.map(control),
86
87
  };
87
88
  }
88
89
  return out;
@@ -231,6 +232,13 @@ function section(id, req) {
231
232
  if (req.outOfScope.length > 0) {
232
233
  out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${prose(s)}`));
233
234
  }
235
+ // Last, and rendered at all because this document is the only human review
236
+ // surface a requirement has: a reader who is told what a requirement promises
237
+ // without being told what about it is still undecided has been told the
238
+ // stronger half of a claim on its own.
239
+ if (req.open.length > 0) {
240
+ out.push('', '**Open**', '', ...req.open.map((s) => `- ${prose(s)}`));
241
+ }
234
242
  return out;
235
243
  }
236
244
  /** The statement as the document says it, with its params in place. */
@@ -11,6 +11,7 @@ export declare const RequirementSchema: z.ZodObject<{
11
11
  rationale: z.ZodString;
12
12
  params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
13
13
  outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
14
+ open: z.ZodDefault<z.ZodArray<z.ZodString>>;
14
15
  }, z.core.$strip>;
15
16
  /**
16
17
  * The id grammar, on its own.
@@ -45,16 +46,19 @@ export declare const RegistrySchema: z.ZodPipe<z.ZodCustom<Record<string, {
45
46
  rationale: string;
46
47
  params?: Record<string, unknown> | undefined;
47
48
  outOfScope?: string[] | undefined;
49
+ open?: string[] | undefined;
48
50
  }>, Record<string, {
49
51
  statement: string;
50
52
  rationale: string;
51
53
  params?: Record<string, unknown> | undefined;
52
54
  outOfScope?: string[] | undefined;
55
+ open?: string[] | undefined;
53
56
  }>>, z.ZodRecord<z.ZodString, z.ZodObject<{
54
57
  statement: z.ZodString;
55
58
  rationale: z.ZodString;
56
59
  params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
57
60
  outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
61
+ open: z.ZodDefault<z.ZodArray<z.ZodString>>;
58
62
  }, z.core.$strip>>>;
59
63
  /** Parsed (output) shapes — defaults applied. */
60
64
  export type Requirement = z.infer<typeof RequirementSchema>;
@@ -63,6 +63,17 @@ export const RequirementSchema = z.object({
63
63
  // instance.
64
64
  params: z.record(z.string(), paramValue).default({}),
65
65
  outOfScope: z.array(z.string()).default([]),
66
+ // What is still undecided about this requirement, in free text, one entry per
67
+ // question. Shaped as `outOfScope` rather than as a new kind of thing: every
68
+ // place that has to learn about it — the patch merge, both splice writers, the
69
+ // renderer — already carries that field's branch to copy, and a defaulted
70
+ // array means an empty list and an absent field are the same state, so no
71
+ // caller has to distinguish "closed" from "never opened".
72
+ //
73
+ // No grammar is imposed on an entry. A greppable free-text line is what
74
+ // spec-kit's `[NEEDS CLARIFICATION: …]` is, and constraining it here would be
75
+ // deciding for every adopter what counts as a question.
76
+ open: z.array(z.string()).default([]),
66
77
  });
67
78
  /**
68
79
  * The id grammar, on its own.
@@ -132,6 +132,30 @@ promises is a two-stage workflow, and the stages are separate on purpose.
132
132
  or it is an \`unbound-param\` ERROR. A \`{placeholder}\` written into a
133
133
  *rationale* is a \`rationale-placeholder\` WARNING — rationales are not
134
134
  interpolated, so it would reach the rendered document with its braces intact.
135
+ - **If you cannot determine a value, say so — do not pick one.** \`open\` is a
136
+ list of what is still undecided about the requirement, in free text, one entry
137
+ per question. Each is a \`requirement-open\` WARNING from the static commands,
138
+ which does not fail them, so a proposal may be legitimately half-finished
139
+ while you are still writing it. The archive gate is where it stops: it blocks
140
+ with \`open-unresolved\` while any requirement in the merged registry still
141
+ carries one, so "done" is never diluted.
142
+
143
+ \`\`\`ts
144
+ 'AUTH-7': {
145
+ statement: 'The system MUST require a TOTP code within {totpWindowSec} seconds.',
146
+ rationale: 'A password alone no longer meets the account-takeover risk we accept.',
147
+ params: { totpWindowSec: 30 },
148
+ open: ['is 30 seconds right, or does the authenticator app dictate it?'],
149
+ },
150
+ \`\`\`
151
+
152
+ This matters more for you than for a human author, and the reason is worth
153
+ stating plainly: the schema will not let you leave the field out, so an agent
154
+ that cannot determine a number picks one. A scenario is then written against
155
+ the invented value, and the result is a single source that is perfectly
156
+ consistent and describes a promise nobody made — the exact drift this workflow
157
+ exists to prevent, arriving through the mechanism meant to stop it. Writing
158
+ the question down costs nothing and blocks nothing until archive time.
135
159
  - **A registry is a literal, and so is a delta.** Every value is written in the
136
160
  file: no imported constant (\`params: { maxMb: MAX_MB }\`), no computed value,
137
161
  no \`Date.now()\`. \`check\`, \`cover\` and \`render\` read \`*.reqs.ts\` with the
@@ -348,6 +372,7 @@ once. Branch on \`issues[].code\`, never on \`message\`:
348
372
  | --- | --- |
349
373
  | \`tests-red\` | a test is failing — the normal state until you are finished |
350
374
  | \`never-red\` | an added requirement's scenario has no recorded failing run |
375
+ | \`open-unresolved\` | a requirement anywhere in the merged registry still declares an \`open\` question |
351
376
  | \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
352
377
  | \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) — withdrawn when one of the two rows below already explains its file, so it never stands in for a load failure |
353
378
  | \`spec-load-failed\` | a spec file could not be imported, so nothing in it ran. The run output carries the import error itself; this names which file it stopped |
@@ -18,11 +18,11 @@ export declare class UnwritableValue extends Error {
18
18
  * body needs one after, and putting that decision here would mean this function
19
19
  * had to be told which case it was in anyway.
20
20
  *
21
- * `params` and `outOfScope` are omitted when empty rather than written as `{}`
22
- * and `[]`. The schema defaults both, so the two spellings mean the same thing,
23
- * and the shorter one is what a person writing this entry by hand would have
24
- * produced — which is the standard for a file `--apply` is merging into rather
25
- * than generating.
21
+ * `params`, `outOfScope` and `open` are omitted when empty rather than written
22
+ * as `{}` and `[]`. The schema defaults all three, so the two spellings mean the
23
+ * same thing, and the shorter one is what a person writing this entry by hand
24
+ * would have produced — which is the standard for a file `--apply` is merging
25
+ * into rather than generating.
26
26
  */
27
27
  export declare function requirementSource(id: string, req: Requirement, indent: string): string;
28
28
  /**
@@ -148,11 +148,11 @@ function paramSource(value) {
148
148
  * body needs one after, and putting that decision here would mean this function
149
149
  * had to be told which case it was in anyway.
150
150
  *
151
- * `params` and `outOfScope` are omitted when empty rather than written as `{}`
152
- * and `[]`. The schema defaults both, so the two spellings mean the same thing,
153
- * and the shorter one is what a person writing this entry by hand would have
154
- * produced — which is the standard for a file `--apply` is merging into rather
155
- * than generating.
151
+ * `params`, `outOfScope` and `open` are omitted when empty rather than written
152
+ * as `{}` and `[]`. The schema defaults all three, so the two spellings mean the
153
+ * same thing, and the shorter one is what a person writing this entry by hand
154
+ * would have produced — which is the standard for a file `--apply` is merging
155
+ * into rather than generating.
156
156
  */
157
157
  export function requirementSource(id, req, indent) {
158
158
  const inner = `${indent} `;
@@ -172,13 +172,28 @@ export function requirementSource(id, req, indent) {
172
172
  lines.push(`${inner}params: { ${body} },`);
173
173
  }
174
174
  if (req.outOfScope.length > 0) {
175
- lines.push(`${inner}outOfScope: ${outOfScopeSource(req.outOfScope)},`);
175
+ lines.push(`${inner}outOfScope: ${stringListSource(req.outOfScope)},`);
176
+ }
177
+ // Reachable only through a delta the gate never approves — `open-unresolved`
178
+ // blocks any end state that still carries one, so an ADDED entry written here
179
+ // has an empty list. Emitted anyway rather than left out: this function's
180
+ // contract is to write the requirement it is given, and a writer silently
181
+ // shorter than the schema is exactly the divergence the MODIFIED half below
182
+ // was found to have.
183
+ if (req.open.length > 0) {
184
+ lines.push(`${inner}open: ${stringListSource(req.open)},`);
176
185
  }
177
186
  lines.push(`${indent}}`);
178
187
  return lines.join('\n');
179
188
  }
180
- /** One `outOfScope` list as source. Shared with the modification writer below. */
181
- function outOfScopeSource(entries) {
189
+ /**
190
+ * One list-of-strings field as source — `outOfScope` or `open`.
191
+ *
192
+ * One function for both, and named for the shape rather than for either field,
193
+ * so a third such field does not arrive with a third spelling of the same
194
+ * emitter. Shared with the modification writer below.
195
+ */
196
+ function stringListSource(entries) {
182
197
  return `[${entries.map((s) => tsString(s)).join(', ')}]`;
183
198
  }
184
199
  /** One `params` key as source, at the key order the emitter writes everywhere. */
@@ -290,14 +305,24 @@ export function spliceModifications(file, source, changes) {
290
305
  }
291
306
  else if (name === 'outOfScope') {
292
307
  if (!sameValue(before.outOfScope, after.outOfScope)) {
293
- out.at(name, span, outOfScopeSource(after.outOfScope));
308
+ out.at(name, span, stringListSource(after.outOfScope));
309
+ }
310
+ }
311
+ else if (name === 'open') {
312
+ if (!sameValue(before.open, after.open)) {
313
+ out.at(name, span, stringListSource(after.open));
294
314
  }
295
315
  }
296
316
  else if (name === 'params') {
297
317
  editParams(layout, before, after, out);
298
318
  }
299
319
  // Any other field is one the schema does not define, and not this
300
- // module's to rewrite or to remove.
320
+ // module's to rewrite or to remove. `open` is above rather than here for
321
+ // exactly that reason: adding it to the schema without adding it to this
322
+ // loop would let the gate approve an end state where a question is closed
323
+ // while the file on disk still carries it — a divergence between the
324
+ // registry the verdict was reached on and the registry the merge commits,
325
+ // with nothing anywhere comparing the two.
301
326
  }
302
327
  if (!layout.fields.has('statement') && before.statement !== after.statement) {
303
328
  openField('statement', tsString(after.statement));
@@ -311,7 +336,10 @@ export function spliceModifications(file, source, changes) {
311
336
  openField('params', `{ ${fresh.join(', ')} }`);
312
337
  }
313
338
  if (!layout.fields.has('outOfScope') && !sameValue(before.outOfScope, after.outOfScope)) {
314
- openField('outOfScope', outOfScopeSource(after.outOfScope));
339
+ openField('outOfScope', stringListSource(after.outOfScope));
340
+ }
341
+ if (!layout.fields.has('open') && !sameValue(before.open, after.open)) {
342
+ openField('open', stringListSource(after.open));
315
343
  }
316
344
  if (opened.length > 0) {
317
345
  const point = layout.fieldInsertion;
@@ -19,8 +19,9 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
19
19
  * - unbound-param: a statement placeholder has no matching param
20
20
  * - non-scalar-interpolation: a statement placeholder names a structured param
21
21
  *
22
- * and one WARNING:
22
+ * and two WARNINGs:
23
23
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
24
+ * - requirement-open: the requirement names something still undecided about it
24
25
  *
25
26
  * and one more WARNING, which exists only when a registry file failed to load:
26
27
  * - orphan-from-failed-registry: the scenarios attesting ids that file declares
@@ -34,8 +34,9 @@ export function uncoveredIssues(registry, plan) {
34
34
  * - unbound-param: a statement placeholder has no matching param
35
35
  * - non-scalar-interpolation: a statement placeholder names a structured param
36
36
  *
37
- * and one WARNING:
37
+ * and two WARNINGs:
38
38
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
39
+ * - requirement-open: the requirement names something still undecided about it
39
40
  *
40
41
  * and one more WARNING, which exists only when a registry file failed to load:
41
42
  * - orphan-from-failed-registry: the scenarios attesting ids that file declares
@@ -208,6 +209,24 @@ export function validateStructure(registry, plan, unreadable = []) {
208
209
  });
209
210
  }
210
211
  }
212
+ // requirement-open: the requirement says what is still undecided about it.
213
+ // WARNING and not an ERROR, because the four static commands are where an
214
+ // author works while a proposal legitimately still has questions in it —
215
+ // failing here would push them back to inventing a value, which is the
216
+ // behaviour the field exists to replace. What refuses to call such a registry
217
+ // done is the archive gate (`open-unresolved`), the same asymmetry `never-red`
218
+ // runs on. One issue per question rather than one per requirement: each is a
219
+ // separate thing to answer, and a reader resolving them wants them listed.
220
+ for (const [id, req] of Object.entries(registry)) {
221
+ for (const question of req.open) {
222
+ issues.push({
223
+ level: 'WARNING',
224
+ code: 'requirement-open',
225
+ reqId: id,
226
+ message: `Requirement "${id}" is still open: ${question} Answer it and remove the entry from open — the archive gate refuses a registry that still carries one.`,
227
+ });
228
+ }
229
+ }
211
230
  return issues;
212
231
  }
213
232
  /**
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.28.0",
6
7
  "engines": {
7
8
  "node": ">=20.19"
8
9
  },
@@ -41,6 +42,24 @@
41
42
  "publishConfig": {
42
43
  "access": "public"
43
44
  },
45
+ "scripts": {
46
+ "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
47
+ "build": "pnpm run clean && tsc -p tsconfig.json",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
49
+ "typecheck:all": "tsc -p tsconfig.typecheck.json",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
53
+ "lint": "eslint .",
54
+ "prepack": "pnpm run build",
55
+ "prepublishOnly": "pnpm run test:consumer",
56
+ "attest": "node bin/attest.js",
57
+ "check:self": "node bin/attest.js check self",
58
+ "verify:self": "node bin/attest.js verify self",
59
+ "cover:self": "node bin/attest.js cover self",
60
+ "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
61
+ "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
62
+ },
44
63
  "keywords": [
45
64
  "tdd",
46
65
  "spec",
@@ -67,20 +86,15 @@
67
86
  "vite": "^8.1.5",
68
87
  "vitest": "^4.1.10"
69
88
  },
70
- "scripts": {
71
- "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
72
- "build": "pnpm run clean && tsc -p tsconfig.json",
73
- "typecheck": "tsc -p tsconfig.json --noEmit",
74
- "typecheck:all": "tsc -p tsconfig.typecheck.json",
75
- "test": "vitest run",
76
- "test:watch": "vitest",
77
- "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
78
- "lint": "eslint .",
79
- "attest": "node bin/attest.js",
80
- "check:self": "node bin/attest.js check self",
81
- "verify:self": "node bin/attest.js verify self",
82
- "cover:self": "node bin/attest.js cover self",
83
- "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
84
- "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
89
+ "pnpm": {
90
+ "onlyBuiltDependencies": [
91
+ "esbuild"
92
+ ],
93
+ "overrides": {
94
+ "brace-expansion@<5.0.9": ">=5.0.9",
95
+ "js-yaml@<4.3.1": ">=4.3.1",
96
+ "nanoid@<3.3.17": ">=3.3.17",
97
+ "postcss@<8.5.23": ">=8.5.23"
98
+ }
85
99
  }
86
- }
100
+ }