@am_shork/attest 0.9.4 → 0.11.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
@@ -146,12 +131,19 @@ attest init --target cursor # .cursor/rules/attest.mdc
146
131
  attest init --target copilot # .github/instructions/attest.instructions.md
147
132
  ```
148
133
 
149
- "Red before you implement" is enforced rather than advised: `archive` records how
150
- each of a change's scenarios ended on its first run, into
151
- `changes/<name>/first-run.json`, and blocks with `never-red` on any requirement
152
- the delta adds whose scenarios were never seen to fail. Commit `first-run.json`
153
- with the change — it is the evidence, and CI has to reach the same verdict as
154
- you do.
134
+ "Every scenario has been seen to fail" is enforced rather than advised:
135
+ `archive` records how each of a change's scenarios ended in every run it
136
+ observes, into `changes/<name>/first-run.json`, and blocks with `never-red` on
137
+ any requirement the delta adds whose scenarios were never seen to fail. Commit
138
+ `first-run.json` with the change — it is the evidence, and CI has to reach the
139
+ same verdict as you do.
140
+
141
+ **It does not require you to write the test first.** A recorded failure is
142
+ permanent and a recorded pass is not, so a failure observed after the
143
+ implementation exists satisfies the gate exactly like one observed before it:
144
+ if you wrote the intent, the scenario and the code together, remove the
145
+ implementation, run `archive`, and put it back. What is enforced is that the
146
+ assertion can fail — not the order you worked in.
155
147
 
156
148
  An agent loads the document on its own — its description is already in the
157
149
  agent's context, or its path matches what you have open — so nothing has to be
@@ -159,7 +151,7 @@ found or pasted. See
159
151
  [`attest init`](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md#attest-init)
160
152
  for the targets and what `init` deliberately does not write.
161
153
 
162
- ### When something goes wrong
154
+ ## When something goes wrong
163
155
 
164
156
  Every diagnostic carries a `code`, and every code has a section in
165
157
  **Troubleshooting** — which the diagnostic itself links to:
@@ -167,7 +159,7 @@ Every diagnostic carries a `code`, and every code has a section in
167
159
  ```
168
160
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
169
161
  Value is not a literal.
