@tiphys/kernel 0.0.0 → 0.2.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.
Files changed (207) hide show
  1. package/AGENTS.md +663 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +299 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +92 -0
  11. package/dist/src/adapters/load.d.ts +202 -0
  12. package/dist/src/adapters/load.js +440 -0
  13. package/dist/src/brief.d.ts +32 -0
  14. package/dist/src/brief.js +66 -0
  15. package/dist/src/checklists.d.ts +169 -0
  16. package/dist/src/checklists.js +310 -0
  17. package/dist/src/checks.d.ts +1539 -0
  18. package/dist/src/checks.js +5025 -0
  19. package/dist/src/cli.d.ts +9 -0
  20. package/dist/src/cli.js +68 -0
  21. package/dist/src/commands/brief.d.ts +92 -0
  22. package/dist/src/commands/brief.js +343 -0
  23. package/dist/src/commands/checklist.d.ts +42 -0
  24. package/dist/src/commands/checklist.js +168 -0
  25. package/dist/src/commands/cutover.d.ts +35 -0
  26. package/dist/src/commands/cutover.js +448 -0
  27. package/dist/src/commands/doctor.d.ts +264 -0
  28. package/dist/src/commands/doctor.js +1546 -0
  29. package/dist/src/commands/gates.d.ts +9 -0
  30. package/dist/src/commands/gates.js +360 -0
  31. package/dist/src/commands/init.d.ts +30 -0
  32. package/dist/src/commands/init.js +177 -0
  33. package/dist/src/commands/lock.d.ts +34 -0
  34. package/dist/src/commands/lock.js +340 -0
  35. package/dist/src/commands/mode.d.ts +40 -0
  36. package/dist/src/commands/mode.js +134 -0
  37. package/dist/src/commands/next.d.ts +130 -0
  38. package/dist/src/commands/next.js +597 -0
  39. package/dist/src/commands/plan.d.ts +20 -0
  40. package/dist/src/commands/plan.js +105 -0
  41. package/dist/src/commands/pool.d.ts +1 -0
  42. package/dist/src/commands/pool.js +139 -0
  43. package/dist/src/commands/resume.d.ts +1 -0
  44. package/dist/src/commands/resume.js +88 -0
  45. package/dist/src/commands/spawn.d.ts +1 -0
  46. package/dist/src/commands/spawn.js +195 -0
  47. package/dist/src/commands/status.d.ts +21 -0
  48. package/dist/src/commands/status.js +122 -0
  49. package/dist/src/commands/sync.d.ts +47 -0
  50. package/dist/src/commands/sync.js +341 -0
  51. package/dist/src/commands/teardown.d.ts +1 -0
  52. package/dist/src/commands/teardown.js +87 -0
  53. package/dist/src/commands/tuition.d.ts +21 -0
  54. package/dist/src/commands/tuition.js +218 -0
  55. package/dist/src/commands/validate.d.ts +78 -0
  56. package/dist/src/commands/validate.js +430 -0
  57. package/dist/src/commands/watch.d.ts +1 -0
  58. package/dist/src/commands/watch.js +172 -0
  59. package/dist/src/cutover.d.ts +584 -0
  60. package/dist/src/cutover.js +1444 -0
  61. package/dist/src/exclusion.d.ts +389 -0
  62. package/dist/src/exclusion.js +843 -0
  63. package/dist/src/exec/env.d.ts +278 -0
  64. package/dist/src/exec/env.js +334 -0
  65. package/dist/src/fleet.d.ts +223 -0
  66. package/dist/src/fleet.js +298 -0
  67. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  68. package/dist/src/gates/adapters/http-json.js +283 -0
  69. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  70. package/dist/src/gates/adapters/migrations-command.js +373 -0
  71. package/dist/src/gates/citations.d.ts +408 -0
  72. package/dist/src/gates/citations.js +1169 -0
  73. package/dist/src/gates/coverage.d.ts +375 -0
  74. package/dist/src/gates/coverage.js +836 -0
  75. package/dist/src/gates/credentials.d.ts +233 -0
  76. package/dist/src/gates/credentials.js +752 -0
  77. package/dist/src/gates/deploy.d.ts +1 -0
  78. package/dist/src/gates/deploy.js +33 -0
  79. package/dist/src/gates/gate-classes.d.ts +56 -0
  80. package/dist/src/gates/gate-classes.js +633 -0
  81. package/dist/src/gates/manifest.d.ts +99 -0
  82. package/dist/src/gates/manifest.js +208 -0
  83. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  84. package/dist/src/gates/merge-preconditions.js +932 -0
  85. package/dist/src/gates/migrations.d.ts +1 -0
  86. package/dist/src/gates/migrations.js +36 -0
  87. package/dist/src/gates/pin.d.ts +114 -0
  88. package/dist/src/gates/pin.js +154 -0
  89. package/dist/src/gates/red-witness.d.ts +22 -0
  90. package/dist/src/gates/red-witness.js +482 -0
  91. package/dist/src/gates/release.d.ts +283 -0
  92. package/dist/src/gates/release.js +820 -0
  93. package/dist/src/gates/result.d.ts +116 -0
  94. package/dist/src/gates/result.js +91 -0
  95. package/dist/src/gates/run.d.ts +614 -0
  96. package/dist/src/gates/run.js +1614 -0
  97. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  98. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  99. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  100. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  101. package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
  102. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  103. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  104. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  105. package/dist/src/gates/scope.d.ts +131 -0
  106. package/dist/src/gates/scope.js +1018 -0
  107. package/dist/src/gates/suite.d.ts +217 -0
  108. package/dist/src/gates/suite.js +968 -0
  109. package/dist/src/gates/validate.d.ts +121 -0
  110. package/dist/src/gates/validate.js +414 -0
  111. package/dist/src/hooks.d.ts +84 -0
  112. package/dist/src/hooks.js +125 -0
  113. package/dist/src/index.d.ts +31 -0
  114. package/dist/src/index.js +30 -0
  115. package/dist/src/liveness.d.ts +321 -0
  116. package/dist/src/liveness.js +396 -0
  117. package/dist/src/lock.d.ts +256 -0
  118. package/dist/src/lock.js +792 -0
  119. package/dist/src/model-resolution.d.ts +159 -0
  120. package/dist/src/model-resolution.js +307 -0
  121. package/dist/src/modes.d.ts +149 -0
  122. package/dist/src/modes.js +258 -0
  123. package/dist/src/path-identity.d.ts +34 -0
  124. package/dist/src/path-identity.js +48 -0
  125. package/dist/src/plan.d.ts +73 -0
  126. package/dist/src/plan.js +153 -0
  127. package/dist/src/pool.d.ts +326 -0
  128. package/dist/src/pool.js +988 -0
  129. package/dist/src/roles.d.ts +461 -0
  130. package/dist/src/roles.js +776 -0
  131. package/dist/src/spawn.d.ts +482 -0
  132. package/dist/src/spawn.js +1003 -0
  133. package/dist/src/status.d.ts +116 -0
  134. package/dist/src/status.js +148 -0
  135. package/dist/src/task.d.ts +504 -0
  136. package/dist/src/task.js +307 -0
  137. package/dist/src/teardown.d.ts +39 -0
  138. package/dist/src/teardown.js +422 -0
  139. package/dist/src/tuition.d.ts +159 -0
  140. package/dist/src/tuition.js +311 -0
  141. package/dist/src/validate.d.ts +263 -0
  142. package/dist/src/validate.js +742 -0
  143. package/dist/src/version.d.ts +3 -0
  144. package/dist/src/version.js +38 -0
  145. package/dist/src/watcher.d.ts +275 -0
  146. package/dist/src/watcher.js +849 -0
  147. package/dist/src/witness/run.d.ts +299 -0
  148. package/dist/src/witness/run.js +1373 -0
  149. package/dist/src/witness/spec.d.ts +270 -0
  150. package/dist/src/witness/spec.js +475 -0
  151. package/dist/tsconfig.src.tsbuildinfo +1 -0
  152. package/gate-registry.yaml +526 -0
  153. package/gates.manifest.json +257 -0
  154. package/package.json +72 -3
  155. package/role-model-config.yaml +88 -0
  156. package/roles/README.md +128 -0
  157. package/roles/_shared-dispatch-contract.md +87 -0
  158. package/roles/adversarial-plan-reviewer.md +80 -0
  159. package/roles/clean-room-reviewer.md +140 -0
  160. package/roles/implementer.md +463 -0
  161. package/roles/investigator.md +138 -0
  162. package/roles/plan-writer.md +95 -0
  163. package/schemas/README.md +82 -0
  164. package/schemas/assurance-modes.schema.json +264 -0
  165. package/schemas/charter.schema.json +185 -0
  166. package/schemas/checklist.schema.json +114 -0
  167. package/schemas/cutover-state.schema.json +64 -0
  168. package/schemas/decision-record.schema.json +88 -0
  169. package/schemas/executor-record.schema.json +36 -0
  170. package/schemas/final-report.schema.json +90 -0
  171. package/schemas/finding.schema.json +106 -0
  172. package/schemas/gate-registry.schema.json +260 -0
  173. package/schemas/mechanism-index.schema.json +94 -0
  174. package/schemas/model-resolution.schema.json +362 -0
  175. package/schemas/plan.schema.json +300 -0
  176. package/schemas/report.schema.json +579 -0
  177. package/schemas/role-brief.schema.json +105 -0
  178. package/schemas/role-model-config.schema.json +90 -0
  179. package/schemas/status-line.schema.json +40 -0
  180. package/schemas/tuition.schema.json +191 -0
  181. package/schemas/verdict.schema.json +295 -0
  182. package/schemas/work-history.schema.json +183 -0
  183. package/schemas/write-bypass.schema.json +69 -0
  184. package/templates/charter.example.yaml +54 -0
  185. package/templates/decision-record.example.yaml +27 -0
  186. package/templates/final-report.example.yaml +80 -0
  187. package/templates/plan.example.yaml +87 -0
  188. package/templates/report.example.yaml +236 -0
  189. package/templates/warnings.md +74 -0
  190. package/templates/work-history.example.yaml +185 -0
  191. package/tuition/README.md +76 -0
  192. package/tuition/T-001.yaml +48 -0
  193. package/tuition/T-002.yaml +51 -0
  194. package/tuition/T-003.yaml +100 -0
  195. package/tuition/T-004.yaml +52 -0
  196. package/tuition/T-005.yaml +72 -0
  197. package/tuition/T-006.yaml +81 -0
  198. package/tuition/T-007.yaml +56 -0
  199. package/tuition/T-008.yaml +111 -0
  200. package/tuition/T-009.yaml +50 -0
  201. package/tuition/T-015.yaml +36 -0
  202. package/tuition/T-016.yaml +36 -0
  203. package/tuition/T-017.yaml +46 -0
  204. package/tuition/T-018.yaml +84 -0
  205. package/tuition/T-021.yaml +40 -0
  206. package/tuition/T-022.yaml +36 -0
  207. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,1614 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { accessSync, constants as fsConstants, lstatSync, mkdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
6
+ /* M3-P2 step 4. The registry is validated by the M3-P1 Ajv engine, not by
7
+ M2's closed-keyword validator, because `schemas/gate-registry.schema.json`
8
+ uses `if`/`then`. `loadTypeSchema` is imported from the validate COMMAND
9
+ rather than re-deriving the shipped `schemas/` location here, because that
10
+ walk is already written, already tested, and already correct in both the
11
+ source and the dist layouts; a second copy would be a second thing to keep
12
+ right. */
13
+ import { loadTypeSchema } from "../commands/validate.js";
14
+ import { decodeDocument, formatDiagnostics, validateInstance } from "../validate.js";
15
+ import { loadManifest, validateManifestDocument, validateResultDocument } from "./manifest.js";
16
+ import { comparePins, describePinDifference } from "./pin.js";
17
+ import { EXIT_GATE_ERROR, EXIT_GREEN, EXIT_NOT_APPLICABLE, EXIT_RED, M2_C_2_DETAIL, exitCodeForStatus, makeGateResult, renderGateResult, statusForExitCode, } from "./result.js";
18
+ export const NO_APPLICABLE_GATE = "no applicable gate";
19
+ /**
20
+ * THE EVIDENCE ENTRY THAT MARKS A NOT-APPLICABLE AS DECLARED (M4-P11, DR-0038).
21
+ *
22
+ * `src/gates/release.ts:1050` already writes this exact string into
23
+ * `precondition.evidence` when a release verification is declared `none`, and
24
+ * `scripts/check-dual-review.mjs` now writes it for the declared single-family
25
+ * review exception. The owner's requirement for that exception is that nobody
26
+ * can hide it; it is stated here as a runner-level property because the place
27
+ * an exception hides is the AGGREGATE, not the gate's own record.
28
+ *
29
+ * WHY AN ARRAY ELEMENT AND NOT A FIELD. A boolean on `PreconditionRecord` would
30
+ * be the right home, and `src/gates/schemas/gate-result.schema.json` is
31
+ * `additionalProperties: false` on that object, so adding one is a schema
32
+ * change outside M4-P11's files-to-touch list. What is done instead is the
33
+ * narrowest available thing that is still structural: an EXACT element of a
34
+ * structured array, compared with `===`, never a pattern over the detail prose.
35
+ * MECHANISMS.md's row about deciding what another program will do by
36
+ * pattern-matching the text of a file it wrote is the failure this avoids, and
37
+ * matching one whole array element is on the safe side of it because the
38
+ * producer writes the element for this purpose and nothing else.
39
+ */
40
+ export const DECLARED_PRECONDITION_EVIDENCE = "declared: true";
41
+ /**
42
+ * Does this record's precondition carry a declaration?
43
+ *
44
+ * A not-applicable with NO precondition is not declared, and neither is one
45
+ * whose precondition carries no evidence: silence is never permission, and it
46
+ * is never a declaration either.
47
+ */
48
+ export function isDeclaredNotApplicable(result) {
49
+ if (result.status !== "not-applicable") {
50
+ return false;
51
+ }
52
+ const evidence = result.precondition?.evidence;
53
+ if (!Array.isArray(evidence)) {
54
+ return false;
55
+ }
56
+ return evidence.some((entry) => entry === DECLARED_PRECONDITION_EVIDENCE);
57
+ }
58
+ /** The default assurance mode when `--registry` is given without `--mode`. */
59
+ export const DEFAULT_MODE = "full";
60
+ /**
61
+ * Does this assurance mode select this entry?
62
+ *
63
+ * A named predicate rather than an inline callback, so the SELECTION RULE and
64
+ * the act of filtering are two separately breakable things. The clean-room
65
+ * review of this phase deleted the filter outright (`inMode = document.gates`)
66
+ * and the whole suite stayed green, because every mode fixture declared
67
+ * exactly the mode it was run under and no test had an entry that had to be
68
+ * EXCLUDED. `gate-registry-mode-excludes-other-modes` is the witness for the
69
+ * exclusion, and separating the predicate from the call site is what lets the
70
+ * two failure shapes, "no filter at all" and "a filter whose condition does
71
+ * not test membership", be reddened independently.
72
+ */
73
+ function selectsMode(entry, mode) {
74
+ return entry.modes.includes(mode);
75
+ }
76
+ /**
77
+ * LOAD A CANONICAL GATE REGISTRY AND PROJECT IT ONTO AN M2 GATE MANIFEST
78
+ * (kernel plan M3, M3-P2 steps 2 and 4; R-094).
79
+ *
80
+ * The registry is a SUPERSET of the M2-P1 manifest, so the promotion is a
81
+ * projection and not a rewrite, and this function is the whole of it. Four
82
+ * things happen here and each one is load-bearing:
83
+ *
84
+ * 1. The path is operator-supplied, so it is READ through the delivered
85
+ * `readRegularFileIfPresent` (M2-C-6). A named pipe reports the observed
86
+ * type and never blocks.
87
+ * 2. The document is DECODED (YAML or JSON, `decodeDocument`) and then
88
+ * VALIDATED against `schemas/gate-registry.schema.json` through the
89
+ * M3-P1 Ajv engine. Decoding and validation are separate stages and
90
+ * produce distinguishable diagnostics (DR-0013 YAML clause 3). The
91
+ * registry schema is NOT validated by `src/gates/validate.ts`: it uses
92
+ * `if`/`then`, which is outside M2-D-04's closed keyword set, which is
93
+ * exactly why it lives in the shipped `schemas/` directory.
94
+ * 3. Entries are selected by MODE and by `verified-by`. A
95
+ * `clean-room-checklist` entry has no process to run (D-11: R-043 and
96
+ * R-044 are not computable from a diff), so it is reported as declared
97
+ * rather than silently dropped.
98
+ * 4. The projected manifest is validated AGAIN, against the M2 manifest
99
+ * schema, by `validateManifestDocument`. That second validation is the
100
+ * superset claim turned into a check: if a projection is not a valid M2
101
+ * manifest then the registry is not a superset of the manifest, and the
102
+ * run refuses rather than proceeding on a document the M2 contract does
103
+ * not recognise.
104
+ *
105
+ * Everything after this function is the M2 runner untouched, so M2-C-2 (never
106
+ * green by omission) and M2-C-3 (fail closed) apply to registry runs by
107
+ * CONSTRUCTION rather than by being remembered. A `--registry` path that
108
+ * built `GateResult` literals of its own would be the realistic way those two
109
+ * constraints get dropped by a promotion, and there is no such path.
110
+ */
111
+ export function loadRegistry(path, mode) {
112
+ const read = readRegularFileIfPresent(path);
113
+ if (read.kind === "absent") {
114
+ return { ok: false, reason: `registry ${path} does not exist`, diagnostics: [] };
115
+ }
116
+ if (read.kind === "refused") {
117
+ return { ok: false, reason: read.reason, diagnostics: [] };
118
+ }
119
+ const decoded = decodeDocument(read.body, path);
120
+ if (!decoded.ok) {
121
+ return { ok: false, reason: decoded.reason, diagnostics: [] };
122
+ }
123
+ let schema;
124
+ try {
125
+ schema = loadTypeSchema("gate-registry");
126
+ }
127
+ catch (error) {
128
+ return {
129
+ ok: false,
130
+ reason: `the gate-registry schema could not be loaded: ${singleLine(error.message)}`,
131
+ diagnostics: [],
132
+ };
133
+ }
134
+ const diagnostics = formatDiagnostics(validateInstance(schema, decoded.value));
135
+ if (diagnostics.length > 0) {
136
+ return {
137
+ ok: false,
138
+ reason: `registry ${path} is not a valid gate registry`,
139
+ diagnostics,
140
+ };
141
+ }
142
+ const document = decoded.value;
143
+ const declaredModes = new Set();
144
+ for (const entry of document.gates) {
145
+ for (const name of entry.modes) {
146
+ declaredModes.add(name);
147
+ }
148
+ }
149
+ if (!declaredModes.has(mode)) {
150
+ // FAIL CLOSED (M2-C-3). A mode no entry declares selects nothing, and a
151
+ // run of nothing that exited 0 would be the vacuous pass this registry
152
+ // exists to prevent. The known modes are named so the operator can see
153
+ // the typo rather than guess at an empty bundle.
154
+ return {
155
+ ok: false,
156
+ reason: `registry ${path} declares no gate for mode ${mode}; declared mode(s): ` +
157
+ `${[...declaredModes].sort().join(", ")}`,
158
+ diagnostics: [],
159
+ };
160
+ }
161
+ const inMode = document.gates.filter((entry) => selectsMode(entry, mode));
162
+ const declaredByChecklist = inMode
163
+ .filter((entry) => entry["verified-by"] === "clean-room-checklist")
164
+ .map((entry) => ({
165
+ id: entry.id,
166
+ probe: entry.probe,
167
+ applicability: entry.applicability,
168
+ }));
169
+ const gates = inMode
170
+ .filter((entry) => entry["verified-by"] === "script")
171
+ .map((entry) => {
172
+ const projected = {
173
+ id: entry.id,
174
+ command: entry.command,
175
+ unitLabel: entry.unitLabel,
176
+ applicability: entry.applicability,
177
+ modes: entry.modes,
178
+ };
179
+ if (entry.parameters !== undefined) {
180
+ projected.parameters = entry.parameters;
181
+ }
182
+ if (entry.precondition !== undefined) {
183
+ projected.precondition = entry.precondition;
184
+ }
185
+ return projected;
186
+ });
187
+ const manifest = {
188
+ version: document.version,
189
+ gates,
190
+ destructiveCommands: document.destructiveCommands,
191
+ };
192
+ const projectionDiagnostics = validateManifestDocument(manifest);
193
+ if (projectionDiagnostics.length > 0) {
194
+ return {
195
+ ok: false,
196
+ reason: `registry ${path} projected onto an M2 gate manifest that the M2 schema refuses, ` +
197
+ "so this registry is not a superset of the manifest it promotes",
198
+ diagnostics: projectionDiagnostics,
199
+ };
200
+ }
201
+ return {
202
+ ok: true,
203
+ manifest,
204
+ sha256: createHash("sha256").update(read.body).digest("hex"),
205
+ body: read.body,
206
+ declaredByChecklist,
207
+ };
208
+ }
209
+ function now() {
210
+ return new Date().toISOString();
211
+ }
212
+ /**
213
+ * Escape a value being substituted into regex SOURCE. The set is the one
214
+ * MDN documents for this purpose; `-` is included because it is special
215
+ * inside a character class and a substituted value can land in one.
216
+ */
217
+ function escapeRegExp(value) {
218
+ return value.replace(/[.*+?^${}()|[\]\\\-]/g, "\\$&");
219
+ }
220
+ function isRealDirectory(path) {
221
+ try {
222
+ return lstatSync(path).isDirectory();
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
228
+ /**
229
+ * Create a directory, refusing a path that exists and is not a real
230
+ * directory. Never opens anything, so a named pipe here reports rather than
231
+ * blocking.
232
+ */
233
+ function ensureDirectory(path) {
234
+ const entry = classifyEntry(path);
235
+ if (entry.kind === "regular") {
236
+ return `${path} is a regular file, not a directory`;
237
+ }
238
+ if (entry.kind === "unexaminable") {
239
+ return entry.reason;
240
+ }
241
+ if (entry.kind === "irregular" && !isRealDirectory(path)) {
242
+ return entry.reason;
243
+ }
244
+ const made = runStep(`creating ${path}`, () => mkdirSync(path, { recursive: true }));
245
+ return made.ok ? undefined : made.reason;
246
+ }
247
+ /**
248
+ * MUTATE ONLY WHILE THIS RUN HOLDS THE CLAIM (CR-860, then CR-900).
249
+ *
250
+ * The finding's mechanism is *a mutation of evidence-directory state
251
+ * performed from a frame that does not know whether the claim is held*.
252
+ *
253
+ * CR-900 is the correction of ONE WORD in that sentence. Round two read the
254
+ * mechanism as being about WRITES, built `writeInsideClaim` around
255
+ * `writeFileSync`, derived its coverage by grepping for the name of its own
256
+ * new wrapper, and concluded that every write into the directory verified the
257
+ * claim. That sentence was true and it was not the property that mattered,
258
+ * because a directory's state is mutated by six operations and not by one:
259
+ *
260
+ * CREATE, CONTENT-WRITE, DELETE, RENAME, MKDIR, and SUBPROCESS DISPATCH
261
+ * that will write there.
262
+ *
263
+ * A two-gate construction defeated the total claim in one run: the runner
264
+ * `rmSync`ed a foreign holder's `result.json` and then spawned a gate into a
265
+ * directory whose claim it had lost, neither of which is a `writeFileSync`.
266
+ *
267
+ * So the predicate is separated from the operation. THIS function answers
268
+ * "may this run mutate that directory right now", and every one of the six
269
+ * operations asks it, rather than one of them owning the rule. The wrappers
270
+ * below (`writeInsideClaim`, `mkdirInsideClaim`) and the four inline checks in
271
+ * `runOneGate`, `runClaimedBundle` and `writeSummaryAtomically` are its
272
+ * callers; the work history's CR-900 section carries the full inventory that
273
+ * fixes what "every" ranges over, derived from the closure's `node:fs` and
274
+ * `node:child_process` imports rather than from the names of these wrappers.
275
+ *
276
+ * Guarding the mutation rather than the call site also makes the ordering
277
+ * OBSERVABLE, which it was not: a release moved back in front of a mutation is
278
+ * now a refusal with a reason, rather than an identical end state reachable
279
+ * two ways. That is what let the ordering be red-witnessed at all (G2b).
280
+ *
281
+ * NOT COVERED, and stated rather than implied: a gate subprocess's own write
282
+ * to the record path it was handed. This runner cannot guard another
283
+ * process's write. What it CAN do, and now does, is decline to dispatch that
284
+ * process at all when it does not hold the claim, and decline to certify a
285
+ * run in which that happened.
286
+ */
287
+ function refuseUnlessHolder(evidenceDir, runId, what) {
288
+ const holder = claimHolder(evidenceDir);
289
+ if (holder !== runId) {
290
+ return (`refusing to ${what}: this run (${runId}) does not hold the claim ` +
291
+ `on ${evidenceDir} (held by ${holder ?? "nobody"})`);
292
+ }
293
+ return undefined;
294
+ }
295
+ /** Content write, row 2 of the CR-900 inventory. */
296
+ function writeInsideClaim(evidenceDir, runId, path, body) {
297
+ return (refuseUnlessHolder(evidenceDir, runId, `write ${path}`) ??
298
+ guardedWrite(path, body));
299
+ }
300
+ /**
301
+ * Mkdir, rows 1a and 1b of the CR-900 inventory. Creating a directory inside
302
+ * an evidence tree this run no longer owns is a smaller harm than deleting a
303
+ * record, and it is the same mechanism: it leaves a directory the real holder
304
+ * never made, in a bundle attributed to the real holder.
305
+ *
306
+ * Row 1c, the creation of the evidence directory ITSELF at the top of
307
+ * `runGatesInner`, deliberately does NOT go through here and must not: the
308
+ * directory has to exist before a claim file can be created inside it, so a
309
+ * holdership test there would be unsatisfiable by construction. That single
310
+ * mkdir is the only mutation in this module that legitimately precedes the
311
+ * claim, and it creates a directory rather than touching any content.
312
+ */
313
+ function mkdirInsideClaim(evidenceDir, runId, path) {
314
+ return (refuseUnlessHolder(evidenceDir, runId, `create ${path}`) ??
315
+ ensureDirectory(path));
316
+ }
317
+ /** Write a file, refusing any path that is not safe to open for writing. */
318
+ function guardedWrite(path, body) {
319
+ const refusal = refuseOpenForWrite(path);
320
+ if (refusal !== undefined) {
321
+ return refusal;
322
+ }
323
+ const written = runStep(`writing ${path}`, () => writeFileSync(path, body));
324
+ return written.ok ? undefined : written.reason;
325
+ }
326
+ /**
327
+ * Which run parameters a gate cannot be evaluated without.
328
+ *
329
+ * Derived from two places and nowhere else: the gate's own `parameters`
330
+ * declaration, and the precondition kind, whose needs are a property of the
331
+ * kind rather than of the gate. `branch-matches` requires `--phase`
332
+ * unconditionally rather than only when the pattern happens to interpolate
333
+ * it, which is stricter than necessary and deliberately so: M2-C-3 says a
334
+ * check that cannot reach a verdict fails closed, and the cost of the strict
335
+ * reading is one flag on an invocation that already carries three.
336
+ */
337
+ export function requiredParameters(entry) {
338
+ const required = new Set(entry.parameters ?? []);
339
+ const kind = entry.precondition?.kind;
340
+ if (kind === "diff-touches") {
341
+ required.add("base");
342
+ }
343
+ if (kind === "branch-matches") {
344
+ required.add("phase");
345
+ }
346
+ return [...required].sort();
347
+ }
348
+ /**
349
+ * ELEMENTS THAT ARE NOT PATHS, WHATEVER SHAPE THEY HAVE. One function, so the
350
+ * two rules below cannot drift apart: fix round 2 found the URL case by
351
+ * fixing only the wider rule and watching the narrower one hard-refuse the
352
+ * same element BEFORE the spawn, which is a strictly worse failure because it
353
+ * refuses a command that would have exited 0. The three reasons are the three
354
+ * this file claims to know, and each is written down rather than inferred:
355
+ *
356
+ * an OPTION begins with `-`. `--out=/tmp/x` is an option carrying
357
+ * a value, not a path; probing the whole element could
358
+ * never succeed, since no file is named `--out=/tmp/x`.
359
+ * an OPTION'S CODE the element before it is in `CODE_VALUED_OPTIONS`.
360
+ * a URL `scheme://...` has slashes and no filesystem.
361
+ */
362
+ function namesNoPath(element, previous) {
363
+ return (element.startsWith("-") || CODE_VALUED_OPTIONS.has(previous) || URL_SHAPED.test(element));
364
+ }
365
+ /**
366
+ * The path operands of a command, by the four-part rule documented above,
367
+ * MINUS the elements `namesNoPath` rules out. This is the STRICT set: it is
368
+ * probed before the spawn and a failure here is a hard `error`, so it stays
369
+ * conservative and keeps the whitespace and after-an-option guards that the
370
+ * wider rule drops.
371
+ */
372
+ export function commandPathOperands(command) {
373
+ const operands = [];
374
+ for (let index = 1; index < command.length; index += 1) {
375
+ const element = command[index];
376
+ const previous = command[index - 1];
377
+ if (namesNoPath(element, previous)) {
378
+ continue;
379
+ }
380
+ if (!element.includes("/")) {
381
+ continue;
382
+ }
383
+ if (/\s/.test(element)) {
384
+ continue;
385
+ }
386
+ if (previous.startsWith("-")) {
387
+ continue;
388
+ }
389
+ operands.push(element);
390
+ }
391
+ return operands;
392
+ }
393
+ /**
394
+ * Options whose VALUE is CODE and never a path. A closed, explicit list, and
395
+ * that is the point: it is the one piece of launcher grammar this file claims
396
+ * to know, it is written down rather than inferred, and anything not in it is
397
+ * treated as possibly naming a path. `node -e` and `node --eval` are the pair
398
+ * that matters here, because `credential-token`'s real precondition in
399
+ * gates.manifest.json:57 is exactly that shape; the others are the same
400
+ * construct in the launchers a precondition is most likely to use (`sh -c`,
401
+ * `bash -c`, `python -c`, `perl -e`, `ruby -e`, `node -p`).
402
+ */
403
+ const CODE_VALUED_OPTIONS = new Set([
404
+ "-e",
405
+ "--eval",
406
+ "-p",
407
+ "--print",
408
+ "-c",
409
+ "--command",
410
+ ]);
411
+ /**
412
+ * Suffixes that make a DIRECTORY-LESS operand a script path. This is the only
413
+ * reason `node check.mjs` (fix round 1's declared residue, and the exact
414
+ * defect class this phase exists to close) is visible at all: it has no `/`,
415
+ * so the separator test cannot see it. A closed list, deliberately, because
416
+ * the alternative is dropping the shape test entirely and that breaks a real
417
+ * declaration: `check-dual-review`'s precondition ends `--precondition .`, and
418
+ * `.` resolves to a DIRECTORY, which `probeOpenable` calls irregular, which
419
+ * would make every run of that gate a false `error`. Measured, not assumed.
420
+ */
421
+ const SCRIPT_SUFFIXES = [
422
+ ".mjs",
423
+ ".cjs",
424
+ ".js",
425
+ ".ts",
426
+ ".mts",
427
+ ".cts",
428
+ ".sh",
429
+ ".bash",
430
+ ".py",
431
+ ".rb",
432
+ ".pl",
433
+ ];
434
+ /** `scheme://...`, which contains slashes and is never a filesystem path. */
435
+ const URL_SHAPED = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
436
+ /**
437
+ * Every argv element that names a PATH THE COMMAND NEEDED, used only to decide
438
+ * whether a NONZERO exit is attributable to the command's own logic.
439
+ *
440
+ * ------------------------------------------------------------------------
441
+ * FIX ROUND 2 (M3-P11) REWROTE THIS RULE, AND WHY IS THE WHOLE POINT.
442
+ *
443
+ * Fix round 1 defined it as "contains `/`", at any position, launcher
444
+ * included. A delta verifier measured what that costs
445
+ * (delivery/verification/m3-p11-fix-round-1.md, findings 2a and 2b) and the
446
+ * cost is larger than round 1's own note conveyed: `decideAggregate` checks
447
+ * `counts.error > 0` FIRST, so ONE false error on ONE conditional gate fails
448
+ * the ENTIRE bundle. An honest, correctly written precondition could take a
449
+ * consumer's whole delivery down. That is a worse failure than the silent
450
+ * skip this phase set out to abolish, because a silent skip is wrong and
451
+ * quiet while this is wrong and total.
452
+ *
453
+ * THE MECHANISM: "contains a slash" was being used as a proxy for "is a path
454
+ * operand", and it is neither necessary nor sufficient.
455
+ *
456
+ * NOT SUFFICIENT: inline code (`process.exit(existsSync("/marker")?0:1)`),
457
+ * a URL, an `--opt=/value` pair, a date (`2026/08/14`), a regex and plain
458
+ * division all contain `/` and none of them is a path.
459
+ *
460
+ * NOT NECESSARY: `node check.mjs` names a real script with no `/` in it.
461
+ *
462
+ * So the rule now tests four things instead of one. An element at index >= 1
463
+ * is a path this command needed when ALL FOUR hold:
464
+ *
465
+ * 1. it does not itself begin with `-`. An option is not an operand, and
466
+ * `--out=/tmp/x` is an option carrying a value, not a path: probing the
467
+ * whole element was a GUARANTEED false error for every `--opt=/path`
468
+ * form, since no file is ever named `--out=/tmp/x`.
469
+ * 2. the element before it is not in `CODE_VALUED_OPTIONS`. This is the
470
+ * `node -e` case, and it is the one measured in finding 2a.
471
+ * 3. it is not URL-shaped.
472
+ * 4. it either contains `/`, or it carries a `SCRIPT_SUFFIXES` suffix and
473
+ * no whitespace. The second disjunct is new in round 2 and is what
474
+ * closes the bare-operand residue.
475
+ *
476
+ * The launcher (index 0) is deliberately NOT in this set. It is already
477
+ * probed, with the executable conditions on top, by `probeCommandRunnable`
478
+ * before the spawn, so including it here only duplicated that work.
479
+ *
480
+ * WHAT THIS RULE STILL GETS WRONG, stated rather than left to be discovered.
481
+ * A FALSE ERROR remains reachable for an element that is not a path, is not
482
+ * an option's value, and either contains `/` or ends in a script suffix: a
483
+ * bare date operand (`mytool 2026/08/14`), an operand-position regex, and a
484
+ * value passed to an option that takes a non-path value NOT in
485
+ * `CODE_VALUED_OPTIONS` (`awk -v expr=a/b`). A SILENT SKIP remains reachable
486
+ * for an operand with no `/` and no known suffix (`node check`, an
487
+ * extensionless script), for a path named through an environment variable or
488
+ * produced by a shell, and for an `--opt=/path` pair, which rule 1 now
489
+ * declines to probe. Both lists are shorter than round 1's; neither is empty.
490
+ * Full accounting, with the enumeration that produced it, in
491
+ * delivery/work-history/m3-p11.md.
492
+ */
493
+ export function commandPathCandidates(command) {
494
+ const candidates = [];
495
+ for (let index = 1; index < command.length; index += 1) {
496
+ const element = command[index];
497
+ const previous = command[index - 1];
498
+ if (namesNoPath(element, previous)) {
499
+ continue;
500
+ }
501
+ const named = element.includes("/") ||
502
+ (!/\s/.test(element) && SCRIPT_SUFFIXES.some((suffix) => element.endsWith(suffix)));
503
+ if (!named) {
504
+ continue;
505
+ }
506
+ candidates.push(element);
507
+ }
508
+ return [...new Set(candidates)];
509
+ }
510
+ /**
511
+ * MAY THIS PROCESS OPEN THIS PATH? The complete question, asked once, so
512
+ * there is one enumeration of "what opening a file requires" and not one per
513
+ * call site: it must exist, it must be a regular file, and this process must
514
+ * be permitted to read it. Fix round 1, half A (finding H-1).
515
+ *
516
+ * `requireExecutable` adds the launcher's two extra conditions: the
517
+ * UID-independent mode-bit test that was already here, and `access(X_OK)`,
518
+ * which is the UID-dependent one it could not answer.
519
+ */
520
+ function probeOpenable(display, absolute, requireExecutable) {
521
+ const entry = classifyEntry(absolute);
522
+ if (entry.kind === "absent") {
523
+ return { ok: false, reason: `${display} does not exist (resolved to ${absolute})` };
524
+ }
525
+ if (entry.kind === "dangling") {
526
+ return {
527
+ ok: false,
528
+ reason: `${display} is a symbolic link whose target does not exist (resolved to ${absolute})`,
529
+ };
530
+ }
531
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
532
+ return { ok: false, reason: entry.reason };
533
+ }
534
+ const stat = runStep(`examining ${absolute}`, () => statSync(absolute));
535
+ if (!stat.ok) {
536
+ return { ok: false, reason: stat.reason };
537
+ }
538
+ const mode = (stat.value.mode & 0o777).toString(8);
539
+ const readable = runStep(`checking read access to ${absolute}`, () => accessSync(absolute, fsConstants.R_OK));
540
+ if (!readable.ok) {
541
+ return {
542
+ ok: false,
543
+ reason: `${display} exists and is a regular file but is NOT READABLE by this process ` +
544
+ `(mode ${mode}, resolved to ${absolute}); the command would launch and then fail to ` +
545
+ "open it, which an exit code cannot be distinguished from a deliberate refusal",
546
+ };
547
+ }
548
+ if (!requireExecutable) {
549
+ return { ok: true };
550
+ }
551
+ if ((stat.value.mode & 0o111) === 0) {
552
+ return {
553
+ ok: false,
554
+ reason: `${display} is not executable (mode ${mode}, resolved to ${absolute})`,
555
+ };
556
+ }
557
+ const executable = runStep(`checking execute access to ${absolute}`, () => accessSync(absolute, fsConstants.X_OK));
558
+ if (!executable.ok) {
559
+ return {
560
+ ok: false,
561
+ reason: `${display} carries an execute bit but is NOT EXECUTABLE by this process ` +
562
+ `(mode ${mode}, resolved to ${absolute})`,
563
+ };
564
+ }
565
+ return { ok: true };
566
+ }
567
+ /**
568
+ * Can this command run at all? See the block comment above for the rule and
569
+ * for what it deliberately does not cover.
570
+ *
571
+ * The launcher is probed only when it names a path (contains `/`); a bare
572
+ * name is a PATH lookup and is left to `spawnSync`'s own `error`, which
573
+ * already reports it, rather than reimplementing PATH resolution here.
574
+ */
575
+ export function probeCommandRunnable(command, cwd) {
576
+ const probed = [];
577
+ const launcher = command[0];
578
+ if (launcher === undefined || launcher === "") {
579
+ return { runnable: false, reason: "the command is empty", probed };
580
+ }
581
+ const targets = [];
582
+ if (launcher.includes("/")) {
583
+ targets.push({ path: launcher, isLauncher: true });
584
+ }
585
+ for (const operand of commandPathOperands(command)) {
586
+ targets.push({ path: operand, isLauncher: false });
587
+ }
588
+ for (const target of targets) {
589
+ const absolute = isAbsolute(target.path) ? target.path : resolve(cwd, target.path);
590
+ probed.push(target.path);
591
+ const openable = probeOpenable(target.path, absolute, target.isLauncher);
592
+ if (!openable.ok) {
593
+ return { runnable: false, reason: openable.reason, probed };
594
+ }
595
+ }
596
+ return { runnable: true, reason: "", probed };
597
+ }
598
+ /**
599
+ * Every path-shaped argv element this process cannot open, with the reason.
600
+ * Empty means a nonzero exit is ATTRIBUTABLE to the command's own logic;
601
+ * non-empty means it is not, and M2-C-3 says a check that cannot reach a
602
+ * verdict fails closed rather than guessing one.
603
+ *
604
+ * FIX ROUND 2 MOVED THE CALL SITE, and the move is the fix for a second
605
+ * mechanism, independent of which elements are scanned. Round 1 ran this
606
+ * AFTER the spawn, on the nonzero arm only. The question it answers is "did
607
+ * the command have what it needed IN ORDER TO RUN", which is a question about
608
+ * the moment BEFORE the spawn, and answering it from the filesystem AFTER the
609
+ * spawn reads the command's own effects back as evidence about its inputs. A
610
+ * precondition script that legitimately decides "unmet" and deletes itself as
611
+ * its last act (a one-shot or bootstrap script) was therefore reported
612
+ * `error`, deterministically, no timing window needed: measured in
613
+ * delivery/verification/m3-p11-fix-round-1.md as finding 2b.
614
+ *
615
+ * So the scan now runs BEFORE the spawn and its result is CARRIED. The exit
616
+ * code still decides whether the result is consulted: an exit of 0 is its own
617
+ * proof that the command ran, so no declaration that succeeds can be affected
618
+ * by this at all, which is the property round 1 established and round 2 keeps.
619
+ */
620
+ export function attributionGaps(command, cwd) {
621
+ const gaps = [];
622
+ for (const element of commandPathCandidates(command)) {
623
+ const absolute = isAbsolute(element) ? element : resolve(cwd, element);
624
+ // A DIRECTORY IS A PATH, and this scan must not say otherwise. Round 2
625
+ // found this by enumeration rather than by argument: this repository's OWN
626
+ // `scope` gate is declared as
627
+ // `node src/gates/scope.ts --declarations delivery/plan/phase-declarations`
628
+ // (gate-registry.yaml:126), whose last element is a directory that exists,
629
+ // is exactly what the command wants, and which `probeOpenable` refuses as
630
+ // "not a regular file". That gate has no `command-exit-zero` precondition,
631
+ // so nothing was breaking today, but it is a real declared counter-example
632
+ // to the regular-file question being the right one HERE. The pre-spawn
633
+ // runnability probe keeps asking the stricter question, because there the
634
+ // element is a script the LAUNCHER is about to open.
635
+ const kind = runStep(`examining ${absolute}`, () => statSync(absolute));
636
+ if (kind.ok && kind.value.isDirectory()) {
637
+ const enterable = runStep(`entering ${absolute}`, () => accessSync(absolute, fsConstants.R_OK | fsConstants.X_OK));
638
+ if (!enterable.ok) {
639
+ gaps.push(`${element} is a directory this process cannot read or enter (resolved to ${absolute})`);
640
+ }
641
+ continue;
642
+ }
643
+ const openable = probeOpenable(element, absolute, false);
644
+ if (!openable.ok) {
645
+ gaps.push(openable.reason);
646
+ }
647
+ }
648
+ return gaps;
649
+ }
650
+ function gitLines(cwd, args) {
651
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
652
+ if (result.error !== undefined) {
653
+ return {
654
+ ok: false,
655
+ reason: `git ${args.join(" ")} could not be run: ${singleLine(String(result.error))}`,
656
+ };
657
+ }
658
+ if (result.status !== 0) {
659
+ return {
660
+ ok: false,
661
+ reason: `git ${args.join(" ")} exited ${String(result.status)}: ${singleLine(result.stderr ?? "")}`,
662
+ };
663
+ }
664
+ return {
665
+ ok: true,
666
+ lines: (result.stdout ?? "")
667
+ .split("\n")
668
+ .map((line) => line.trim())
669
+ .filter((line) => line !== ""),
670
+ };
671
+ }
672
+ function evaluatePrecondition(precondition, options, cwd) {
673
+ const id = precondition.id;
674
+ if (precondition.kind === "file-exists" || precondition.kind === "file-absent") {
675
+ const path = precondition.path;
676
+ if (path === undefined) {
677
+ return { kind: "error", reason: `precondition ${id} declares no path` };
678
+ }
679
+ const entry = classifyEntry(path);
680
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
681
+ // M2-C-6: present, and not a thing this gate may open. Reporting the
682
+ // observed type is the whole point; guessing "unmet" would be a
683
+ // verdict about a path nobody examined.
684
+ return { kind: "error", reason: entry.reason };
685
+ }
686
+ const present = entry.kind === "regular";
687
+ const wanted = precondition.kind === "file-exists";
688
+ const met = present === wanted;
689
+ const record = {
690
+ id,
691
+ met,
692
+ reason: present
693
+ ? `${path} is a regular file`
694
+ : `${path} does not exist`,
695
+ evidence: [path],
696
+ };
697
+ return met ? { kind: "met", record } : { kind: "unmet", record };
698
+ }
699
+ if (precondition.kind === "branch-matches") {
700
+ const pattern = precondition.pattern;
701
+ if (pattern === undefined) {
702
+ return { kind: "error", reason: `precondition ${id} declares no pattern` };
703
+ }
704
+ if (options.phase === undefined) {
705
+ return {
706
+ kind: "error",
707
+ reason: `precondition ${id} is kind branch-matches and --phase was not supplied`,
708
+ };
709
+ }
710
+ const branch = gitLines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
711
+ if (!branch.ok) {
712
+ return { kind: "error", reason: branch.reason };
713
+ }
714
+ const name = branch.lines[0] ?? "";
715
+ // CR-805, two faults in one line. `{phase}` is documented as a TOKEN
716
+ // substitution, and substituting a value into regex SOURCE without
717
+ // escaping silently changes the pattern's meaning: a phase id containing
718
+ // `.` matched any character. And the compiled expression was unanchored,
719
+ // so `claude/m2-p4-` matched the decoy branch
720
+ // `evil/claude/m2-p4-scope-auditor-DECOY`. A precondition whose job is to
721
+ // decide "am I on the branch this phase governs" was deciding it on a
722
+ // strictly weaker predicate than it appeared to.
723
+ const source = pattern.split("{phase}").join(escapeRegExp(options.phase));
724
+ let expression;
725
+ try {
726
+ expression = new RegExp(`^(?:${source})$`);
727
+ }
728
+ catch (error) {
729
+ return {
730
+ kind: "error",
731
+ reason: `precondition ${id} pattern ${source} is not a valid expression: ${error.message}`,
732
+ };
733
+ }
734
+ const met = expression.test(name);
735
+ const record = {
736
+ id,
737
+ met,
738
+ reason: `branch ${name} ${met ? "matches" : "does not match"} ^(?:${source})$`,
739
+ };
740
+ return met ? { kind: "met", record } : { kind: "unmet", record };
741
+ }
742
+ if (precondition.kind === "diff-touches") {
743
+ const paths = precondition.paths;
744
+ if (paths === undefined || paths.length === 0) {
745
+ return { kind: "error", reason: `precondition ${id} declares no paths` };
746
+ }
747
+ if (options.base === undefined) {
748
+ return {
749
+ kind: "error",
750
+ reason: `precondition ${id} is kind diff-touches and --base was not supplied`,
751
+ };
752
+ }
753
+ const head = options.head ?? "HEAD";
754
+ const changed = gitLines(cwd, [
755
+ "diff",
756
+ "--name-only",
757
+ `${options.base}...${head}`,
758
+ ]);
759
+ if (!changed.ok) {
760
+ return { kind: "error", reason: changed.reason };
761
+ }
762
+ const touched = changed.lines.filter((line) => paths.some((prefix) => line === prefix || line.startsWith(prefix)));
763
+ const met = touched.length > 0;
764
+ const record = {
765
+ id,
766
+ met,
767
+ reason: met
768
+ ? `${String(touched.length)} changed path(s) under ${paths.join(", ")}`
769
+ : `no changed path under ${paths.join(", ")}`,
770
+ evidence: touched,
771
+ };
772
+ return met ? { kind: "met", record } : { kind: "unmet", record };
773
+ }
774
+ const command = precondition.command;
775
+ if (command === undefined || command.length === 0) {
776
+ return { kind: "error", reason: `precondition ${id} declares no command` };
777
+ }
778
+ // M3-P11. THE PROBE COMES BEFORE THE SPAWN, and it is the whole of the
779
+ // difference between the three outcomes below and the two there used to be.
780
+ // After the spawn there is only an exit code, and an exit code cannot
781
+ // separate "this script does not exist" from "this script says no".
782
+ const runnable = probeCommandRunnable(command, cwd);
783
+ if (!runnable.runnable) {
784
+ return {
785
+ kind: "error",
786
+ reason: `precondition ${id} command ${command.join(" ")} could not be run: ${runnable.reason}` +
787
+ ` (this is NOT not-applicable: nothing was evaluated, M2-C-3)`,
788
+ };
789
+ }
790
+ // FIX ROUND 2, MECHANISM B. Taken HERE, before the spawn, and carried.
791
+ // "Did this command have what it needed" is a question about the state the
792
+ // command was launched into, and the filesystem after it has run is a
793
+ // different subject. Consulted only on the nonzero arm, below.
794
+ const gapsAtSpawnTime = attributionGaps(command, cwd);
795
+ const result = spawnSync(command[0], command.slice(1), {
796
+ cwd,
797
+ encoding: "utf8",
798
+ });
799
+ if (result.error !== undefined) {
800
+ // The command does not exist, or could not be executed. That is not
801
+ // "the precondition is unmet": nothing was evaluated (M2-C-3).
802
+ return {
803
+ kind: "error",
804
+ reason: `precondition ${id} command ${command.join(" ")} could not be run: ${singleLine(String(result.error))}`,
805
+ };
806
+ }
807
+ if (result.signal !== null && result.signal !== undefined) {
808
+ return {
809
+ kind: "error",
810
+ reason: `precondition ${id} command ${command.join(" ")} was terminated by ${result.signal}`,
811
+ };
812
+ }
813
+ const met = result.status === 0;
814
+ if (!met) {
815
+ // FIX ROUND 1, HALF B, AS CORRECTED BY ROUND 2. Reading this nonzero exit
816
+ // as "evaluated and unmet" asserts that the command reached its own logic.
817
+ // That assertion is only sound if everything the command needed in order
818
+ // to get there was available AT THE MOMENT IT WAS LAUNCHED, which is what
819
+ // `gapsAtSpawnTime` records. The pre-spawn runnability probe establishes
820
+ // the same thing for a PROPER SUBSET of the elements (it declines to look
821
+ // at an option's value at all, and it hard-refuses rather than carrying a
822
+ // result), so this wider, carried scan is what covers the rest.
823
+ if (gapsAtSpawnTime.length > 0) {
824
+ return {
825
+ kind: "error",
826
+ reason: `precondition ${id} command ${command.join(" ")} exited ${String(result.status)}, ` +
827
+ `and that exit CANNOT BE ATTRIBUTED to an evaluated precondition: ` +
828
+ `${String(gapsAtSpawnTime.length)} path-shaped argv element(s) could not be opened by ` +
829
+ `this process when the command was launched: ` +
830
+ `${gapsAtSpawnTime.join("; ")}` +
831
+ ` (this is NOT not-applicable: nothing was established, M2-C-3. If such an element is ` +
832
+ `not a path, give the command a form in which it is not path-shaped)`,
833
+ };
834
+ }
835
+ }
836
+ const record = {
837
+ id,
838
+ met,
839
+ reason: `${command.join(" ")} exited ${String(result.status)}`,
840
+ };
841
+ return met ? { kind: "met", record } : { kind: "unmet", record };
842
+ }
843
+ function errorResult(entry, startedAt, detail, precondition) {
844
+ return makeGateResult({
845
+ gate: entry.id,
846
+ status: "error",
847
+ units: 0,
848
+ unitLabel: entry.unitLabel,
849
+ startedAt,
850
+ endedAt: now(),
851
+ detail,
852
+ precondition,
853
+ });
854
+ }
855
+ function runOneGate(entry, options, cwd, evidenceDir, runId) {
856
+ const startedAt = now();
857
+ const gateDir = join(evidenceDir, entry.id);
858
+ // CR-900 row 1a. Every mutation below is inside `evidenceDir`, and the claim
859
+ // can be lost between gates (a previous gate steals it) or DURING one (this
860
+ // gate's own precondition command steals it), so the question is asked at
861
+ // each operation and not once at the top of this function.
862
+ const dirRefusal = mkdirInsideClaim(evidenceDir, runId, gateDir);
863
+ if (dirRefusal !== undefined) {
864
+ return {
865
+ result: errorResult(entry, startedAt, dirRefusal),
866
+ applicable: false,
867
+ };
868
+ }
869
+ const recordPath = join(gateDir, "result.json");
870
+ const stdoutPath = join(gateDir, "stdout.txt");
871
+ const stderrPath = join(gateDir, "stderr.txt");
872
+ // Parameters first: a gate invoked without something it needs measured
873
+ // nothing, and that is `error`, never `not-applicable` (M2-C-3, M2R-003).
874
+ const missing = requiredParameters(entry).filter((name) => options[name] === undefined);
875
+ if (missing.length > 0) {
876
+ return {
877
+ result: errorResult(entry, startedAt, `gate ${entry.id} requires ${missing.map((name) => `--${name}`).join(" ")}, which was not supplied`),
878
+ applicable: false,
879
+ };
880
+ }
881
+ if (entry.precondition !== undefined) {
882
+ // CR-900 row 4, which the review's table did not list. Evaluating a
883
+ // precondition DISPATCHES a subprocess: `git` for `branch-matches` and
884
+ // `diff-touches`, and for kind `command` an ARBITRARY program named by the
885
+ // manifest. Dispatching a program is a mutation of this directory by
886
+ // proxy, so it asks the same question the delete and the gate spawn ask.
887
+ const preconditionRefusal = refuseUnlessHolder(evidenceDir, runId, `evaluate precondition ${entry.precondition.id} for gate ${entry.id}`);
888
+ if (preconditionRefusal !== undefined) {
889
+ return {
890
+ result: errorResult(entry, startedAt, preconditionRefusal),
891
+ applicable: false,
892
+ };
893
+ }
894
+ const outcome = evaluatePrecondition(entry.precondition, options, cwd);
895
+ if (outcome.kind === "error") {
896
+ return {
897
+ result: errorResult(entry, startedAt, outcome.reason),
898
+ applicable: false,
899
+ };
900
+ }
901
+ if (outcome.kind === "unmet") {
902
+ return {
903
+ result: makeGateResult({
904
+ gate: entry.id,
905
+ status: "not-applicable",
906
+ units: 0,
907
+ unitLabel: entry.unitLabel,
908
+ startedAt,
909
+ endedAt: now(),
910
+ detail: `precondition ${outcome.record.id} evaluated and unmet: ${outcome.record.reason}`,
911
+ precondition: outcome.record,
912
+ }),
913
+ applicable: false,
914
+ };
915
+ }
916
+ }
917
+ // M3-P11, the same rule one level out. A GATE whose command names a path
918
+ // that does not exist reached `error` before this phase too, but by a
919
+ // route that named the wrong path: the child exited 1 without writing a
920
+ // record, so the detail read "gate X exited 1 without writing a result
921
+ // record at <the RECORD path>", which is the one path in the sentence that
922
+ // is not the problem. Probing first makes the missing path the thing the
923
+ // operator is told about (criterion 1). This sits AFTER the precondition
924
+ // block deliberately: a precondition the runner evaluated and found unmet
925
+ // is a real skip, and a gate that was never going to run is honestly
926
+ // reported as not-applicable rather than as a crash.
927
+ const commandRunnable = probeCommandRunnable(entry.command, cwd);
928
+ if (!commandRunnable.runnable) {
929
+ return {
930
+ result: errorResult(entry, startedAt, `gate ${entry.id} could not be run: ${commandRunnable.reason}`),
931
+ applicable: false,
932
+ };
933
+ }
934
+ // The record path is probed BEFORE the child exists. A named pipe here
935
+ // would block the gate in the kernel forever and the runner behind it.
936
+ const recordRefusal = refuseOpenForWrite(recordPath);
937
+ if (recordRefusal !== undefined) {
938
+ return {
939
+ result: errorResult(entry, startedAt, `${recordRefusal}; refusing to run gate ${entry.id}`),
940
+ applicable: false,
941
+ };
942
+ }
943
+ // CR-900 rows 5 and 6, the two the finding named. The DELETE below removes a
944
+ // record that, under a stolen claim, belongs to another run; the DISPATCH
945
+ // after it hands that other run's directory to a program of this manifest's
946
+ // choosing. One question covers both because nothing between them can change
947
+ // the claim: the argv construction is pure string work. Splitting it into two
948
+ // identical checks would add a line no test could redden independently.
949
+ //
950
+ // This check is what makes the fix COMPLETE rather than the review's sketch
951
+ // of one check at the top of `runOneGate`. By here the precondition command
952
+ // has already run, and it can have stolen the claim after that top check
953
+ // passed (witness W3).
954
+ const mutateRefusal = refuseUnlessHolder(evidenceDir, runId, `clear ${recordPath} and run gate ${entry.id}`);
955
+ if (mutateRefusal !== undefined) {
956
+ return {
957
+ result: errorResult(entry, startedAt, mutateRefusal),
958
+ applicable: false,
959
+ };
960
+ }
961
+ const cleared = runStep(`clearing ${recordPath}`, () => rmSync(recordPath, { force: true }));
962
+ if (!cleared.ok) {
963
+ return {
964
+ result: errorResult(entry, startedAt, cleared.reason),
965
+ applicable: false,
966
+ };
967
+ }
968
+ const argv = [...entry.command.slice(1), "--result", recordPath, "--evidence", gateDir];
969
+ for (const name of requiredParameters(entry)) {
970
+ argv.push(`--${name}`, options[name]);
971
+ }
972
+ const child = spawnSync(entry.command[0], argv, {
973
+ cwd,
974
+ encoding: "utf8",
975
+ });
976
+ // Through writeInsideClaim like every other write into this directory: the
977
+ // rule is a property of writing here, not of one call site (CR-860).
978
+ const stdoutRefusal = writeInsideClaim(evidenceDir, runId, stdoutPath, child.stdout ?? "");
979
+ const stderrRefusal = writeInsideClaim(evidenceDir, runId, stderrPath, child.stderr ?? "");
980
+ const captureRefusal = stdoutRefusal ?? stderrRefusal;
981
+ const ingested = ingestGateRun(entry, startedAt, recordPath, child, captureRefusal);
982
+ return {
983
+ result: ingested,
984
+ // CR-800: "applicable" is read off the RESULT, never off the fact that a
985
+ // child was started. A gate that decides its own applicability and says
986
+ // `not-applicable` is not applicable, and it used to be counted as one.
987
+ applicable: ingested.status !== "not-applicable",
988
+ recordPath,
989
+ stdoutPath,
990
+ stderrPath,
991
+ };
992
+ }
993
+ /**
994
+ * Turn one gate subprocess's exit code plus whatever it left at the record
995
+ * path into a GateResult. Every path that is not "the gate said what it
996
+ * meant" ends at `error`.
997
+ */
998
+ function ingestGateRun(entry, startedAt, recordPath, child, captureRefusal) {
999
+ if (captureRefusal !== undefined) {
1000
+ return errorResult(entry, startedAt, captureRefusal);
1001
+ }
1002
+ if (child.error !== undefined) {
1003
+ return errorResult(entry, startedAt, `gate ${entry.id} could not be run: ${singleLine(String(child.error))}`);
1004
+ }
1005
+ if (child.signal !== null && child.signal !== undefined) {
1006
+ return errorResult(entry, startedAt, `gate ${entry.id} was terminated by ${child.signal}`);
1007
+ }
1008
+ const exitCode = child.status ?? -1;
1009
+ const entryClass = classifyEntry(recordPath);
1010
+ if (entryClass.kind === "irregular" || entryClass.kind === "unexaminable") {
1011
+ return errorResult(entry, startedAt, entryClass.reason);
1012
+ }
1013
+ if (entryClass.kind !== "regular") {
1014
+ // No record. A nonzero exit here is NOT red: Node exits 1 on an uncaught
1015
+ // exception, which collides exactly with the red code, so a crash and a
1016
+ // refutation would be indistinguishable (step 7).
1017
+ return errorResult(entry, startedAt, `gate ${entry.id} exited ${String(exitCode)} without writing a result record at ${recordPath}`);
1018
+ }
1019
+ const read = readRegularFileIfPresent(recordPath);
1020
+ if (read.kind !== "read") {
1021
+ return errorResult(entry, startedAt, read.kind === "refused"
1022
+ ? read.reason
1023
+ : `gate ${entry.id} result record vanished at ${recordPath}`);
1024
+ }
1025
+ let parsed;
1026
+ try {
1027
+ parsed = JSON.parse(read.body);
1028
+ }
1029
+ catch (error) {
1030
+ return errorResult(entry, startedAt, `gate ${entry.id} result record does not parse: ${error.message}`);
1031
+ }
1032
+ const diagnostics = validateResultDocument(parsed);
1033
+ if (diagnostics.length > 0) {
1034
+ return errorResult(entry, startedAt, `gate ${entry.id} result record is invalid: ${diagnostics.join("; ")}`);
1035
+ }
1036
+ const record = parsed;
1037
+ if (record.gate !== entry.id) {
1038
+ return errorResult(entry, startedAt, `gate ${entry.id} wrote a record for ${record.gate}`);
1039
+ }
1040
+ const expected = exitCodeForStatus(record.status);
1041
+ if (exitCode !== expected) {
1042
+ const named = statusForExitCode(exitCode);
1043
+ return errorResult(entry, startedAt, `gate ${entry.id} recorded status ${record.status} (exit ${String(expected)}) but exited ${String(exitCode)}` +
1044
+ (named === undefined ? "" : ` (${named})`));
1045
+ }
1046
+ // CR-806. `vacuous` exists to be set by the two rewrite points and by
1047
+ // nothing else (deviation D2). A gate that writes it itself was being
1048
+ // believed, so a green record could be counted in the `vacuous` bucket and
1049
+ // break step 8's "vacuous is a strict subset of error". The runner is
1050
+ // documented as adversarial towards its own gates; here it was trusting one.
1051
+ const claimed = { ...record };
1052
+ delete claimed.vacuous;
1053
+ if (claimed.status === "green" && claimed.units === 0) {
1054
+ // The constructor cannot produce this, but a gate that does not use the
1055
+ // constructor can, and that is exactly the party this rule is aimed at.
1056
+ return {
1057
+ ...claimed,
1058
+ status: "error",
1059
+ vacuous: true,
1060
+ detail: claimed.detail === ""
1061
+ ? M2_C_2_DETAIL
1062
+ : `${M2_C_2_DETAIL}; the gate reported: ${claimed.detail}`,
1063
+ };
1064
+ }
1065
+ // CR-804 part two: M2-C-5, enforced by the runner rather than left to each
1066
+ // gate's own honesty. The runner holds both the record's pins and the
1067
+ // module that compares them, two lines from where it applies the
1068
+ // structurally identical M2-C-2 rewrite, and was doing nothing with either.
1069
+ const pinFailure = pinRefusal(claimed);
1070
+ if (pinFailure !== undefined && claimed.status === "green") {
1071
+ return {
1072
+ ...claimed,
1073
+ status: "error",
1074
+ detail: claimed.detail === ""
1075
+ ? pinFailure
1076
+ : `${pinFailure}; the gate reported: ${claimed.detail}`,
1077
+ };
1078
+ }
1079
+ return claimed;
1080
+ }
1081
+ export const M2_C_5_DETAIL = "M2-C-5 (a run that cannot name what it executed is not evidence)";
1082
+ /**
1083
+ * Why a record's pins refuse it, or undefined when they do not. A gate that
1084
+ * declares no pin is not bound by M2-C-5 and is not touched here; the
1085
+ * constraint binds the gates that execute a test suite, and each of those
1086
+ * declares its pins.
1087
+ */
1088
+ function pinRefusal(record) {
1089
+ if (record.pin === undefined) {
1090
+ return undefined;
1091
+ }
1092
+ if (record.pin.start.fileCount === 0 || record.pin.end.fileCount === 0) {
1093
+ return `${M2_C_5_DETAIL}: a pin over ${record.pin.start.roots.join(", ")} measured no files`;
1094
+ }
1095
+ const differences = comparePins(record.pin.start, record.pin.end);
1096
+ if (differences.length === 0) {
1097
+ return undefined;
1098
+ }
1099
+ return (`${M2_C_5_DETAIL}: the tree changed during the run: ` +
1100
+ differences.map(describePinDifference).join("; "));
1101
+ }
1102
+ /**
1103
+ * THE ONE AGGREGATE DECISION, extracted so it can be EXERCISED rather than
1104
+ * read (CR-800's fix round). CR-800 was a reading of these branch conditions
1105
+ * that turned out not to hold, and the first test written for the fix
1106
+ * asserted on the TEXT of this function, which is the guard-that-asserts-text
1107
+ * class MECHANISMS.md records six instances of. A pure function over a counts
1108
+ * object can be handed states the runner cannot currently produce, including
1109
+ * the internally inconsistent ones the invariants below exist for, so the
1110
+ * invariants are witnessed instead of quoted.
1111
+ *
1112
+ * AGGREGATE PRECEDENCE, fixed here so it is one rule and not a reading. A
1113
+ * concrete failure outranks the vacuity check, because "3 gates reported
1114
+ * error" tells the operator more than "no applicable gate" and both exit 21
1115
+ * anyway. The vacuity check outranks a required not-applicable gate, because
1116
+ * a bundle that examined nothing is not a report about any one gate (M2-C-2
1117
+ * at the aggregate level, M2R-012).
1118
+ */
1119
+ export function decideAggregate(counts, requiredNotApplicable, rows) {
1120
+ /* M4-P11, DR-0038. THE ONE THING A DECLARED EXCEPTION MUST NEVER BE IS
1121
+ INVISIBLE, AND THE AGGREGATE IS WHERE IT WOULD BE.
1122
+ `scripts/check-dual-review.mjs` is a CONDITIONAL gate, so its
1123
+ not-applicable never reaches `requiredNotApplicable` and never appears in
1124
+ the reason line. Before this clause, a bundle carrying a gate that had
1125
+ declined DR-0012's cross-family requirement by declaration printed "every
1126
+ applicable gate is green" and exited 0, and the exception appeared nowhere
1127
+ a reader of the bundle would look. That is the same substitution T-009
1128
+ names, one scope smaller: a bundle-level green standing in for a
1129
+ gate-level fact.
1130
+
1131
+ IT IS APPENDED TO EVERY ARM, not only the success path. A declaration is
1132
+ equally worth seeing beside a red, and an arm that reported it on one
1133
+ branch and not another would be a guard that goes quiet exactly when
1134
+ something else is also wrong. */
1135
+ const declared = rows
1136
+ .filter((row) => row.status === "not-applicable" && row.declaredNotApplicable === true)
1137
+ .map((row) => row.id)
1138
+ .sort();
1139
+ const declaredClause = declared.length === 0
1140
+ ? ""
1141
+ : `; ${String(declared.length)} gate(s) not applicable by declaration: ${declared.join(", ")}`;
1142
+ const decided = (verdict) => ({ exitCode: verdict.exitCode, reason: `${verdict.reason}${declaredClause}` });
1143
+ let exitCode = EXIT_GREEN;
1144
+ let reason = "every applicable gate is green";
1145
+ if (counts.error > 0) {
1146
+ exitCode = EXIT_GATE_ERROR;
1147
+ reason = `${String(counts.error)} gate(s) reported error: ${rows
1148
+ .filter((row) => row.status === "error")
1149
+ .map((row) => row.id)
1150
+ .join(", ")}`;
1151
+ }
1152
+ else if (counts.red > 0) {
1153
+ exitCode = EXIT_RED;
1154
+ reason = `${String(counts.red)} gate(s) reported red: ${rows
1155
+ .filter((row) => row.status === "red")
1156
+ .map((row) => row.id)
1157
+ .join(", ")}`;
1158
+ }
1159
+ else if (counts.verdict === 0) {
1160
+ // CR-800. This used to read `counts.applicable === 0`, and `applicable`
1161
+ // used to mean "was spawned", so a bundle of gates that each declared
1162
+ // their own not-applicable slipped past it and exited 0. The count
1163
+ // consulted here is now the only one that means work was done.
1164
+ exitCode = EXIT_GATE_ERROR;
1165
+ reason = NO_APPLICABLE_GATE;
1166
+ }
1167
+ else if (requiredNotApplicable.length > 0) {
1168
+ exitCode = EXIT_NOT_APPLICABLE;
1169
+ reason = `required gate(s) not applicable: ${requiredNotApplicable.join(", ")}`;
1170
+ }
1171
+ // TOTAL OVER GARBAGE, not just over zero (CR-862). The first version of
1172
+ // this check was `counts.green === 0`, which is FALSE for NaN, for
1173
+ // undefined, for a missing key and for a negative number, so all four
1174
+ // reached exit 0 with "every applicable gate is green". None is reachable
1175
+ // through the runner today, where `counts` is built from integer literals
1176
+ // and `+= 1`. That is exactly the argument this function exists to refuse
1177
+ // to rely on: its stated contract is to be handed states the runner cannot
1178
+ // currently produce, and a guard that only rejects the value it was written
1179
+ // against is the same shape as the defect it was written for.
1180
+ //
1181
+ // So every count is checked for being a non-negative safe integer FIRST,
1182
+ // and the success-path assertion is written as `!(green > 0)`, which is
1183
+ // true for every non-number as well as for zero.
1184
+ const badCounts = Object.entries(counts)
1185
+ .filter(([, value]) => !Number.isSafeInteger(value) || value < 0)
1186
+ .map(([name]) => name)
1187
+ .sort();
1188
+ if (badCounts.length > 0) {
1189
+ return decided({
1190
+ exitCode: EXIT_GATE_ERROR,
1191
+ reason: "internal inconsistency: count(s) that are not non-negative integers: " +
1192
+ `${badCounts.join(", ")} (${JSON.stringify(counts)})`,
1193
+ });
1194
+ }
1195
+ for (const name of [
1196
+ "declared",
1197
+ "applicable",
1198
+ "verdict",
1199
+ "green",
1200
+ "red",
1201
+ "not-applicable",
1202
+ "error",
1203
+ "vacuous",
1204
+ ]) {
1205
+ if (!Object.prototype.hasOwnProperty.call(counts, name)) {
1206
+ return decided({
1207
+ exitCode: EXIT_GATE_ERROR,
1208
+ reason: `internal inconsistency: the count ${name} is missing (${JSON.stringify(counts)})`,
1209
+ });
1210
+ }
1211
+ }
1212
+ // THE SUCCESS PATH CANNOT DESCRIBE AN EMPTY GREEN BUCKET. The branches
1213
+ // above already make exit 0 unreachable with a zero green bucket, and this
1214
+ // asserts it rather than trusting the reading, because CR-800 was exactly a
1215
+ // reading of the branch conditions that turned out not to hold.
1216
+ //
1217
+ // `!(green > 0)` rather than `green === 0` is WITNESSED, not belt and braces
1218
+ // (CR-901). Round two recorded it as "unwitnessable: no input distinguishes
1219
+ // the two forms", which was an impossibility claim of exactly the shape
1220
+ // tuition T-006 is about, and it is false. The two screens above do not
1221
+ // screen the same set of properties: the bad-count screen enumerates with
1222
+ // `Object.entries`, which sees own ENUMERABLE properties, while the presence
1223
+ // screen uses `hasOwnProperty`, which sees own properties enumerable or not.
1224
+ // A count defined as
1225
+ //
1226
+ // Object.defineProperty(counts, "green", { value: NaN, enumerable: false })
1227
+ //
1228
+ // is invisible to the first screen and present to the second, so it arrives
1229
+ // here unexamined. `!(NaN > 0)` is true and reports the inconsistency;
1230
+ // `NaN === 0` is false and would certify "every applicable gate is green"
1231
+ // with green equal to NaN. Registered as
1232
+ // `gate-aggregate-nonenumerable-nan-green`.
1233
+ if (exitCode === EXIT_GREEN && !(counts.green > 0)) {
1234
+ exitCode = EXIT_GATE_ERROR;
1235
+ reason =
1236
+ "internal inconsistency: the run reached the success path with zero " +
1237
+ `green gates (${JSON.stringify(counts)})`;
1238
+ }
1239
+ // Step 8's stated relation, asserted rather than assumed (CR-806).
1240
+ if (counts.vacuous > counts.error) {
1241
+ exitCode = EXIT_GATE_ERROR;
1242
+ reason =
1243
+ `internal inconsistency: vacuous ${String(counts.vacuous)} exceeds ` +
1244
+ `error ${String(counts.error)}, and vacuous is a strict subset of error`;
1245
+ }
1246
+ return decided({ exitCode, reason });
1247
+ }
1248
+ export const RUN_CLAIM_FILE = ".tiphys-gate-run.json";
1249
+ /**
1250
+ * Claim the evidence directory with an O_EXCL create, the pattern
1251
+ * `src/lock.ts` already carries (MECHANISMS.md, "Claim file (mutual exclusion
1252
+ * by O_EXCL)", which requires a third user to read that module first: done,
1253
+ * and this follows its rule rather than inventing a second one).
1254
+ *
1255
+ * No steal, no age heuristic, no bounded wait. A claim that cannot be taken
1256
+ * fails LOUDLY and NAMES THE STUCK FILE, because a silent wait is
1257
+ * indistinguishable from an absence of contention, and because an evidence
1258
+ * directory is not a contended resource by design: two runs sharing one is
1259
+ * an operator error, not a queue.
1260
+ */
1261
+ function claimEvidenceDirectory(evidenceDir, runId, manifestPath) {
1262
+ const claimPath = join(evidenceDir, RUN_CLAIM_FILE);
1263
+ const refusal = refuseOpenForWrite(claimPath);
1264
+ if (refusal !== undefined) {
1265
+ return refusal;
1266
+ }
1267
+ const body = `${JSON.stringify({ runId, manifest: manifestPath, startedAt: now() }, null, 2)}\n`;
1268
+ try {
1269
+ writeFileSync(claimPath, body, { flag: "wx" });
1270
+ return undefined;
1271
+ }
1272
+ catch (error) {
1273
+ if (error.code !== "EEXIST") {
1274
+ return `evidence directory ${evidenceDir} could not be claimed: ${singleLine(String(error))}`;
1275
+ }
1276
+ const holder = claimHolder(evidenceDir);
1277
+ const held = readRegularFileIfPresent(claimPath);
1278
+ const who = held.kind === "read" ? singleLine(held.body) : "unreadable";
1279
+ // CR-861. The refused run writes NOTHING here, which is correct, and that
1280
+ // used to mean the only statement in the directory about what happened
1281
+ // was a DIFFERENT run's: the previous summary sat there saying exit 0,
1282
+ // aborted false, "every applicable gate is green". So the refusal itself
1283
+ // carries both ids and says, in words, that anything in the directory
1284
+ // belongs to the other run.
1285
+ return (`evidence directory ${evidenceDir} is already claimed by another run; ` +
1286
+ `claim file ${claimPath} holds ${who}. ` +
1287
+ `This run is ${runId} and it wrote NOTHING: any summary.json in ` +
1288
+ `${evidenceDir} belongs to run ${holder ?? "unknown"}, not to this run, ` +
1289
+ "and is not a report about this invocation. " +
1290
+ "Two runs sharing one evidence directory produce a bundle attributable " +
1291
+ "to neither. Use a different --evidence directory, or delete that file " +
1292
+ "if no run holds it.");
1293
+ }
1294
+ }
1295
+ /**
1296
+ * Replace `summary.json` atomically, staging under a name that carries this
1297
+ * run's id. MECHANISMS.md's "Atomic file replacement" row is explicit that a
1298
+ * FIXED stage name lets two concurrent passes share one temporary and the
1299
+ * loser dies on ENOENT, so the runId is in the stage name and no other run
1300
+ * can collide with it. The claim above already excludes a second runner from
1301
+ * this directory; this is the second lock on the same door, and it costs one
1302
+ * rename.
1303
+ */
1304
+ function writeSummaryAtomically(evidenceDir, summaryPath, runId, summary) {
1305
+ const stagePath = `${summaryPath}.${runId}.stage`;
1306
+ const staged = writeInsideClaim(evidenceDir, runId, stagePath, `${JSON.stringify(summary, null, 2)}\n`);
1307
+ if (staged !== undefined) {
1308
+ return staged;
1309
+ }
1310
+ const refusal = refuseOpenForWrite(summaryPath);
1311
+ if (refusal !== undefined) {
1312
+ return refusal;
1313
+ }
1314
+ // CR-900 row 8. A rename is a mutation of two entries in this directory, and
1315
+ // it was guarded only by the READING that the staged write above must have
1316
+ // succeeded first. That reading happens to be sound today; this phase is on
1317
+ // its third round because a reading of control flow was wrong twice, and the
1318
+ // rule this module now states is that the mutation asks, not its caller.
1319
+ const renameRefusal = refuseUnlessHolder(evidenceDir, runId, `replace ${summaryPath}`);
1320
+ if (renameRefusal !== undefined) {
1321
+ return renameRefusal;
1322
+ }
1323
+ const renamed = runStep(`replacing ${summaryPath}`, () => renameSync(stagePath, summaryPath));
1324
+ return renamed.ok ? undefined : renamed.reason;
1325
+ }
1326
+ /** The runId recorded in a claim file, or undefined if it cannot be read. */
1327
+ function claimHolder(evidenceDir) {
1328
+ const read = readRegularFileIfPresent(join(evidenceDir, RUN_CLAIM_FILE));
1329
+ if (read.kind !== "read") {
1330
+ return undefined;
1331
+ }
1332
+ try {
1333
+ const parsed = JSON.parse(read.body);
1334
+ return typeof parsed.runId === "string" ? parsed.runId : undefined;
1335
+ }
1336
+ catch {
1337
+ return undefined;
1338
+ }
1339
+ }
1340
+ /**
1341
+ * RELEASE ONLY WHAT THIS RUN HOLDS (CR-860).
1342
+ *
1343
+ * The mechanism the finding names is: *cleanup that is valid only while the
1344
+ * claim is held, performed from a frame that does not know whether the claim
1345
+ * is held*. The instance was a release in an inner `finally` followed by a
1346
+ * second, unconditional release in an outer `catch`, so a crashed run could
1347
+ * unlink a claim that by then belonged to a DIFFERENT run, revoking a live
1348
+ * run's exclusion. That is "release a lock you no longer hold", the classic
1349
+ * claim-file defect.
1350
+ *
1351
+ * Two things close it, and both are here because the line deletion alone is
1352
+ * the instance fix and this project fixes the mechanism:
1353
+ *
1354
+ * 1. THIS function reads the claim and unlinks ONLY when the runId is its
1355
+ * own, which is what `src/lock.ts` does when it verifies holdership
1356
+ * before mutating. A release from a frame that no longer holds the claim
1357
+ * is then a no-op rather than a revocation, whatever the call graph does.
1358
+ * 2. The call graph is also fixed, in `runGates`: exactly one release, in
1359
+ * one `finally`, after every write into the directory. Depending on the
1360
+ * guard alone would leave the writes happening outside the claimed
1361
+ * region, which is the other half of the same finding.
1362
+ *
1363
+ * A claim this run does not hold is deliberately LEFT IN PLACE. Deleting
1364
+ * another run's claim is the harm; leaving a stranded one costs a human one
1365
+ * `rm`, and the refusal text says which file and why.
1366
+ */
1367
+ export function releaseEvidenceDirectory(evidenceDir, runId) {
1368
+ const holder = claimHolder(evidenceDir);
1369
+ if (holder !== runId) {
1370
+ return false;
1371
+ }
1372
+ try {
1373
+ unlinkSync(join(evidenceDir, RUN_CLAIM_FILE));
1374
+ return true;
1375
+ }
1376
+ catch {
1377
+ // Releasing a claim that is already gone is not a failure.
1378
+ return false;
1379
+ }
1380
+ }
1381
+ /**
1382
+ * The public entry. It exists so that NO throw can escape the runner and be
1383
+ * read as this phase's RED exit code by whatever consumes it (CR-801). The
1384
+ * runner enforces exactly this rule on its gates; it now obeys it itself.
1385
+ */
1386
+ export function runGates(options) {
1387
+ const runId = randomBytes(12).toString("hex");
1388
+ try {
1389
+ return runGatesInner(options, runId);
1390
+ }
1391
+ catch (error) {
1392
+ // Reached only for a throw BEFORE the claim was taken, because every
1393
+ // path after it is wrapped inside the claimed region below. Nothing is
1394
+ // written here: without the claim this run does not own the directory.
1395
+ return {
1396
+ runId,
1397
+ exitCode: EXIT_GATE_ERROR,
1398
+ reason: `the gate runner failed: ${singleLine(error.message ?? String(error))}`,
1399
+ };
1400
+ }
1401
+ }
1402
+ function writeAbortedSummary(options, runId, reason) {
1403
+ if (!isRealDirectory(options.evidenceDir)) {
1404
+ return;
1405
+ }
1406
+ const summary = {
1407
+ runId,
1408
+ manifest: options.manifestPath,
1409
+ manifestSha256: "",
1410
+ startedAt: now(),
1411
+ endedAt: now(),
1412
+ parameters: {},
1413
+ only: options.only ?? [],
1414
+ manifestGates: 0,
1415
+ gates: [],
1416
+ counts: {
1417
+ declared: 0,
1418
+ applicable: 0,
1419
+ verdict: 0,
1420
+ green: 0,
1421
+ red: 0,
1422
+ "not-applicable": 0,
1423
+ error: 0,
1424
+ vacuous: 0,
1425
+ },
1426
+ requiredNotApplicable: [],
1427
+ /* An aborted run ran no gate, so nothing declared anything. EMPTY rather
1428
+ than omitted, for the reason the field's own comment gives: an absent
1429
+ field would be indistinguishable from an older summary that could not
1430
+ have carried one. */
1431
+ declaredNotApplicable: [],
1432
+ aborted: true,
1433
+ exitCode: EXIT_GATE_ERROR,
1434
+ reason,
1435
+ };
1436
+ writeInsideClaim(options.evidenceDir, runId, join(options.evidenceDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
1437
+ }
1438
+ function runGatesInner(options, runId) {
1439
+ const cwd = options.cwd ?? process.cwd();
1440
+ const startedAt = now();
1441
+ // The evidence directory is created and CLAIMED before the manifest is
1442
+ // loaded, so that every failure from here on leaves a summary a consumer
1443
+ // can read. CR-801 member 1 (a throw during the manifest load) otherwise
1444
+ // left no directory, no summary and only an exit code, and M2-P9's harness
1445
+ // is a programmatic consumer of the bundle.
1446
+ const dirRefusal = ensureDirectory(options.evidenceDir);
1447
+ if (dirRefusal !== undefined) {
1448
+ return { runId, exitCode: EXIT_GATE_ERROR, reason: dirRefusal };
1449
+ }
1450
+ const claimRefusal = claimEvidenceDirectory(options.evidenceDir, runId, options.manifestPath);
1451
+ if (claimRefusal !== undefined) {
1452
+ // Deliberately no write and no release: the directory belongs to the run
1453
+ // that holds the claim, and writing into it is the very thing this
1454
+ // refusal exists to prevent. What the refusal DOES carry is both run
1455
+ // ids, so a caller can tell that any summary.json there is not its own
1456
+ // (CR-861).
1457
+ return { runId, exitCode: EXIT_GATE_ERROR, reason: claimRefusal };
1458
+ }
1459
+ // EVERY WRITE INTO THE DIRECTORY HAPPENS INSIDE THIS BLOCK, and the single
1460
+ // release is its `finally` (CR-860). The aborted summary used to be written
1461
+ // by an OUTER catch, after an inner `finally` had already released, so for
1462
+ // a measured 2.13ms the run wrote into a directory it no longer owned.
1463
+ try {
1464
+ try {
1465
+ /* One load, two document shapes. A registry is projected onto a
1466
+ manifest by `loadRegistry` and everything downstream is identical,
1467
+ which is the property the promotion depends on. */
1468
+ const loaded = options.registry === true
1469
+ ? loadRegistry(options.manifestPath, options.mode ?? DEFAULT_MODE)
1470
+ : loadManifest(options.manifestPath);
1471
+ if (!loaded.ok) {
1472
+ const reason = [loaded.reason, ...loaded.diagnostics].join("\n");
1473
+ writeAbortedSummary(options, runId, reason);
1474
+ return { runId, exitCode: EXIT_GATE_ERROR, reason };
1475
+ }
1476
+ return runClaimedBundle(options, cwd, startedAt, runId, loaded);
1477
+ }
1478
+ catch (error) {
1479
+ const reason = `the gate runner failed: ${singleLine(error.message ?? String(error))}`;
1480
+ // A failure to record must not itself throw out of the catch.
1481
+ try {
1482
+ writeAbortedSummary(options, runId, reason);
1483
+ }
1484
+ catch {
1485
+ // Nothing further can be recorded; the exit code and stderr remain.
1486
+ }
1487
+ return { runId, exitCode: EXIT_GATE_ERROR, reason };
1488
+ }
1489
+ }
1490
+ finally {
1491
+ releaseEvidenceDirectory(options.evidenceDir, runId);
1492
+ }
1493
+ }
1494
+ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
1495
+ const manifest = loaded.manifest;
1496
+ const only = options.only ?? [];
1497
+ if (only.length > 0) {
1498
+ const known = new Set(manifest.gates.map((gate) => gate.id));
1499
+ const unknown = only.filter((id) => !known.has(id));
1500
+ if (unknown.length > 0) {
1501
+ return {
1502
+ runId,
1503
+ exitCode: EXIT_GATE_ERROR,
1504
+ reason: `--only names no such gate: ${unknown.join(", ")}`,
1505
+ };
1506
+ }
1507
+ }
1508
+ const selected = only.length > 0
1509
+ ? manifest.gates.filter((gate) => only.includes(gate.id))
1510
+ : manifest.gates;
1511
+ const rows = [];
1512
+ const counts = {
1513
+ declared: selected.length,
1514
+ applicable: 0,
1515
+ verdict: 0,
1516
+ green: 0,
1517
+ red: 0,
1518
+ "not-applicable": 0,
1519
+ error: 0,
1520
+ vacuous: 0,
1521
+ };
1522
+ const requiredNotApplicable = [];
1523
+ const declaredNotApplicable = [];
1524
+ for (const entry of selected) {
1525
+ const outcome = runOneGate(entry, options, cwd, options.evidenceDir, runId);
1526
+ const result = outcome.result;
1527
+ if (outcome.applicable) {
1528
+ counts.applicable += 1;
1529
+ }
1530
+ counts[result.status] += 1;
1531
+ if (result.status === "green" || result.status === "red") {
1532
+ counts.verdict += 1;
1533
+ }
1534
+ if (result.vacuous === true) {
1535
+ counts.vacuous += 1;
1536
+ }
1537
+ if (result.status === "not-applicable" && entry.applicability === "required") {
1538
+ requiredNotApplicable.push(entry.id);
1539
+ }
1540
+ /* M4-P11. READ OFF THE INGESTED RECORD, not off the manifest and not off
1541
+ the gate's own claim about itself: `ingestGateRun` has already refused a
1542
+ record whose status and exit code disagree, so by here the precondition
1543
+ belongs to a record the runner accepted. */
1544
+ const declaredHere = isDeclaredNotApplicable(result);
1545
+ if (declaredHere) {
1546
+ declaredNotApplicable.push(entry.id);
1547
+ }
1548
+ // The runner owns the record on disk whenever it produced or changed
1549
+ // one: a not-applicable gate never ran and wrote nothing, and a rewritten
1550
+ // vacuous green must not stay green in the evidence (criterion 4).
1551
+ const recordPath = outcome.recordPath ?? join(options.evidenceDir, entry.id, "result.json");
1552
+ // CR-900 row 1b. Same mutation, second call site.
1553
+ const dirRefusalForGate = mkdirInsideClaim(options.evidenceDir, runId, join(options.evidenceDir, entry.id));
1554
+ if (dirRefusalForGate === undefined) {
1555
+ writeInsideClaim(options.evidenceDir, runId, recordPath, renderGateResult(result));
1556
+ }
1557
+ rows.push({
1558
+ id: entry.id,
1559
+ status: result.status,
1560
+ units: result.units,
1561
+ unitLabel: result.unitLabel,
1562
+ vacuous: result.vacuous === true,
1563
+ applicable: outcome.applicable,
1564
+ declaredNotApplicable: declaredHere,
1565
+ detail: result.detail,
1566
+ record: dirRefusalForGate === undefined ? recordPath : undefined,
1567
+ stdout: outcome.stdoutPath,
1568
+ stderr: outcome.stderrPath,
1569
+ });
1570
+ }
1571
+ const decided = decideAggregate(counts, requiredNotApplicable, rows);
1572
+ const exitCode = decided.exitCode;
1573
+ const reason = decided.reason;
1574
+ const parameters = {};
1575
+ if (options.base !== undefined) {
1576
+ parameters.base = options.base;
1577
+ }
1578
+ if (options.head !== undefined) {
1579
+ parameters.head = options.head;
1580
+ }
1581
+ if (options.phase !== undefined) {
1582
+ parameters.phase = options.phase;
1583
+ }
1584
+ const summary = {
1585
+ runId,
1586
+ manifest: options.manifestPath,
1587
+ ...(options.registry === true
1588
+ ? {
1589
+ registry: true,
1590
+ mode: options.mode ?? DEFAULT_MODE,
1591
+ declaredByChecklist: loaded.declaredByChecklist ?? [],
1592
+ }
1593
+ : {}),
1594
+ manifestSha256: loaded.sha256,
1595
+ startedAt,
1596
+ endedAt: now(),
1597
+ parameters,
1598
+ only,
1599
+ manifestGates: manifest.gates.length,
1600
+ gates: rows,
1601
+ counts,
1602
+ requiredNotApplicable,
1603
+ declaredNotApplicable: [...declaredNotApplicable].sort(),
1604
+ aborted: false,
1605
+ exitCode,
1606
+ reason,
1607
+ };
1608
+ const summaryPath = join(options.evidenceDir, "summary.json");
1609
+ const summaryRefusal = writeSummaryAtomically(options.evidenceDir, summaryPath, runId, summary);
1610
+ if (summaryRefusal !== undefined) {
1611
+ return { runId, exitCode: EXIT_GATE_ERROR, summary, reason: summaryRefusal };
1612
+ }
1613
+ return { runId, exitCode, summary, reason };
1614
+ }