@am_shork/attest 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,28 +16,6 @@ anyone when a tuning knob changes. A param may be any JSON value, so a
16
16
  composite constant — a vendor blacklist, a `kind -> payload` table — gets the
17
17
  same single source as a lone number, which is where drift is worst.
18
18
 
19
- What that does not buy is a warning when you change the value. `check` runs
20
- nothing, so editing a param leaves it at `✓ No issues` — nothing became unbound,
21
- nothing became uncovered. The value cannot *diverge* from the assertion, which is
22
- the stronger property; noticing that it *moved* is `verify`'s job, and only when
23
- a scenario asserts on the value it read from `params`.
24
-
25
- Reading the param is necessary and **not sufficient**, which is worth knowing
26
- before you rely on it: an assertion that recomputes its expectation from the same
27
- param the code under test just read has no independent term, so both sides move
28
- together and the test stays green through any edit. Pin the expectation to
29
- something that does not move with the param — a fixture, a literal in the test,
30
- or a second independently derived value.
31
-
32
- A composite param has a second failure of its own, and it runs the other way. When
33
- a test **loops over** a list it read from `params`, that list is the set of cases
34
- the run covers: drop a member and every assertion inside the loop still passes
35
- over what is left, so the suite quietly tests less with nothing to show for it.
36
- Pin the extent beside the loop — the members against a literal when their identity
37
- is the promise, the length when the size is. That literal is not the copy the
38
- single source exists to prevent: it is not what the system is measured against,
39
- it is what the intent claimed to cover.
40
-
41
19
  ## Prerequisites
42
20
 
43
21
  - Node ≥ 20.19
@@ -134,7 +112,14 @@ for exactly one machine-readable document on stdout. Flags, per-command
134
112
  behaviour and the JSON shape are in the
135
113
  [CLI reference](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md).
136
114
 