170
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.4/docs/en/troubleshooting.md#registry-not-static
162
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.11.0/docs/en/troubleshooting.md#registry-not-static
171
163
  ```
172
164
 
173
165
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -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
  /**
@@ -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.
@@ -102,17 +102,27 @@ export declare function specLoadFailedIssues(run: RunResult, already?: ReadonlyS
102
102
  */
103
103
  export declare function notRunIssues(plan: AttestPlan, run: RunResult, specific?: readonly Issue[]): Issue[];
104
104
  /**
105
- * Never-red: a scenario attesting a requirement this change ADDs, whose first
106
- * observed run did not fail (design §6, mechanism 2).
105
+ * Never-red: a scenario attesting a requirement this change ADDs, which no run
106
+ * the gate observed has seen fail (design §6, mechanism 2).
107
107
  *
108
108
  * This is the half of the gate that certifies the tests ever *discriminated*.
109
109
  * Green at archive time says only that they pass now — a scenario that asserted
110
110
  * nothing has been green since the commit that added it and clears every other
111
111
  * check identically. The two states are reported apart because the fixes differ:
112
- * a first run that passed means the scenario does not test what it claims, while
113
- * no observation at all means the workflow's stage-1 step was skipped, and the
114
- * remedy for the second is to run the gate before implementing rather than to
115
- * rewrite anything.
112
+ * a scenario observed passing and never failing does not test what it claims,
113
+ * while no observation at all means no run has been taken yet, and the remedy
114
+ * for the second is to take one rather than to rewrite anything.
115
+ *
116
+ * **The obligation is discrimination, and it says nothing about ordering.** The
117
+ * record is monotonic toward failure but a pass is provisional (`red-record.ts`),
118
+ * so a failure observed *after* the implementation exists replaces a recorded
119
+ * pass and satisfies this exactly like one observed before it. Running the gate
120
+ * first is the cheapest route to the evidence and not the only one: removing the
121
+ * implementation, running the gate, and restoring it produces the same fact by
122
+ * the same command. Nothing here could enforce an order anyway — a stage-1 red
123
+ * can come from a typo in the spec rather than from the requirement being
124
+ * unimplemented, so an earlier observation was never stronger evidence, only
125
+ * earlier.
116
126
  *
117
127
  * Scoped to ADDED ids only. A requirement written straight into the base
118
128
  * registry — which is how existing behaviour gets described, in a brownfield
package/dist/core/gate.js CHANGED
@@ -132,17 +132,27 @@ export function notRunIssues(plan, run, specific = []) {
132
132
  ];
133
133
  }
134
134
  /**
135
- * Never-red: a scenario attesting a requirement this change ADDs, whose first
136
- * observed run did not fail (design §6, mechanism 2).
135
+ * Never-red: a scenario attesting a requirement this change ADDs, which no run
136
+ * the gate observed has seen fail (design §6, mechanism 2).
137
137
  *
138
138
  * This is the half of the gate that certifies the tests ever *discriminated*.
139
139
  * Green at archive time says only that they pass now — a scenario that asserted
140
140
  * nothing has been green since the commit that added it and clears every other
141
141
  * check identically. The two states are reported apart because the fixes differ:
142
- * a first run that passed means the scenario does not test what it claims, while
143
- * no observation at all means the workflow's stage-1 step was skipped, and the
144
- * remedy for the second is to run the gate before implementing rather than to
145
- * rewrite anything.
142
+ * a scenario observed passing and never failing does not test what it claims,
143
+ * while no observation at all means no run has been taken yet, and the remedy
144
+ * for the second is to take one rather than to rewrite anything.
145
+ *
146
+ * **The obligation is discrimination, and it says nothing about ordering.** The
147
+ * record is monotonic toward failure but a pass is provisional (`red-record.ts`),
148
+ * so a failure observed *after* the implementation exists replaces a recorded
149
+ * pass and satisfies this exactly like one observed before it. Running the gate
150
+ * first is the cheapest route to the evidence and not the only one: removing the
151
+ * implementation, running the gate, and restoring it produces the same fact by
152
+ * the same command. Nothing here could enforce an order anyway — a stage-1 red
153
+ * can come from a typo in the spec rather than from the requirement being
154
+ * unimplemented, so an earlier observation was never stronger evidence, only
155
+ * earlier.
146
156
  *
147
157
  * Scoped to ADDED ids only. A requirement written straight into the base
148
158
  * registry — which is how existing behaviour gets described, in a brownfield
@@ -165,8 +175,8 @@ export function neverRedIssues(plan, addedIds, firstRun) {
165
175
  file: s.file,
166
176
  line: s.line,
167
177
  message: outcome === 'pass'
168
- ? `scenario "${s.name}" passed on its first run, so it never showed that ${s.reqId} was unimplemented; give it an assertion that fails without the change and run the gate again — a later failing run replaces this record.`
169
- : `scenario "${s.name}" has no recorded first run for ${s.reqId}; run \`attest archive\` before implementing, so ${RED_RECORD_FILE} records it failing.`,
178
+ ? `scenario "${s.name}" was observed passing and never failing, so it has not shown that it can fail without ${s.reqId} being implemented; give it an assertion that discriminates and run the gate again — a later failing run replaces this record.`
179
+ : `scenario "${s.name}" has no observed run recorded for ${s.reqId}; run \`attest archive\` with the implementation absent — before writing it, or with it removed — so ${RED_RECORD_FILE} records the scenario failing.`,
170
180
  });
171
181
  }
172
182
  return issues;
@@ -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`,
@@ -18,11 +18,11 @@ export declare function redRecordPath(root: string, changeName: string): string;
18
18
  /** The file name, exported so a diagnostic can name it without rebuilding it. */
19
19
  export declare const RED_RECORD_FILE = "first-run.json";
20
20
  /**
21
- * reqId -> spec file -> scenario name -> the outcome of its first observed run.
21
+ * reqId -> spec file -> scenario name -> the strongest outcome observed for it.
22
22
  *
23
- * Deliberately not a list of "red scenarios": a scenario whose first run passed
24
- * is the case mechanism 2 exists to catch, so it has to be recorded as a fact
25
- * rather than as an absence. An absence then means only one thing — never
23
+ * Deliberately not a list of "red scenarios": a scenario only ever observed
24
+ * passing is the case mechanism 2 exists to catch, so it has to be recorded as a
25
+ * fact rather than as an absence. An absence then means only one thing — never
26
26
  * observed at all — and the gate can report the two separately.
27
27
  *
28
28
  * **The file level is what identifies a scenario, and it was missing.** A
@@ -1,5 +1,13 @@
1
- // Mechanism 2's record: what each of a change's scenarios did the *first* time
2
- // it ran (design §6).
1
+ // Mechanism 2's record: the strongest outcome the gate has ever observed for
2
+ // each of a change's scenarios (design §6).
3
+ //
4
+ // Not "the first run", which is what the file is named after and what this was
5
+ // once described as. The record is revised — see the asymmetry below — and the
6
+ // difference is the whole reason the *order* of the observations is free: an
7
+ // author who has already written the implementation earns the evidence by
8
+ // removing it, running the gate, and restoring it. The file keeps its name
9
+ // because archived records already carry it (`inspect.ts`) and a rename would
10
+ // spend that to fix a word.
3
11
  //
4
12
  // §8's gate requires green at archive time. That certifies "these tests pass",
5
13
  // not "these tests ever discriminated" — a scenario that asserted nothing has
@@ -36,7 +44,8 @@
36
44
  // change red. Once a scenario has been seen to fail, nothing can take it back.
37
45
  //
38
46
  // Provisional pass: plain first-write-wins was the first design here, and it
39
- // makes the defect this mechanism exists to catch *unrecoverable*. A scenario
47
+ // makes the defect this mechanism exists to catch *unrecoverable*, and it is
48
+ // also what would have made the ordering real. A scenario
40
49
  // that passed on its first run is one that asserts nothing; the fix is to give
41
50
  // it a real assertion and watch it fail — but under first-write-wins that
42
51
  // observation could never be recorded, so the only way out was deleting the
@@ -320,20 +329,30 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
320
329
  forFile[s.name] = kept;
321
330
  changed = true;
322
331
  }
323
- // Sort within each requirement, and within each file, for the same
324
- // byte-stability reason.
325
- for (const id of Object.keys(record)) {
332
+ // Sort at every level, for the same byte-stability reason the ids included.
333
+ //
334
+ // The rebuild above sorts only the ids the record already held; the loop after
335
+ // it *appends* the ones this run is the first to observe, in plan order. So
336
+ // the ids are unsorted by the time this runs, and this is the level that has
337
+ // to put them in order.
338
+ //
339
+ // A rebuild rather than an in-place sort, which is the trap: reassigning
340
+ // `record[id]` writes a value and does not move a key, so a version of this
341
+ // that sorted the two inner levels while walking the ids as they came would
342
+ // leave the outer order exactly as it found it.
343
+ const sorted = emptyMap();
344
+ for (const id of Object.keys(record).sort(byCodeUnit)) {
326
345
  const sortedFiles = emptyMap();
327
346
  for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
328
- const sorted = emptyMap();
347
+ const names = emptyMap();
329
348
  for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
330
- sorted[name] = record[id][file][name];
349
+ names[name] = record[id][file][name];
331
350
  }
332
- sortedFiles[file] = sorted;
351
+ sortedFiles[file] = names;
333
352
  }
334
- record[id] = sortedFiles;
353
+ sorted[id] = sortedFiles;
335
354
  }
336
- return { record, changed };
355
+ return { record: sorted, changed };
337
356
  }
338
357
  /** Serialise the record. A trailing newline, so the file is a well-formed text file. */
339
358
  export function serialiseRedRecord(changeName, record) {
@@ -115,8 +115,34 @@ type ProposedRequirements<T extends RegistryDelta> = T extends {
115
115
  * helper over a whole registry; the ids a single change adds are few and known
116
116
  * at the call site, so the stricter shape costs nothing and rejects a mistyped
117
117
  * id outright instead of leaving it to `check`.
118
+ *
119
+ * **`added` is withheld from the type although the value still carries it**, and
120
+ * that is the one place this shape deliberately understates its own object. Both
121
+ * `reqs['AUTH-7'].params.x` and `d.added['AUTH-7'].params.x` are correct during
122
+ * stage 1; only the first survives `--apply`, which repoints the specifier at
123
+ * the merged registry, and a registry has no `added`. Nothing else catches that:
124
+ * the gate runs before the rename, `check` executes nothing, and the compiler
125
+ * sees whichever half is on disk — so the difference appears one command later,
126
+ * as a `spec-load-failed` naming the file and not the cause.
127
+ *
128
+ * The optional `never` rather than a bare `Omit` is load-bearing.
129
+ * `RegistryDelta`'s four fields are all optional, so an added-only delta with
130
+ * `added` removed has *no* property in common with it and TypeScript's weak-type
131
+ * check refuses `applyDelta(base, delta({ added: … }))` outright — nine call
132
+ * sites in this tree, none of them wrong. An optional property restores the
133
+ * overlap while still refusing the read, and the refusal survives the escape
134
+ * hatches that would make it advisory: `d.added!` and `d.added?.[…]` both land
135
+ * on `never`, and only an explicit `as any` compiles.
136
+ *
137
+ * `modified`, `removed` and `renamed` are left alone on purpose. They vanish at
138
+ * the merge too, but none of them has a by-id equivalent — there is no correct
139
+ * spelling to redirect an author to, so a spec reading one is reading delta-only
140
+ * data rather than misspelling a right thing, which is a different defect and
141
+ * has never been reported.
118
142
  */
119
- export type DefinedDelta<T extends RegistryDelta> = T & ProposedRequirements<T>;
143
+ export type DefinedDelta<T extends RegistryDelta> = Omit<T, 'added'> & {
144
+ added?: never;
145
+ } & ProposedRequirements<T>;
120
146
  /**
121
147
  * Declare a registry delta for a change (design §7). Ordered idempotent
122
148
  * application (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta.
@@ -130,6 +156,10 @@ export type DefinedDelta<T extends RegistryDelta> = T & ProposedRequirements<T>;
130
156
  * exists to reward — and then billed them the rewrite at merge for having
131
157
  * complied. Now merging a spec changes its import and nothing else.
132
158
  *
159
+ * That rejected expression is now rejected by the compiler rather than by
160
+ * this comment — {@link DefinedDelta} withholds `added`, because it was the
161
+ * one wrong spelling that stage 1 could not tell from the right one.
162
+ *
133
163
  * Still no validation, deliberately. A delta carrying an id the registry would
134
164
  * refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
135
165
  * (ATX-41), which is where a reviewer sees it; throwing here would move that
@@ -48,6 +48,10 @@ export function defineRequirements(input) {
48
48
  * exists to reward — and then billed them the rewrite at merge for having
49
49
  * complied. Now merging a spec changes its import and nothing else.
50
50
  *
51
+ * That rejected expression is now rejected by the compiler rather than by
52
+ * this comment — {@link DefinedDelta} withholds `added`, because it was the
53
+ * one wrong spelling that stage 1 could not tell from the right one.
54
+ *
51
55
  * Still no validation, deliberately. A delta carrying an id the registry would
52
56
  * refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
53
57
  * (ATX-41), which is where a reviewer sees it; throwing here would move that