137
- ### Working with an agent
115
+ Reading a param is necessary and **not sufficient** — an assertion that
116
+ recomputes its expectation from the same param the code just read has no
117
+ independent term, and a scenario that loops over a list param covers exactly
118
+ that list. Both have a known shape and a known repair, and both are in
119
+ [Judging your own intent layer](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/intent-quality.md#writing-a-param-that-pins-something),
120
+ along with the decision table — the shape a composite param is best at.
121
+
122
+ ## Working with an agent
138
123
 
139
124
  The engine above is only half the framework. The other half is the workflow —
140
125
  agree on intent, write the delta, drive the scenarios red, then implement to
@@ -159,7 +144,7 @@ found or pasted. See
159
144
  [`attest init`](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md#attest-init)
160
145
  for the targets and what `init` deliberately does not write.
161
146
 
162
- ### When something goes wrong
147
+ ## When something goes wrong
163
148
 
164
149
  Every diagnostic carries a `code`, and every code has a section in
165
150
  **Troubleshooting** — which the diagnostic itself links to:
@@ -167,7 +152,7 @@ Every diagnostic carries a `code`, and every code has a section in
167
152
  ```
168
153
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
169
154
  Value is not a literal.
170
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.3/docs/en/troubleshooting.md#registry-not-static
155
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.10.0/docs/en/troubleshooting.md#registry-not-static
171
156
  ```
172
157
 
173
158
  The anchor **is** the code, so the link cannot point somewhere the section
package/bin/attest.js CHANGED
File without changes
@@ -73,10 +73,14 @@ export async function runAction(command, opts, action) {
73
73
  }
74
74
  catch (err) {
75
75
  if (opts.json) {
76
- // Unsanitised on purpose: `JSON.stringify` escapes every C0 character,
77
- // so these bytes cannot carry one out (see report.ts). That is a claim
78
- // about what this line writes and nothing wider what the project under
79
- // test writes is the divert's business, above.
76
+ // Sanitised, though not on this line: `renderJson` strips every string in
77
+ // the document as it serialises it (ATX-74), so the crash path is covered
78
+ // by going through the same function every other report goes through.
79
+ //
80
+ // Not by `JSON.stringify`, which is what this comment used to claim. That
81
+ // escapes C0 and nothing else — DEL and the C1 range survive it — so the
82
+ // replacer in `renderJson` is load-bearing rather than belt and braces.
83
+ // What the project under test writes is still the divert's business, above.
80
84
  console.log(renderJson(errorReport(VERSION, command, err)));
81
85
  }
82
86
  else {
package/dist/cli/index.js CHANGED
@@ -261,10 +261,15 @@ program
261
261
  const { issues: blocking, written } = await run(root(dir), change, {
262
262
  vitestConfig: vitestConfig(opts),
263
263
  });
264
+ // Built once and handed to both renderings, rather than each being told
265
+ // separately what the merge did. Two renderings of one result agreeing by
266
+ // transcription is what let the verdict disagree with itself (ATX-60),
267
+ // and the paths are the other thing this command has to say.
268
+ const merge = { applied: opts.apply === true, written };
264
269
  return {
265
- report: archiveReport(VERSION, change, blocking),
270
+ report: archiveReport(VERSION, change, blocking, merge),
266
271
  human: () => {
267
- console.log(formatArchiveVerdict(change, blocking, { applied: opts.apply === true, written }));
272
+ console.log(formatArchiveVerdict(change, blocking, merge));
268
273
  },
269
274
  };
270
275
  }));
@@ -33,6 +33,22 @@ export interface JsonReport {
33
33
  };
34
34
  /** `archive` and `status`: the change being gated or reported on. */
35
35
  change?: string;
36
+ /**
37
+ * `archive --apply` only: every path the merge touched, relative to the
38
+ * project root, in the order it touched them.
39
+ *
40
+ * Additive, so no SCHEMA_VERSION bump. **Absent** when `--apply` was not
41
+ * asked for — no merge was attempted, and there is nothing to report — while
42
+ * an **empty array** means the merge ran and had nothing left to write, which
43
+ * is the ordinary state of a re-run after a completed one. Folding the two
44
+ * into `[]` would make the field unable to say which happened.
45
+ *
46
+ * The human rendering has printed these since `--apply` shipped, on the
47
+ * grounds that this is the command that edits files the user cannot
48
+ * regenerate and they need to know which ones to read before committing. That
49
+ * argument does not stop at the reader with a terminal.
50
+ */
51
+ written?: string[];
36
52
  /** `status` only: one row per requirement the change adds. */
37
53
  progress?: StatusRow[];
38
54
  /** `status` only: roll-up of the rows by obligation state. */
@@ -83,7 +99,18 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
83
99
  * was added for, to say something the array already says.
84
100
  */
85
101
  export declare function initReport(version: string, issues: Issue[], outFiles: string[]): JsonReport;
86
- export declare function archiveReport(version: string, change: string, blocking: Issue[]): JsonReport;
102
+ /**
103
+ * `archive`, and — when `--apply` was asked for — what the merge wrote.
104
+ *
105
+ * `merge` is the same bundle `formatArchiveVerdict` takes, passed to both from
106
+ * one place in the shell. The two renderings of one result had already grown
107
+ * apart once over the verdict (ATX-60); handing them the same object is what
108
+ * stops the paths from being the second thing they disagree about.
109
+ */
110
+ export declare function archiveReport(version: string, change: string, blocking: Issue[], merge?: {
111
+ applied: boolean;
112
+ written: readonly string[];
113
+ }): JsonReport;
87
114
  /**
88
115
  * `status`. The progress rows never touch the verdict: `ok` comes from the same
89
116
  * `hasError` predicate as everywhere else, over an `issues` array that carries
package/dist/cli/json.js CHANGED
@@ -109,7 +109,15 @@ export function initReport(version, issues, outFiles) {
109
109
  const [only] = outFiles;
110
110
  return outFiles.length === 1 && only !== undefined ? { ...report, outFile: only } : report;
111
111
  }
112
- export function archiveReport(version, change, blocking) {
112
+ /**
113
+ * `archive`, and — when `--apply` was asked for — what the merge wrote.
114
+ *
115
+ * `merge` is the same bundle `formatArchiveVerdict` takes, passed to both from
116
+ * one place in the shell. The two renderings of one result had already grown
117
+ * apart once over the verdict (ATX-60); handing them the same object is what
118
+ * stops the paths from being the second thing they disagree about.
119
+ */
120
+ export function archiveReport(version, change, blocking, merge) {
113
121
  return {
114
122
  ...envelope({
115
123
  version,
@@ -123,6 +131,7 @@ export function archiveReport(version, change, blocking) {
123
131
  issues: blocking,
124
132
  }),
125
133
  change,
134
+ ...(merge?.applied ? { written: [...merge.written] } : {}),
126
135
  };
127
136
  }
128
137
  /**
@@ -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
@@ -35,11 +35,26 @@
35
35
  // present without a given scenario is one that did and then drifted.
36
36
  import { readdir } from 'node:fs/promises';
37
37
  import { join } from 'node:path';
38
+ import { forEachBounded } from './locate.js';
38
39
  import { byCodeUnit } from './order.js';
39
40
  import { relativePath } from './paths.js';
40
41
  import { keepOutcome, readRedRecordIn } from './red-record.js';
41
42
  /** The directory archived changes are moved into, under the project root. */
42
43
  const ARCHIVE_DIR = 'archive';
44
+ /**
45
+ * How many archived first-run records are read at once. Its own constant for
46
+ * the reason `WALK_CONCURRENCY` gives, and a fourth resource: what this bounds
47
+ * is the *parsed records held at once*, not descriptors. `readFile` does
48
+ * open-read-close inside one libuv threadpool work item, so concurrent
49
+ * descriptors track the threadpool and never the fan-out — measured, 3,000
50
+ * unbounded reads peaked at 53 open handles. Heap is what tracks the input, and
51
+ * a project's archive grows by one folder per change merged, forever, so the
52
+ * list is as much the project's shape as a spec tree is.
53
+ * `Promise.all(names.map(...))` put every record in flight at once: measured at
54
+ * the `fs` seam, 64 changes peaked at 64 concurrent reads, and 2,000 records of
55
+ * 45 KB peaked at 352 MB of heap against 177 MB bounded.
56
+ */
57
+ const ARCHIVE_CONCURRENCY = 32;
43
58
  /**
44
59
  * Read every archived change folder under `root`.
45
60
  *
@@ -67,10 +82,18 @@ export async function readArchivedChanges(root) {
67
82
  .filter((e) => e.isDirectory())
68
83
  .map((e) => e.name)
69
84
  .sort(byCodeUnit);
70
- return Promise.all(names.map(async (name) => {
85
+ // Preallocated and written by `index`, which is how a `forEachBounded` caller
86
+ // keeps a stable result: the pool calls in input order but completes in
87
+ // whatever order the disk answers, and `names` is already sorted above.
88
+ const changes = new Array(names.length);
89
+ await forEachBounded(names, ARCHIVE_CONCURRENCY, async (name, i) => {
90
+ // `readRedRecordIn` catches its own read and parse, so nothing here can
91
+ // throw and abandon the rest — the same thing `readGuarded` is for on the
92
+ // registry pool, and what keeps every slot of the array above filled.
71
93
  const path = join(dir, name);
72
- return { name, path: relativePath(root, path), firstRun: await readRedRecordIn(path) };
73
- }));
94
+ changes[i] = { name, path: relativePath(root, path), firstRun: await readRedRecordIn(path) };
95
+ });
96
+ return changes;
74
97
  }
75
98
  /**
76
99
  * The archive's first-run evidence, merged across every archived change.
@@ -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
@@ -20,6 +20,33 @@ export declare const isProposedSpecFile: (name: string) => boolean;
20
20
  * of them until its gate passes.
21
21
  */
22
22
  export declare const isSpecFile: (name: string) => boolean;
23
+ /**
24
+ * Run `fn` over every item with at most `limit` of them in flight.
25
+ *
26
+ * Extracted at the second call site rather than the first, because a shape
27
+ * written N times is one a later fix reaches only some copies of.
28
+ *
29
+ * **There are three, and the third is the one to look for.** `findFiles` and
30
+ * `parseSpecs` are named for what they fan out over; `loadRegistry` is named for
31
+ * merging, and its concurrency sits under a comment about *ordering* that
32
+ * answers a different question convincingly. Anything here that reads a list the
33
+ * project's tree decides the length of belongs in this pool, whatever the
34
+ * function around it is called.
35
+ *
36
+ * The order `fn` is *called* in is the input order; the order it *completes* in
37
+ * is not, so a caller that needs a stable result either indexes into a
38
+ * preallocated array by `index` or sorts afterwards. All three callers here do
39
+ * one of those, deliberately — as does `readArchivedChanges`, the caller
40
+ * outside this file, which preallocates and writes by `index`.
41
+ *
42
+ * No result is collected and none is needed — every caller writes into something
43
+ * it already owns, and a version returning `T[]` would have to choose an
44
+ * ordering on their behalf. A throw from `fn` propagates and abandons the rest,
45
+ * which is the existing behaviour at all three sites: `parseSpecs` and
46
+ * `loadRegistry` catch per file so that one hostile source scraps only itself
47
+ * (ATX-65), and a failed `readdir` really does end the walk.
48
+ */
49
+ export declare function forEachBounded<T>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<void>): Promise<void>;
23
50
  /**
24
51
  * Recursively find files under root whose basename matches `match`.
25
52
  *
@@ -56,7 +56,8 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
56
56
  * The order `fn` is *called* in is the input order; the order it *completes* in
57
57
  * is not, so a caller that needs a stable result either indexes into a
58
58
  * preallocated array by `index` or sorts afterwards. All three callers here do
59
- * one of those, deliberately.
59
+ * one of those, deliberately — as does `readArchivedChanges`, the caller
60
+ * outside this file, which preallocates and writes by `index`.
60
61
  *
61
62
  * No result is collected and none is needed — every caller writes into something
62
63
  * it already owns, and a version returning `T[]` would have to choose an
@@ -65,7 +66,7 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
65
66
  * `loadRegistry` catch per file so that one hostile source scraps only itself
66
67
  * (ATX-65), and a failed `readdir` really does end the walk.
67
68
  */
68
- async function forEachBounded(items, limit, fn) {
69
+ export async function forEachBounded(items, limit, fn) {
69
70
  let cursor = 0;
70
71
  const worker = async () => {
71
72
  for (let i = cursor++; i < items.length; i = cursor++) {
@@ -319,6 +319,7 @@ function modifyRefusal(root, file, refusal) {
319
319
  const where = refusal.field ? `${refusal.reqId}.${refusal.field}` : refusal.reqId;
320
320
  const why = {
321
321
  comment: `a comment sits inside the value it would replace, and which side of that edit the comment belongs to is not something --apply can decide`,
322
+ 'duplicate-key': `that name is written more than once in the file, so the span --apply located is not the one the registry's readers evaluate`,
322
323
  'entry-not-found': `the registry file that owns its prefix holds no entry for it`,
323
324
  'not-a-literal': `the value it would replace is not written as a literal`,
324
325
  'param-dropped': `the proved end state does not carry that param, and a delta has no way to say one was removed`,
@@ -320,20 +320,30 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
320
320
  forFile[s.name] = kept;
321
321
  changed = true;
322
322
  }
323
- // Sort within each requirement, and within each file, for the same
324
- // byte-stability reason.
325
- for (const id of Object.keys(record)) {
323
+ // Sort at every level, for the same byte-stability reason the ids included.
324
+ //
325
+ // The rebuild above sorts only the ids the record already held; the loop after
326
+ // it *appends* the ones this run is the first to observe, in plan order. So
327
+ // the ids are unsorted by the time this runs, and this is the level that has
328
+ // to put them in order.
329
+ //
330
+ // A rebuild rather than an in-place sort, which is the trap: reassigning
331
+ // `record[id]` writes a value and does not move a key, so a version of this
332
+ // that sorted the two inner levels while walking the ids as they came would
333
+ // leave the outer order exactly as it found it.
334
+ const sorted = emptyMap();
335
+ for (const id of Object.keys(record).sort(byCodeUnit)) {
326
336
  const sortedFiles = emptyMap();
327
337
  for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
328
- const sorted = emptyMap();
338
+ const names = emptyMap();
329
339
  for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
330
- sorted[name] = record[id][file][name];
340
+ names[name] = record[id][file][name];
331
341
  }
332
- sortedFiles[file] = sorted;
342
+ sortedFiles[file] = names;
333
343
  }
334
- record[id] = sortedFiles;
344
+ sorted[id] = sortedFiles;
335
345
  }
336
- return { record, changed };
346
+ return { record: sorted, changed };
337
347
  }
338
348
  /** Serialise the record. A trailing newline, so the file is a well-formed text file. */
339
349
  export function serialiseRedRecord(changeName, record) {
@@ -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
  /**
@@ -65,7 +65,7 @@ export interface Modification {
65
65
  export interface ModifyRefusal {
66
66
  reqId: string;
67
67
  field?: string;
68
- reason: 'comment' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
68
+ reason: 'comment' | 'duplicate-key' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
69
69
  }
70
70
  export type ModifyResult = {
71
71
  ok: true;