@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,1444 @@
1
+ /**
2
+ * Cutover state and rollback (kernel plan M4, M4-P26; D-19's second limb at
3
+ * delivery/plan/kernel-plan-v1.md:394).
4
+ *
5
+ * WHAT THIS MODULE IS. The freeze point is not one event. It is FIVE
6
+ * independently flippable switches, taken from the list the current delivery
7
+ * process retains at
8
+ * delivery/decisions/DR-0025-controlled-pre-m4-local-pilot.md:45 and read
9
+ * against DR-0036's authority condition. Five switches rather than one event
10
+ * is what makes rollback PARTIAL rather than all-or-nothing, and it is why
11
+ * `credentials-and-refs`, the one switch with owner latency, is separable
12
+ * from the four that are not.
13
+ *
14
+ * THE SPLIT THAT KEEPS THIS HONEST, and it runs through every function here.
15
+ * Rollback of the FILES is cheap: everything under the retirement roots is
16
+ * git-tracked and revertible from history. Rollback of AUTHORITY is the
17
+ * expensive half: the branch-protection ruleset is owner-configured, the
18
+ * orchestrator cannot change it, and
19
+ * delivery/decisions/DR-0036-the-harness-adapter-leads-m4-and-the-kernel-is-the-second-subject.md:15
20
+ * prices the revert at "whatever phases ran under it". Nothing in this module
21
+ * flips authority. It PREPARES the request an owner acts on, and it refuses
22
+ * loudly rather than pretending the preparation is the act.
23
+ *
24
+ * WHAT THIS MODULE DELIBERATELY DOES NOT DO. It does not print
25
+ * `tiphys cutover status`, does not write switches to `kernel`, and does not
26
+ * ship a state schema. Those are M4-P25's acceptance criteria and M4-P25's
27
+ * files. This phase was dispatched ahead of its stated dependency because the
28
+ * conflict pre-pass holds `src/commands/cutover.ts` for exactly one unit at a
29
+ * time, so the state MODEL is created here and the status SURFACE is left
30
+ * where the plan put it. The boundary is recorded in
31
+ * delivery/work-history/m4-p26.md:1 rather than left to be discovered.
32
+ *
33
+ * PLAN CONSTRAINTS. C-1: current state comes from `cutover.json` and from
34
+ * meta.json plus the turn-end file, never from the tail of a log. C-2: no pid,
35
+ * no signal, no /proc and no process probing appears here; a live worktree is
36
+ * a DIRECTORY and an open task is a FILE STATE. C-3: nothing is backgrounded.
37
+ */
38
+ import { spawnSync } from "node:child_process";
39
+ import { randomBytes } from "node:crypto";
40
+ import { closeSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
41
+ import { dirname, join } from "node:path";
42
+ import { packageRoot } from "./modes.js";
43
+ import { classifyEntry, readRegularFileIfPresent, turnEndPath, } from "./task.js";
44
+ import { validateToLines } from "./validate.js";
45
+ /* -------------------------------------------------------------------- */
46
+ /* The five switches */
47
+ /* -------------------------------------------------------------------- */
48
+ /**
49
+ * The five retained items of DR-0025, in the order section 4.3 of the M4
50
+ * plan tabulates them. This list is CLOSED. A sixth name is a plan revision,
51
+ * not a code edit, because every rollback trigger enumerates it.
52
+ */
53
+ export const CUTOVER_SWITCHES = [
54
+ "planning-and-scope",
55
+ "review-and-arbitration",
56
+ "credentials-and-refs",
57
+ "salvage-and-recovery",
58
+ "closeout",
59
+ ];
60
+ export const SWITCH_STATES = ["current", "kernel"];
61
+ /**
62
+ * The switch with owner latency. Named as a constant because three separate
63
+ * places have to treat it differently and a repeated string literal is how
64
+ * one of them ends up not doing so.
65
+ */
66
+ export const OWNER_LATENCY_SWITCH = "credentials-and-refs";
67
+ /**
68
+ * `cutover.json` sits at the FLEET ROOT and not under `state/`, because
69
+ * `state/` is gitignored (src/fleet.ts:29) and trigger 2 step 1 identifies a
70
+ * flip with `git log` over this exact path. A switch history that is not
71
+ * committed is not a history.
72
+ */
73
+ export function cutoverStatePath(fleet) {
74
+ return join(fleet.root, "cutover.json");
75
+ }
76
+ function isSwitchState(value) {
77
+ return typeof value === "string" && SWITCH_STATES.includes(value);
78
+ }
79
+ function nonEmptyString(value) {
80
+ return typeof value === "string" && value.trim().length > 0;
81
+ }
82
+ /**
83
+ * Validate a parsed document into a CutoverState, returning every reason at
84
+ * once rather than the first.
85
+ *
86
+ * FAIL CLOSED ON `restoreTo`. A record missing it, or carrying it as a word
87
+ * outside the two-value vocabulary, is REFUSED rather than defaulted to
88
+ * `current`. Defaulting would make rollback silently guess, and a guess that
89
+ * happens to be right most of the time is the guard that cannot go red.
90
+ */
91
+ export function validateCutoverDocument(document) {
92
+ const reasons = [];
93
+ if (typeof document !== "object" || document === null || Array.isArray(document)) {
94
+ return ["cutover state is not a JSON object"];
95
+ }
96
+ const switches = document.switches;
97
+ if (typeof switches !== "object" || switches === null || Array.isArray(switches)) {
98
+ return ["cutover state has no switches object"];
99
+ }
100
+ const table = switches;
101
+ for (const name of CUTOVER_SWITCHES) {
102
+ const record = table[name];
103
+ if (typeof record !== "object" || record === null || Array.isArray(record)) {
104
+ reasons.push(`switch ${name} is missing`);
105
+ continue;
106
+ }
107
+ const row = record;
108
+ if (!isSwitchState(row["state"])) {
109
+ reasons.push(`switch ${name} has no state of current or kernel`);
110
+ }
111
+ if (!isSwitchState(row["restoreTo"])) {
112
+ reasons.push(`switch ${name} has no restoreTo of current or kernel`);
113
+ }
114
+ for (const field of ["flippedAt", "flippedBy", "reason"]) {
115
+ if (!nonEmptyString(row[field])) {
116
+ reasons.push(`switch ${name} has no ${field}`);
117
+ }
118
+ }
119
+ }
120
+ const unknown = Object.keys(table).filter((key) => !CUTOVER_SWITCHES.includes(key));
121
+ for (const key of unknown) {
122
+ reasons.push(`switch ${key} is not one of the five`);
123
+ }
124
+ return reasons;
125
+ }
126
+ /** Read and validate the fleet's cutover state. */
127
+ export function readCutoverState(fleet) {
128
+ const path = cutoverStatePath(fleet);
129
+ const read = readRegularFileIfPresent(path);
130
+ if (read.kind === "absent") {
131
+ return { kind: "absent" };
132
+ }
133
+ if (read.kind === "refused") {
134
+ return { kind: "refused", reason: read.reason };
135
+ }
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(read.body);
139
+ }
140
+ catch (error) {
141
+ return { kind: "refused", reason: `${path} is not valid JSON: ${String(error)}` };
142
+ }
143
+ const reasons = validateCutoverDocument(parsed);
144
+ if (reasons.length > 0) {
145
+ return { kind: "refused", reason: `${path}: ${reasons.join("; ")}` };
146
+ }
147
+ return {
148
+ kind: "read",
149
+ state: parsed,
150
+ document: parsed,
151
+ };
152
+ }
153
+ export function renderCutoverState(state) {
154
+ return `${JSON.stringify(state, null, 2)}\n`;
155
+ }
156
+ /**
157
+ * Put a new switch table into the document the file actually held, leaving
158
+ * every other key exactly as it was read.
159
+ *
160
+ * WHY THIS EXISTS AS A NAMED FUNCTION. `validateCutoverDocument` refuses an
161
+ * unknown SWITCH NAME because the five are a closed list, and that made it
162
+ * look as though the whole document were closed. It is not: M4-P25 owns the
163
+ * schema and may add top-level keys, so a rollback that serialises only what
164
+ * this module's interface names would silently delete them. Refusing them
165
+ * instead would be this phase deciding M4-P25's schema, which is not its to
166
+ * decide, so the document is CARRIED rather than narrowed or refused.
167
+ */
168
+ export function withSwitches(document, switches) {
169
+ return { ...document, switches };
170
+ }
171
+ /**
172
+ * Publish a whole cutover state ATOMICALLY: serialise everything, write it to
173
+ * a sibling temporary file, fsync that file, then rename over the
174
+ * destination. Nothing partial is ever visible at the destination path,
175
+ * because rename(2) within one directory is atomic.
176
+ *
177
+ * THE MECHANISM THIS EXISTS AGAINST is not "a crash". It is WRITING STATE
178
+ * PER ITEM WHILE ITERATING, which leaves a file that is internally valid and
179
+ * factually wrong: three switches saying `kernel` and two saying `current`
180
+ * describes a process that has no owner for three of its five authorities.
181
+ * Every caller in this module builds the complete next state first and calls
182
+ * this once.
183
+ */
184
+ export function publishCutoverState(path, state) {
185
+ /* M4-P25 criterion 4, AND THE ORDER IS THE PROPERTY. The shipped schema
186
+ refuses a switch record missing `restoreTo` (or `flippedAt`, `flippedBy`
187
+ or `reason`) BEFORE the temporary file is opened, so a refused write
188
+ leaves the destination byte-identical and, where there was none, leaves
189
+ no file at all. The check lives in the schema rather than here because a
190
+ check implemented only in a command cannot bind a later writer, and the
191
+ later writer is exactly who M4-P26's rollback depends on: `targetFor`
192
+ reads the recorded value rather than reconstructing an intent. */
193
+ const diagnostics = cutoverSchemaDiagnostics(state);
194
+ if (diagnostics.length > 0) {
195
+ throw new Error(`the cutover state was refused by ${CUTOVER_STATE_SCHEMA_FILENAME} and NOTHING was written: ${diagnostics.join("; ")}`);
196
+ }
197
+ const directory = dirname(path);
198
+ mkdirSync(directory, { recursive: true });
199
+ /* The suffix is random, never a pid. C-2 forbids a pid as an identity, and
200
+ a temp-file name is an identity for exactly as long as the rename takes. */
201
+ const temporary = join(directory, `.cutover.${randomBytes(8).toString("hex")}.tmp`);
202
+ const body = renderCutoverState(state);
203
+ let handle;
204
+ try {
205
+ handle = openSync(temporary, "wx");
206
+ writeSync(handle, body);
207
+ fsyncSync(handle);
208
+ closeSync(handle);
209
+ handle = undefined;
210
+ renameSync(temporary, path);
211
+ }
212
+ catch (error) {
213
+ if (handle !== undefined) {
214
+ try {
215
+ closeSync(handle);
216
+ }
217
+ catch {
218
+ /* the write already failed; the close outcome adds nothing */
219
+ }
220
+ }
221
+ try {
222
+ rmSync(temporary, { force: true });
223
+ }
224
+ catch {
225
+ /* best effort: a leftover temp file is not the destination */
226
+ }
227
+ throw error;
228
+ }
229
+ }
230
+ export const ROLLBACK_TRIGGERS = [
231
+ "drain-reversal",
232
+ "freeze-point-restore",
233
+ "retirement-unmet",
234
+ ];
235
+ /**
236
+ * The target value a trigger moves a switch to.
237
+ *
238
+ * Trigger 1 (drain reversal) hands every authority back to the current
239
+ * process, so the target is `current` for all five: in-flight work has to be
240
+ * handled by the process that started it.
241
+ *
242
+ * Trigger 2 (freeze-point restore) reads each switch's own `restoreTo`. That
243
+ * is the difference between the two, and it is why M4-P25 criterion 4 refuses
244
+ * a write that omits the field.
245
+ *
246
+ * ROLLBACK IS MONOTONE AND THE CLAMP BELOW IS WHAT MAKES THE MODULE HEADER
247
+ * TRUE. `restoreTo` is the value a switch held BEFORE its last flip, and
248
+ * `planRollback` sets it to the value the switch is leaving. So a switch that
249
+ * has already been rolled back to `current` carries `restoreTo: "kernel"`, and
250
+ * returning that value would move the switch FORWARD on the second run of the
251
+ * same command. The failure arm of trigger 1 step 1 exits nonzero after the
252
+ * local write has already happened, and the natural response to that is to run
253
+ * the command again, so the second run is not a hypothetical. A rollback never
254
+ * hands authority to the kernel: `current` is a floor, and a switch already at
255
+ * it is a no-op rather than a flip.
256
+ */
257
+ export function targetFor(trigger, record) {
258
+ if (trigger === "drain-reversal") {
259
+ return "current";
260
+ }
261
+ if (record.state === "current") {
262
+ return "current";
263
+ }
264
+ return record.restoreTo;
265
+ }
266
+ /**
267
+ * Assemble the complete next state. Throws rather than returning a partial
268
+ * plan, and touches no file at all: publishing is a separate call.
269
+ */
270
+ export function planRollback(state, trigger, options) {
271
+ const changes = [];
272
+ const nextSwitches = {};
273
+ let index = 0;
274
+ for (const name of CUTOVER_SWITCHES) {
275
+ const record = state.switches[name];
276
+ const to = targetFor(trigger, record);
277
+ const change = { name, from: record.state, to };
278
+ options.onSwitch?.(change, index);
279
+ if (change.from === change.to) {
280
+ /* A SWITCH THIS ROLLBACK DOES NOT MOVE IS NOT REWRITTEN. The record is
281
+ carried through unchanged, `flippedAt`, `flippedBy`, `reason` and
282
+ `restoreTo` included. Stamping the current run over an unmoved switch
283
+ would destroy the one fact freeze-point restore depends on, which is
284
+ what that switch left, and it would do it while reporting zero
285
+ changes: a write whose scope is wider than the sentence describing
286
+ it. */
287
+ nextSwitches[name] = record;
288
+ }
289
+ else {
290
+ nextSwitches[name] = {
291
+ /* Spread the record as it was READ. The interface names five fields;
292
+ the file may carry more, and a rollback that rebuilds the record
293
+ from the interface deletes whatever it did not know about. */
294
+ ...record,
295
+ state: to,
296
+ flippedAt: options.now,
297
+ flippedBy: options.by,
298
+ reason: options.reason,
299
+ /* The rolled-back switch can be flipped forward again, and the value
300
+ it would return to is the one it is leaving now. Carrying the OLD
301
+ restoreTo forward would make a second rollback restore a state two
302
+ flips old. */
303
+ restoreTo: record.state,
304
+ };
305
+ changes.push(change);
306
+ }
307
+ index += 1;
308
+ }
309
+ return { changes, next: { switches: nextSwitches } };
310
+ }
311
+ /**
312
+ * Plan and publish in one call: validate the whole input, assemble the whole
313
+ * next state, then write once.
314
+ *
315
+ * ORDER IS THE PROPERTY. Every refusal reachable from a bad input happens
316
+ * before `publishCutoverState` is called, so a refusal leaves the file
317
+ * byte-identical. That is asserted directly rather than trusted: see
318
+ * test/cutover.test.ts.
319
+ */
320
+ export function applyRollback(fleet, trigger, options) {
321
+ const read = readCutoverState(fleet);
322
+ if (read.kind === "absent") {
323
+ return { ok: false, reason: `${cutoverStatePath(fleet)} is absent` };
324
+ }
325
+ if (read.kind === "refused") {
326
+ return { ok: false, reason: read.reason };
327
+ }
328
+ let plan;
329
+ try {
330
+ plan = planRollback(read.state, trigger, options);
331
+ }
332
+ catch (error) {
333
+ return {
334
+ ok: false,
335
+ reason: `rollback assembly failed before any write: ${error instanceof Error ? error.message : String(error)}`,
336
+ };
337
+ }
338
+ try {
339
+ /* Publish the DOCUMENT with the new switch table in it, not the typed
340
+ view. `read.document` is what the file held; `plan.next.switches` is the
341
+ only part this rollback decided. Publishing `plan.next` alone would
342
+ delete every top-level key this module does not name. */
343
+ publishCutoverState(cutoverStatePath(fleet), withSwitches(read.document, plan.next.switches));
344
+ }
345
+ catch (error) {
346
+ return {
347
+ ok: false,
348
+ reason: `rollback could not be published: ${error instanceof Error ? error.message : String(error)}`,
349
+ };
350
+ }
351
+ return { ok: true, changes: plan.changes, next: plan.next };
352
+ }
353
+ /**
354
+ * WHAT DRAIN COUNTS, and the omission is the decision.
355
+ *
356
+ * In-flight work only: live worktrees and open tasks with no turn-end. Pushed
357
+ * unmerged BRANCHES are deliberately NOT counted (M4-D-15, decided at
358
+ * delivery/plan/kernel-plan-m4.md:3279). This container cannot delete a remote
359
+ * ref and the delete dry run exits 0 either way, and the probes generalised
360
+ * that further: a dry run does not probe push authorization at all
361
+ * (delivery/verification/m4-prototype-probes.md:165). A drain predicate over
362
+ * branches therefore blocks cutover on an owner action with no local
363
+ * pre-check, which is a predicate that can never read clean.
364
+ *
365
+ * C-2 is load-bearing here. A live worktree is a DIRECTORY on disk and an
366
+ * open task is meta.json's status plus the absence of the turn-end file. No
367
+ * process is probed, no pid is read, and nothing asks whether an agent is
368
+ * still breathing.
369
+ */
370
+ export function inFlightItems(fleet) {
371
+ const items = [];
372
+ /* ABSENT IS UNDECIDABLE HERE AND NOT EMPTY. `loadFleet` refuses a fleet
373
+ whose `worktrees/` or `tasks/` is not a directory (src/fleet.ts:65), so a
374
+ Fleet value that reaches this function had both when it was loaded. One
375
+ that is gone now was removed since, which says nothing about what was in
376
+ it, and reading that as a clean drain is the same fall-through as reading
377
+ an unreadable directory as an empty one. */
378
+ const worktrees = listDirectory(fleet.worktreesDir);
379
+ if (worktrees.kind !== "listed") {
380
+ items.push({
381
+ kind: "unexaminable",
382
+ id: fleet.worktreesDir,
383
+ detail: worktrees.kind === "absent"
384
+ ? `${fleet.worktreesDir} is gone, and the fleet had it when it was loaded`
385
+ : worktrees.reason,
386
+ });
387
+ }
388
+ for (const id of worktrees.kind === "listed" ? worktrees.names : []) {
389
+ const path = join(fleet.worktreesDir, id);
390
+ const probe = probeDirectory(path);
391
+ if (probe.kind === "directory") {
392
+ items.push({ kind: "worktree", id, detail: path });
393
+ }
394
+ else if (probe.kind === "unexaminable") {
395
+ items.push({ kind: "unexaminable", id, detail: probe.reason });
396
+ }
397
+ }
398
+ const tasks = listDirectory(fleet.tasksDir);
399
+ if (tasks.kind !== "listed") {
400
+ items.push({
401
+ kind: "unexaminable",
402
+ id: fleet.tasksDir,
403
+ detail: tasks.kind === "absent"
404
+ ? `${fleet.tasksDir} is gone, and the fleet had it when it was loaded`
405
+ : tasks.reason,
406
+ });
407
+ }
408
+ for (const id of tasks.kind === "listed" ? tasks.names : []) {
409
+ /* A TASK IS A DIRECTORY under tasks/, checked by TYPE and never by name,
410
+ which is the rule src/liveness.ts states for every task reader. This
411
+ loop was the one reader that skipped it, so `tiphys init`'s own
412
+ `tasks/.gitkeep` read as one task in flight and a freshly initialised
413
+ fleet could never report DRAIN clean. Resolved with stat, as the
414
+ worktree loop above does, so a symlink to a task directory is a task. */
415
+ const entry = probeDirectory(join(fleet.tasksDir, id));
416
+ if (entry.kind === "absent" || entry.kind === "other") {
417
+ continue;
418
+ }
419
+ if (entry.kind === "unexaminable") {
420
+ items.push({ kind: "unexaminable", id, detail: entry.reason });
421
+ continue;
422
+ }
423
+ const metaRead = readRegularFileIfPresent(join(fleet.tasksDir, id, "meta.json"));
424
+ if (metaRead.kind === "refused") {
425
+ /* Present and not readable. A meta.json that is a named pipe, a
426
+ directory, or a path this process may not stat says NOTHING about
427
+ whether the task finished, and the T-003 hazard shape is exactly a
428
+ FIFO where a regular file was expected. */
429
+ items.push({ kind: "task", id, detail: `meta.json could not be examined: ${metaRead.reason}` });
430
+ continue;
431
+ }
432
+ if (metaRead.kind === "absent") {
433
+ items.push({ kind: "task", id, detail: "meta.json is absent, so the task has no recorded status" });
434
+ continue;
435
+ }
436
+ let status;
437
+ try {
438
+ status = JSON.parse(metaRead.body).status;
439
+ }
440
+ catch {
441
+ /* An unparseable meta.json is not evidence that the task finished, so
442
+ it counts as in flight rather than being skipped. */
443
+ items.push({ kind: "task", id, detail: "meta.json is unparseable" });
444
+ continue;
445
+ }
446
+ /* `closed` is the ONLY positive evidence that a task is finished. The
447
+ previous form tested `status !== "open"`, which made every value that is
448
+ not the word `open` - a missing field, a typo, a number - read as
449
+ finished. The vocabulary is closed (src/task.ts, TaskStatus), so a value
450
+ outside it is undecided and undecided counts. */
451
+ if (status === "closed") {
452
+ continue;
453
+ }
454
+ if (status !== "open") {
455
+ items.push({
456
+ kind: "task",
457
+ id,
458
+ detail: `meta.json status ${JSON.stringify(status)} is not one of open or closed`,
459
+ });
460
+ continue;
461
+ }
462
+ const turnEnd = classifyEntry(turnEndPath(fleet, id));
463
+ /* Symmetrically: a REGULAR turn-end file is the only positive evidence
464
+ that the turn ended. `irregular` and `unexaminable` are undecided, not
465
+ finished, and the previous form counted only `absent` and `dangling`,
466
+ so a turn-end that was a named pipe read as a finished task. */
467
+ if (turnEnd.kind === "regular") {
468
+ continue;
469
+ }
470
+ items.push({
471
+ kind: "task",
472
+ id,
473
+ detail: turnEnd.kind === "absent" || turnEnd.kind === "dangling"
474
+ ? "open with no turn-end"
475
+ : `open and the turn-end could not be examined: ${turnEnd.reason}`,
476
+ });
477
+ }
478
+ return items.sort((a, b) => `${a.kind}/${a.id}`.localeCompare(`${b.kind}/${b.id}`));
479
+ }
480
+ function probeDirectory(path) {
481
+ let stats;
482
+ try {
483
+ stats = statSync(path);
484
+ }
485
+ catch (error) {
486
+ if (error.code === "ENOENT") {
487
+ return { kind: "absent" };
488
+ }
489
+ return { kind: "unexaminable", reason: `${path} could not be examined: ${String(error)}` };
490
+ }
491
+ return stats.isDirectory() ? { kind: "directory" } : { kind: "other" };
492
+ }
493
+ /**
494
+ * Enumerate a directory, distinguishing "it is not there" from "it could not
495
+ * be enumerated". An empty list and a failed listing are the same value to a
496
+ * caller that returns `[]` for both, and in this module the caller is the
497
+ * drain predicate: a `tasks/` that cannot be read would have reported a clean
498
+ * drain over an unknown number of open tasks.
499
+ */
500
+ function listDirectory(path) {
501
+ const probe = probeDirectory(path);
502
+ if (probe.kind === "absent") {
503
+ return { kind: "absent" };
504
+ }
505
+ if (probe.kind === "unexaminable") {
506
+ return { kind: "unexaminable", reason: probe.reason };
507
+ }
508
+ if (probe.kind === "other") {
509
+ return {
510
+ kind: "unexaminable",
511
+ reason: `${path} is not a directory, so its contents could not be enumerated`,
512
+ };
513
+ }
514
+ try {
515
+ return { kind: "listed", names: readdirSync(path).sort() };
516
+ }
517
+ catch (error) {
518
+ return { kind: "unexaminable", reason: `${path} could not be enumerated: ${String(error)}` };
519
+ }
520
+ }
521
+ /**
522
+ * The named list of things no local command can see. It is printed with every
523
+ * drain report and is NOT abbreviated when the network is unreachable: a
524
+ * command that silently degrades to a shorter answer is indistinguishable
525
+ * from one reporting a quiet system, which is the shape standing warning 6
526
+ * records for watchers.
527
+ */
528
+ export const CANNOT_SEE = [
529
+ "open pull requests",
530
+ "CI conclusions",
531
+ "post-merge push runs",
532
+ "whether the pilot's own fleet is drained (DR-0037: the pilot is not this orchestrator's subject)",
533
+ ];
534
+ export function runGit(cwd, args) {
535
+ const result = spawnSync("git", args, {
536
+ cwd,
537
+ encoding: "utf8",
538
+ env: {
539
+ ...process.env,
540
+ GIT_AUTHOR_NAME: "tiphys",
541
+ GIT_AUTHOR_EMAIL: "tiphys@localhost",
542
+ GIT_COMMITTER_NAME: "tiphys",
543
+ GIT_COMMITTER_EMAIL: "tiphys@localhost",
544
+ GIT_TERMINAL_PROMPT: "0",
545
+ },
546
+ });
547
+ return {
548
+ status: result.status ?? 1,
549
+ stdout: result.stdout ?? "",
550
+ stderr: result.stderr ?? (result.error === undefined ? "" : String(result.error)),
551
+ };
552
+ }
553
+ function nonEmptyLines(text) {
554
+ return text
555
+ .split("\n")
556
+ .map((line) => line.trim())
557
+ .filter((line) => line.length > 0);
558
+ }
559
+ export function syncFleetState(fleetRoot, options) {
560
+ if (options.paths.length === 0) {
561
+ return {
562
+ ok: false,
563
+ reason: "syncFleetState was given no path to stage, and a rollback that stages everything commits work it did not do",
564
+ };
565
+ }
566
+ const remotes = runGit(fleetRoot, ["remote"]);
567
+ if (remotes.status !== 0) {
568
+ return { ok: false, reason: `git remote failed in ${fleetRoot}: ${remotes.stderr.trim()}` };
569
+ }
570
+ const hasOrigin = remotes.stdout.split("\n").some((line) => line.trim() === "origin");
571
+ /* An index that already holds something is refused rather than absorbed: a
572
+ scoped staging does not unstage what somebody else staged, so committing
573
+ here would carry it under this message. */
574
+ const preStaged = runGit(fleetRoot, ["diff", "--cached", "--name-only"]);
575
+ if (preStaged.status !== 0) {
576
+ return { ok: false, reason: `git diff --cached failed: ${preStaged.stderr.trim()}` };
577
+ }
578
+ const already = nonEmptyLines(preStaged.stdout);
579
+ if (already.length > 0) {
580
+ return {
581
+ ok: false,
582
+ reason: `${fleetRoot} already holds ${String(already.length)} staged path(s) this rollback did not stage, ` +
583
+ `and nothing was committed: ${already.slice(0, 10).join(", ")}`,
584
+ };
585
+ }
586
+ const add = runGit(fleetRoot, ["add", "--", ...options.paths]);
587
+ if (add.status !== 0) {
588
+ return { ok: false, reason: `git add failed in ${fleetRoot}: ${add.stderr.trim()}` };
589
+ }
590
+ const staged = runGit(fleetRoot, ["diff", "--cached", "--name-only"]);
591
+ if (staged.status !== 0) {
592
+ return { ok: false, reason: `git diff --cached failed: ${staged.stderr.trim()}` };
593
+ }
594
+ const stagedPaths = nonEmptyLines(staged.stdout);
595
+ /* The scope is VERIFIED and not assumed. A named path that turns out to be a
596
+ directory, or a pathspec the caller did not mean, shows up here. */
597
+ const stray = stagedPaths.filter((path) => !options.paths.some((want) => path === want || path.startsWith(`${want}/`)));
598
+ if (stray.length > 0) {
599
+ return {
600
+ ok: false,
601
+ reason: `staging ${options.paths.join(", ")} also staged ${String(stray.length)} path(s) outside it, ` +
602
+ `and nothing was committed: ${stray.slice(0, 10).join(", ")}`,
603
+ };
604
+ }
605
+ if (stagedPaths.length > 0) {
606
+ const commit = runGit(fleetRoot, ["commit", "-q", "-m", options.message]);
607
+ if (commit.status !== 0) {
608
+ return { ok: false, reason: `git commit failed: ${commit.stderr.trim()}` };
609
+ }
610
+ }
611
+ if (!hasOrigin) {
612
+ const reason = `${fleetRoot} has no origin remote, so the rollback is committed locally and NOT published`;
613
+ return options.allowNoRemote === true
614
+ ? { ok: true, pushed: false, reason, staged: stagedPaths }
615
+ : { ok: false, reason };
616
+ }
617
+ const push = runGit(fleetRoot, ["push", "origin", "HEAD"]);
618
+ if (push.status !== 0) {
619
+ return {
620
+ ok: false,
621
+ reason: `git push failed: ${(push.stderr + push.stdout).trim().split("\n").join(" ")}`,
622
+ };
623
+ }
624
+ const head = runGit(fleetRoot, ["rev-parse", "HEAD"]);
625
+ if (head.status !== 0) {
626
+ return { ok: false, reason: `git rev-parse HEAD failed: ${head.stderr.trim()}` };
627
+ }
628
+ return { ok: true, pushed: true, head: head.stdout.trim(), staged: stagedPaths };
629
+ }
630
+ /* -------------------------------------------------------------------- */
631
+ /* Trigger 2 step 2: restoring the FILES */
632
+ /* -------------------------------------------------------------------- */
633
+ /**
634
+ * Refuse to touch a tree that holds uncommitted work.
635
+ *
636
+ * ANY `git checkout --` in such a tree is destructive, INCLUDING when it names
637
+ * a single path, and especially the path being edited. This repository has
638
+ * paid for that twice; the rule is standing warning 8 in the agent-rules file.
639
+ * The guard is a precondition of the restore and not a warning printed beside
640
+ * it, because a warning is advice and this is the only thing standing between
641
+ * a rollback and four rounds of somebody's uncommitted work.
642
+ */
643
+ export function refuseIfTreeDirty(repoRoot) {
644
+ const status = runGit(repoRoot, ["status", "--porcelain"]);
645
+ if (status.status !== 0) {
646
+ return { ok: false, reason: `git status failed in ${repoRoot}: ${status.stderr.trim()}` };
647
+ }
648
+ const dirty = status.stdout
649
+ .split("\n")
650
+ .map((line) => line.trim())
651
+ .filter((line) => line.length > 0);
652
+ if (dirty.length > 0) {
653
+ return {
654
+ ok: false,
655
+ reason: `${repoRoot} holds ${dirty.length} uncommitted change(s) and nothing was touched: ` +
656
+ dirty.slice(0, 10).join(", "),
657
+ };
658
+ }
659
+ return { ok: true };
660
+ }
661
+ /**
662
+ * Restore the named retirement roots from a pre-freeze sha.
663
+ *
664
+ * The dirty check runs FIRST and returns before any version-control
665
+ * invocation that can write. That order is the whole guarantee, and the test
666
+ * asserts the dirty file is byte-identical afterwards rather than asserting
667
+ * the exit code alone.
668
+ *
669
+ * "RESTORE" IS A CLAIM ABOUT THE WHOLE ROOT, AND CHECKING A TREE OUT OVER A
670
+ * PATH IS NOT ONE. `checkout <sha> -- <root>` writes what the sha held and
671
+ * removes NOTHING, so every file added under the root after the freeze
672
+ * survives the restore untouched. The result is a hybrid tree that the caller
673
+ * prints `RESTORED` over: a verdict wider than the operation that produced it.
674
+ * The post-freeze additions are therefore enumerated and removed, and then the
675
+ * root is COMPARED with the sha. The comparison is the verdict; without it the
676
+ * success arm is an assumption, and a success arm that cannot fail is the
677
+ * guard that cannot go red.
678
+ */
679
+ export function restoreRetirementRoots(repoRoot, sha, roots) {
680
+ if (roots.length === 0) {
681
+ return { ok: false, reason: "no retirement root was named" };
682
+ }
683
+ const guard = refuseIfTreeDirty(repoRoot);
684
+ if (!guard.ok) {
685
+ return { ok: false, reason: guard.reason };
686
+ }
687
+ const resolved = runGit(repoRoot, ["rev-parse", "--verify", `${sha}^{commit}`]);
688
+ if (resolved.status !== 0) {
689
+ return { ok: false, reason: `${sha} does not resolve to a commit in ${repoRoot}` };
690
+ }
691
+ /* Tracked under a root NOW and absent at the freeze. The tree is clean here,
692
+ because refuseIfTreeDirty has already returned, so HEAD is the tree. */
693
+ const added = runGit(repoRoot, [
694
+ "diff",
695
+ "--name-only",
696
+ "--diff-filter=A",
697
+ /* --no-renames IS LOAD-BEARING AND THE VERIFICATION ARM BELOW IS WHAT
698
+ FOUND IT. With rename detection on, a file renamed after the freeze is
699
+ reported as R rather than A, so its NEW name is not enumerated, the
700
+ removal misses it, and the checkout restores the old name beside it. The
701
+ first version of this function had the flag missing; the residue
702
+ comparison turned the hybrid tree into a refusal naming
703
+ `retired/renamed.md` instead of a green. That is the whole reason the
704
+ verdict is measured rather than assumed. */
705
+ "--no-renames",
706
+ sha,
707
+ "HEAD",
708
+ "--",
709
+ ...roots,
710
+ ]);
711
+ if (added.status !== 0) {
712
+ return {
713
+ ok: false,
714
+ reason: `enumerating the post-freeze additions failed: ${added.stderr.trim()}`,
715
+ };
716
+ }
717
+ const postFreeze = nonEmptyLines(added.stdout);
718
+ const checkout = runGit(repoRoot, ["checkout", sha, "--", ...roots]);
719
+ if (checkout.status !== 0) {
720
+ return { ok: false, reason: `git checkout failed: ${checkout.stderr.trim()}` };
721
+ }
722
+ if (postFreeze.length > 0) {
723
+ const removed = runGit(repoRoot, ["rm", "-q", "-f", "--", ...postFreeze]);
724
+ if (removed.status !== 0) {
725
+ return {
726
+ ok: false,
727
+ reason: `removing the post-freeze additions failed: ${removed.stderr.trim()}`,
728
+ };
729
+ }
730
+ }
731
+ /* THE VERDICT IS MEASURED. Compare the sha with the working tree over the
732
+ same roots; anything printed here is a difference the restore did not
733
+ close, and the caller is told rather than shown a success. */
734
+ const residue = runGit(repoRoot, ["diff", "--name-only", sha, "--", ...roots]);
735
+ if (residue.status !== 0) {
736
+ return {
737
+ ok: false,
738
+ reason: `verifying the restore against ${sha} failed: ${residue.stderr.trim()}`,
739
+ };
740
+ }
741
+ const differing = nonEmptyLines(residue.stdout);
742
+ if (differing.length > 0) {
743
+ return {
744
+ ok: false,
745
+ reason: `${String(differing.length)} path(s) under the retirement roots still differ from ${sha} ` +
746
+ `after the restore, so nothing is reported as restored: ${differing.slice(0, 10).join(", ")}`,
747
+ };
748
+ }
749
+ return { ok: true, roots, removed: postFreeze };
750
+ }
751
+ /* -------------------------------------------------------------------- */
752
+ /* Trigger 2 step 3: the owner request that cannot be rehearsed */
753
+ /* -------------------------------------------------------------------- */
754
+ /**
755
+ * The property that makes trigger 2 step 3 unrehearsable, in one sentence,
756
+ * exported so the document, the command and the rehearsal script all print
757
+ * the SAME sentence and cannot drift apart.
758
+ */
759
+ export const UNREHEARSABLE_REASON = "the branch-protection ruleset is a single live object on one owner-owned " +
760
+ "repository: there is no second instance to rehearse against, and no dry " +
761
+ "run distinguishes allowed from refused";
762
+ /**
763
+ * Flatten the captured ruleset into the fields the owner request must carry.
764
+ * The shape is fixed here rather than schema-loaded because M4-P25 owns the
765
+ * schema; what this phase needs is the COMPLETENESS check over whatever that
766
+ * capture holds.
767
+ */
768
+ const REQUIRED_TOP_LEVEL = [
769
+ "capturedAt",
770
+ "repository",
771
+ "rulesetName",
772
+ "rules",
773
+ "credentialGrants",
774
+ ];
775
+ function isEmptyValue(value) {
776
+ if (value === undefined || value === null) {
777
+ return true;
778
+ }
779
+ if (typeof value === "string") {
780
+ return value.trim().length === 0;
781
+ }
782
+ if (Array.isArray(value)) {
783
+ return value.length === 0;
784
+ }
785
+ if (typeof value === "object") {
786
+ return Object.keys(value).length === 0;
787
+ }
788
+ return false;
789
+ }
790
+ /**
791
+ * Generate the owner request for trigger 2 step 3.
792
+ *
793
+ * TWO STRUCTURALLY DIFFERENT REFUSALS, and the second is the one a field
794
+ * check is green on. An ABSENT key is caught by asking whether the key is
795
+ * there. A key that is PRESENT and EMPTY passes that question and produces a
796
+ * request with a blank where a pre-flip value belongs, which the owner would
797
+ * have to fill in from memory. Both are refused, and the refusal names the
798
+ * field, because an owner request with a hole in it is worse than no request:
799
+ * it looks complete.
800
+ */
801
+ export function generateRestoreRequest(document) {
802
+ if (typeof document !== "object" || document === null || Array.isArray(document)) {
803
+ return { ok: false, reasons: ["pre-freeze ruleset is not a JSON object"] };
804
+ }
805
+ const capture = document;
806
+ const reasons = [];
807
+ for (const key of REQUIRED_TOP_LEVEL) {
808
+ if (!(key in capture)) {
809
+ reasons.push(`field ${key} is absent from the captured ruleset`);
810
+ continue;
811
+ }
812
+ if (isEmptyValue(capture[key])) {
813
+ reasons.push(`field ${key} is present but empty, so it carries no pre-flip value`);
814
+ }
815
+ }
816
+ const fields = [];
817
+ for (const [listName, idKey] of [
818
+ ["rules", "id"],
819
+ ["credentialGrants", "name"],
820
+ ]) {
821
+ const list = capture[listName];
822
+ if (!Array.isArray(list)) {
823
+ /* PRESENT, NON-EMPTY AND NOT A LIST. The top-level loop above asks only
824
+ whether the key is there and whether it is empty, and a string such as
825
+ "see the wiki" passes both. Falling through here would generate an
826
+ owner request carrying zero fields from that key while reporting
827
+ success, which is the present-but-useless arm criterion 6 member B
828
+ exists for, one level up from a single field. Absence is already
829
+ reported above, so only presence is reported here. */
830
+ if (listName in capture) {
831
+ reasons.push(`field ${listName} is present but is not a list, so no pre-flip value could be read from it`);
832
+ }
833
+ continue;
834
+ }
835
+ list.forEach((row, index) => {
836
+ if (typeof row !== "object" || row === null || Array.isArray(row)) {
837
+ reasons.push(`${listName}[${index}] is not an object`);
838
+ return;
839
+ }
840
+ const entry = row;
841
+ const label = nonEmptyString(entry[idKey]) ? entry[idKey] : `[${index}]`;
842
+ if (!nonEmptyString(entry[idKey])) {
843
+ reasons.push(`${listName}[${index}] has no ${idKey}`);
844
+ }
845
+ if (!("preFlipValue" in entry)) {
846
+ reasons.push(`${listName}.${label} has no preFlipValue`);
847
+ return;
848
+ }
849
+ if (isEmptyValue(entry["preFlipValue"])) {
850
+ reasons.push(`${listName}.${label} has a preFlipValue that is present but empty`);
851
+ return;
852
+ }
853
+ fields.push({ path: `${listName}.${label}`, value: entry["preFlipValue"] });
854
+ });
855
+ }
856
+ if (reasons.length > 0) {
857
+ return { ok: false, reasons };
858
+ }
859
+ const lines = [];
860
+ lines.push("OWNER ACTION: restore the pre-freeze branch protection and credential grants");
861
+ lines.push("");
862
+ lines.push(`repository: ${String(capture["repository"])}`);
863
+ lines.push(`ruleset: ${String(capture["rulesetName"])}`);
864
+ lines.push(`captured at: ${String(capture["capturedAt"])}`);
865
+ lines.push("");
866
+ lines.push("Restore each field below to its pre-flip value:");
867
+ for (const field of fields) {
868
+ lines.push(` ${field.path} = ${JSON.stringify(field.value)}`);
869
+ }
870
+ lines.push("");
871
+ lines.push("THIS STEP CANNOT BE REHEARSED. " + UNREHEARSABLE_REASON + ".");
872
+ lines.push("A green rehearsal of this request says the REQUEST is complete. It says " +
873
+ "nothing about whether the change will be accepted.");
874
+ lines.push("");
875
+ lines.push("Request an A-n id from delivery/STATE.md, which is the sole allocator, " +
876
+ "and record this action there before sending it.");
877
+ return { ok: true, text: `${lines.join("\n")}\n`, fields: fields.length };
878
+ }
879
+ /**
880
+ * Decide one PORT row.
881
+ *
882
+ * `ported` needs BOTH halves and the second is the one that matters. A verdict
883
+ * derived only from the destination file existing is the vacuous version: a
884
+ * file can exist and say nothing. The negative witness was RED under the old
885
+ * rule, so a witness that exits 0 under the new artifact means the new
886
+ * artifact does not catch what the old one caught, which is precisely
887
+ * "WEAKER". Exit 0 from the witness is therefore `unported`, not a pass.
888
+ *
889
+ * `ported` IS REACHED FROM A POSITIVE TEST AND NEVER FROM A FALLTHROUGH, and
890
+ * that is what the first three checks below are. `readRetirementInventory`
891
+ * casts whatever the fixture's `rows` array holds, so a row may be a string, a
892
+ * number, or an object whose `disposition` is misspelt; the earlier form asked
893
+ * only whether the disposition was NOT the word `PORT`, so every one of those
894
+ * returned `ported`, which is a verdict of "this retirement is complete" over
895
+ * a row nobody could read. The vocabulary is closed, so a value outside it is
896
+ * `unported` and names itself.
897
+ */
898
+ export const DISPOSITIONS = ["PORT", "DELETE", "KEEP"];
899
+ function isDisposition(value) {
900
+ return typeof value === "string" && DISPOSITIONS.includes(value);
901
+ }
902
+ export function evaluatePortRow(row, repoRoot) {
903
+ if (typeof row !== "object" || row === null || Array.isArray(row)) {
904
+ return {
905
+ id: `(row ${JSON.stringify(row)})`,
906
+ verdict: "unported",
907
+ reason: "inventory row is not an object, so its disposition could not be read",
908
+ };
909
+ }
910
+ const id = nonEmptyString(row.id) ? row.id : "(row with no id)";
911
+ if (!nonEmptyString(row.id)) {
912
+ return { id, verdict: "unported", reason: "inventory row has no id" };
913
+ }
914
+ if (!isDisposition(row.disposition)) {
915
+ return {
916
+ id,
917
+ verdict: "unported",
918
+ reason: `disposition ${JSON.stringify(row.disposition)} is not one of ${DISPOSITIONS.join(", ")}`,
919
+ };
920
+ }
921
+ if (row.disposition !== "PORT") {
922
+ return { id, verdict: "ported", reason: `disposition ${row.disposition} needs no port` };
923
+ }
924
+ if (!nonEmptyString(row.destination)) {
925
+ return { id, verdict: "unported", reason: "PORT row names no destination" };
926
+ }
927
+ /* A DESTINATION MAY NAME MORE THAN ONE ARTIFACT, and that is the inventory's
928
+ own declared convention rather than a reading invented here: its checker
929
+ splits the field on commas at scripts/check-retirement-inventory.mjs:754.
930
+ Measured 2026-09-18 against the shipped inventory: three rows
931
+ (`next-script:gittry`, `next-script:gitcount`, `next-script:harderrors`)
932
+ carry `roles/investigator.md, checklists/clean-room.yaml`, and treating
933
+ the field as ONE path reported all three as `unported` with a reason about
934
+ a file that does not exist. That is a false finding about the kernel
935
+ produced by a disagreement about a separator, so EVERY named path must
936
+ exist and the first that does not is the one named. Splitting a
937
+ single-path field yields that one path, so nothing else changes. */
938
+ const destinations = row.destination.split(/\s*,\s*/).filter((part) => part.length > 0);
939
+ for (const part of destinations) {
940
+ if (classifyEntry(join(repoRoot, part)).kind !== "regular") {
941
+ return {
942
+ id,
943
+ verdict: "unported",
944
+ reason: `destination ${part} does not exist as a file`,
945
+ };
946
+ }
947
+ }
948
+ /* THE COMMAND'S TYPE IS ESTABLISHED BEFORE IT IS DESTRUCTURED OR SPAWNED,
949
+ and `destination` one line up is why this line looks the way it does: that
950
+ field is tested with `nonEmptyString`, this one was tested with `.length`,
951
+ and `.length` is a property read off a value nobody typed. `42` and `{}`
952
+ are not iterable, so the destructuring below threw
953
+ `TypeError: ... is not iterable` out of a function whose interface is a
954
+ PortResult; `null` threw on `.length` before reaching it; and an ARRAY
955
+ holding a non-string threw inside spawnSync on the "file" argument. Four
956
+ throws where a verdict was owed. An inventory row is data supplied by
957
+ another phase's file, so its fields are unknown in the same way a parsed
958
+ document's are, and a throw carries no reason for the refusal. */
959
+ if (!Array.isArray(row.negativeWitness) || row.negativeWitness.length === 0) {
960
+ return {
961
+ id,
962
+ verdict: "unported",
963
+ reason: "PORT row carries no negative-witness command",
964
+ };
965
+ }
966
+ if (!row.negativeWitness.every((part) => nonEmptyString(part))) {
967
+ return {
968
+ id,
969
+ verdict: "unported",
970
+ reason: "PORT row's negative-witness command is not a list of non-empty strings, so it could not be run",
971
+ };
972
+ }
973
+ const [program, ...args] = row.negativeWitness;
974
+ const run = spawnSync(program, args, { cwd: repoRoot, encoding: "utf8" });
975
+ if (run.error !== undefined) {
976
+ return {
977
+ id,
978
+ verdict: "unported",
979
+ reason: `negative witness could not be run: ${String(run.error)}`,
980
+ };
981
+ }
982
+ /* A WITNESS THAT DIED IS NOT A WITNESS THAT WAS RED. `status` is null when a
983
+ child is killed by a signal, and null is not 0, so the nonzero arm at the
984
+ bottom used to accept it and report the row as ported. A witness killed by
985
+ the out-of-memory killer or by a harness timeout has demonstrated nothing
986
+ about the new artifact. */
987
+ if (run.signal !== null && run.signal !== undefined) {
988
+ return {
989
+ id,
990
+ verdict: "unported",
991
+ reason: `negative witness was killed by ${run.signal}, which is not evidence that it is red under the new artifact`,
992
+ };
993
+ }
994
+ if (run.status === null) {
995
+ return {
996
+ id,
997
+ verdict: "unported",
998
+ reason: "negative witness reported no exit status, so it did not demonstrate anything",
999
+ };
1000
+ }
1001
+ if (run.status === 0) {
1002
+ return {
1003
+ id,
1004
+ verdict: "unported",
1005
+ reason: "negative witness exits 0 under the new artifact, so the destination is WEAKER than the rule it replaced",
1006
+ };
1007
+ }
1008
+ /* A NONZERO EXIT IS NOT THE SAME THING AS A RED WITNESS, and this is the
1009
+ vacuous red one level below the vacuous green above. `grep` exits 1 when
1010
+ it searched and found nothing, which is the answer that makes the row's
1011
+ claim stand, and 2 when it could not search at all: a missing subject
1012
+ file, an unreadable directory, a bad pattern. Accepting any nonzero
1013
+ status reports a row as PORTED on the strength of an error message, which
1014
+ is a guard that fails open when its own tool fails. The row's own
1015
+ `negative-witness.exit` is what it recorded when it was written, so when
1016
+ the row declares one it is REQUIRED to match. Same rule, same reason, as
1017
+ scripts/check-retirement-inventory.mjs:628 applies to its widening grep.
1018
+ Measured 2026-09-18 over the shipped inventory: all 199 PORT rows record
1019
+ exit 1 and all 199 observed exit 1, so this changes no verdict today and
1020
+ is a guard against the day one of them starts erroring instead. */
1021
+ if (row.expectedWitnessExit !== undefined && run.status !== row.expectedWitnessExit) {
1022
+ return {
1023
+ id,
1024
+ verdict: "unported",
1025
+ reason: `negative witness exits ${String(run.status)} under the new artifact but the row recorded ${String(row.expectedWitnessExit)}, so it did not search and find nothing, it failed`,
1026
+ };
1027
+ }
1028
+ return {
1029
+ id,
1030
+ verdict: "ported",
1031
+ reason: `negative witness exits ${String(run.status)} under the new artifact`,
1032
+ };
1033
+ }
1034
+ /**
1035
+ * Read an M4-P23 retirement inventory. The path is a parameter because that
1036
+ * phase runs concurrently with this one and its file does not exist on this
1037
+ * branch; the rehearsal supplies a fixture.
1038
+ */
1039
+ export function readRetirementInventory(path) {
1040
+ const read = readRegularFileIfPresent(path);
1041
+ if (read.kind === "absent") {
1042
+ return { kind: "absent" };
1043
+ }
1044
+ if (read.kind === "refused") {
1045
+ return { kind: "refused", reason: read.reason };
1046
+ }
1047
+ let parsed;
1048
+ try {
1049
+ parsed = JSON.parse(read.body);
1050
+ }
1051
+ catch (error) {
1052
+ return { kind: "refused", reason: `${path} is not valid JSON: ${String(error)}` };
1053
+ }
1054
+ /* THE TYPE IS ESTABLISHED BEFORE A PROPERTY IS READ OFF IT, and this line is
1055
+ why. `JSON.parse("null")` succeeds and returns null, so the cast-and-read
1056
+ that used to stand here threw a TypeError out of a function whose whole
1057
+ interface is a three-way refusal. A read function that throws has no
1058
+ refusal REASON, so the caller cannot report what was wrong with the file,
1059
+ and the crash is indistinguishable from a defect in the reader. The
1060
+ sibling readers both test the type first (validateCutoverDocument at
1061
+ src/cutover.ts:154 and generateRestoreRequest at src/cutover.ts:1025);
1062
+ this one did not, and the not-covered statement's claim that a malformed
1063
+ inventory is refused was false for exactly that member. */
1064
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1065
+ return { kind: "refused", reason: `${path} is not a JSON object` };
1066
+ }
1067
+ const rows = parsed.rows;
1068
+ if (!Array.isArray(rows)) {
1069
+ return { kind: "refused", reason: `${path} has no rows array` };
1070
+ }
1071
+ return { kind: "read", rows: rows };
1072
+ }
1073
+ /* -------------------------------------------------------------------- */
1074
+ /* M4-P25: the shipped state schema */
1075
+ /* -------------------------------------------------------------------- */
1076
+ /** The shipped schema document's basename, in the package's `schemas/`. */
1077
+ export const CUTOVER_STATE_SCHEMA_FILENAME = "cutover-state.schema.json";
1078
+ /**
1079
+ * Absolute path of the shipped cutover-state schema.
1080
+ *
1081
+ * `packageRoot()` walks UP and TESTS rather than counting `..`, because the
1082
+ * depth differs between running from `src/` and running from `dist/src/`
1083
+ * (src/modes.ts:39 states the same reason for the same walk).
1084
+ */
1085
+ export function cutoverStateSchemaPath() {
1086
+ return join(packageRoot(), "schemas", CUTOVER_STATE_SCHEMA_FILENAME);
1087
+ }
1088
+ let cachedCutoverSchema;
1089
+ /** The shipped schema document, read once. */
1090
+ export function cutoverStateSchema() {
1091
+ if (cachedCutoverSchema === undefined) {
1092
+ const path = cutoverStateSchemaPath();
1093
+ const read = readRegularFileIfPresent(path);
1094
+ if (read.kind !== "read") {
1095
+ throw new Error(read.kind === "absent"
1096
+ ? `${path} is missing from this installation`
1097
+ : read.reason);
1098
+ }
1099
+ cachedCutoverSchema = JSON.parse(read.body);
1100
+ }
1101
+ return cachedCutoverSchema;
1102
+ }
1103
+ /**
1104
+ * Validate a whole cutover document against the SHIPPED SCHEMA.
1105
+ *
1106
+ * WHY THIS IS NOT `validateCutoverDocument`, and the two are kept apart on
1107
+ * purpose. That function is this module's READ guard and answers "can this
1108
+ * file be interpreted". This one is the WRITE guard and answers "is this
1109
+ * document one the package is prepared to ship", and criterion 4 says the
1110
+ * required-field rule is validated BY THE SCHEMA rather than by a command.
1111
+ * A check implemented only in a command is one the schema cannot enforce for
1112
+ * a later writer, and a later writer is precisely who rollback depends on:
1113
+ * M4-P26's `targetFor` READS the recorded `restoreTo` (src/cutover.ts:352)
1114
+ * rather than remembering an intent, so a record written without one is a
1115
+ * switch that can never be rolled back.
1116
+ */
1117
+ export function cutoverSchemaDiagnostics(document) {
1118
+ return validateToLines(cutoverStateSchema(), document);
1119
+ }
1120
+ /* -------------------------------------------------------------------- */
1121
+ /* M4-P25 criterion 5: the pre-freeze precondition */
1122
+ /* -------------------------------------------------------------------- */
1123
+ /**
1124
+ * The captured pre-freeze state, relative to the REPOSITORY root. It carries
1125
+ * the branch-protection ruleset as it was before the first flip, so the
1126
+ * restore request M4-P26 generates has an input rather than a memory. T-025
1127
+ * is why: the one step that cannot be rehearsed is the one that failed, and
1128
+ * its INPUT can be captured in advance even when its EXECUTION cannot.
1129
+ */
1130
+ export const PRE_FREEZE_RULESET_PATH = "delivery/plan/cutover/pre-freeze-ruleset.json";
1131
+ /**
1132
+ * THE CAPTURE TIME IS READ FROM THE DOCUMENT, NOT FROM ITS MTIME, and that is
1133
+ * a correction rather than a preference.
1134
+ *
1135
+ * The criterion says the capture must not be OLDER than the most recent switch
1136
+ * write. An mtime does not survive the journey: `git clone` and `git checkout`
1137
+ * set every working-tree mtime to the moment of the checkout, so a capture
1138
+ * taken weeks before a flip reads as newer than the flip on any fresh clone,
1139
+ * and the guard reports satisfied on exactly the machine a reviewer uses.
1140
+ * Measured, and the measurement is in the work history. So the document
1141
+ * records `captured-at` and the comparison is content to content.
1142
+ *
1143
+ * FAIL CLOSED ON AN UNREADABLE CAPTURE. A document with no `captured-at`, or
1144
+ * one that does not parse as an instant, is `unreadable` and refuses. Treating
1145
+ * it as satisfied would make a malformed capture indistinguishable from a good
1146
+ * one, which is the guard that cannot go red.
1147
+ */
1148
+ export function preFreezeGuard(repoRoot, state) {
1149
+ const frozen = CUTOVER_SWITCHES.filter((name) => state.switches[name].state === "kernel");
1150
+ if (frozen.length === 0) {
1151
+ return { kind: "not-required" };
1152
+ }
1153
+ const path = join(repoRoot, PRE_FREEZE_RULESET_PATH);
1154
+ const read = readRegularFileIfPresent(path);
1155
+ if (read.kind === "absent") {
1156
+ return {
1157
+ kind: "refused",
1158
+ arm: "absent",
1159
+ reason: `${PRE_FREEZE_RULESET_PATH} is absent, so the pre-freeze branch-protection ruleset was never captured and there is nothing to restore to`,
1160
+ };
1161
+ }
1162
+ if (read.kind === "refused") {
1163
+ return { kind: "refused", arm: "unreadable", reason: read.reason };
1164
+ }
1165
+ let parsed;
1166
+ try {
1167
+ parsed = JSON.parse(read.body);
1168
+ }
1169
+ catch (error) {
1170
+ return {
1171
+ kind: "refused",
1172
+ arm: "unreadable",
1173
+ reason: `${PRE_FREEZE_RULESET_PATH} is not valid JSON: ${String(error)}`,
1174
+ };
1175
+ }
1176
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1177
+ return {
1178
+ kind: "refused",
1179
+ arm: "unreadable",
1180
+ reason: `${PRE_FREEZE_RULESET_PATH} is not a JSON object`,
1181
+ };
1182
+ }
1183
+ const capturedAt = parsed["captured-at"];
1184
+ if (!nonEmptyString(capturedAt)) {
1185
+ return {
1186
+ kind: "refused",
1187
+ arm: "unreadable",
1188
+ reason: `${PRE_FREEZE_RULESET_PATH} records no captured-at, so it cannot be compared with any switch write`,
1189
+ };
1190
+ }
1191
+ const capturedMs = Date.parse(capturedAt);
1192
+ if (Number.isNaN(capturedMs)) {
1193
+ return {
1194
+ kind: "refused",
1195
+ arm: "unreadable",
1196
+ reason: `${PRE_FREEZE_RULESET_PATH} records captured-at ${JSON.stringify(capturedAt)}, which does not parse as an instant`,
1197
+ };
1198
+ }
1199
+ /* The NEWEST switch write across all five, not only the frozen ones: a
1200
+ switch written back to `current` after the capture is still a write, and
1201
+ the capture has to be at least as new as the whole table. */
1202
+ let newest = Number.NEGATIVE_INFINITY;
1203
+ let newestName = "";
1204
+ for (const name of CUTOVER_SWITCHES) {
1205
+ const at = Date.parse(state.switches[name].flippedAt);
1206
+ if (Number.isNaN(at)) {
1207
+ return {
1208
+ kind: "refused",
1209
+ arm: "unreadable",
1210
+ reason: `switch ${name} records flippedAt ${JSON.stringify(state.switches[name].flippedAt)}, which does not parse as an instant, so the capture could not be compared with it`,
1211
+ };
1212
+ }
1213
+ if (at > newest) {
1214
+ newest = at;
1215
+ newestName = name;
1216
+ }
1217
+ }
1218
+ if (capturedMs < newest) {
1219
+ return {
1220
+ kind: "refused",
1221
+ arm: "stale",
1222
+ reason: `${PRE_FREEZE_RULESET_PATH} was captured at ${capturedAt}, which is older than the most recent switch write (${newestName} at ${state.switches[newestName].flippedAt}), so it does not describe the state before that flip`,
1223
+ };
1224
+ }
1225
+ return { kind: "satisfied", capturedAt };
1226
+ }
1227
+ /**
1228
+ * Count the PUSHED, UNMERGED branches, and print them nowhere near the drain
1229
+ * verdict.
1230
+ *
1231
+ * THIS NUMBER DOES NOT FEED THE PREDICATE, AND THAT IS THE DECISION (M4-D-15,
1232
+ * delivery/plan/kernel-plan-m4.md:3279). Remote ref deletion is refused in
1233
+ * this container, and `git push --dry-run` does not probe push authorization
1234
+ * at all, in either direction, for any ref namespace
1235
+ * (delivery/verification/m4-prototype-probes.md:165). A drain defined as "no
1236
+ * unmerged branches" therefore waits forever on an owner action that has no
1237
+ * local pre-check, which is a predicate that can never read clean. It is
1238
+ * reported because an operator wants to see it, and it is reported on its own
1239
+ * line so that no reader can mistake it for a reason the cutover is blocked.
1240
+ *
1241
+ * `unexaminable` is a THIRD answer for the same reason it is one in
1242
+ * `inFlightItems`: a repository with no `origin/<default>` cannot be asked
1243
+ * this question, and printing `0` there would be an answer nobody measured.
1244
+ */
1245
+ export function unmergedBranchCount(repoRoot, upstream = "origin/main") {
1246
+ const listed = runGit(repoRoot, [
1247
+ "for-each-ref",
1248
+ "--format=%(refname:short)",
1249
+ "--no-merged",
1250
+ upstream,
1251
+ "refs/remotes/origin",
1252
+ ]);
1253
+ if (listed.status !== 0) {
1254
+ return {
1255
+ kind: "unexaminable",
1256
+ reason: `git for-each-ref --no-merged ${upstream} exited ${String(listed.status)}: ${singleLineText(listed.stderr)}`,
1257
+ };
1258
+ }
1259
+ const branches = listed.stdout
1260
+ .split("\n")
1261
+ .map((line) => line.trim())
1262
+ .filter((line) => line.length > 0 && line !== "origin/HEAD");
1263
+ return { kind: "counted", count: branches.length, branches };
1264
+ }
1265
+ function singleLineText(text) {
1266
+ return text.replace(/\s+/g, " ").trim();
1267
+ }
1268
+ /* -------------------------------------------------------------------- */
1269
+ /* M4-P25 criterion 6: the retirement verdict over the real inventory */
1270
+ /* -------------------------------------------------------------------- */
1271
+ /** The M4-P23 inventory, relative to the REPOSITORY root. */
1272
+ export const RETIREMENT_INVENTORY_PATH = "delivery/plan/cutover/retirement-inventory.json";
1273
+ /**
1274
+ * The first tokens a retirement row's command may start a segment with.
1275
+ *
1276
+ * MIRRORED FROM `scripts/check-retirement-inventory.mjs`, DELIBERATELY, AND
1277
+ * THE DRIFT IS ASSERTED BY A TEST rather than by this comment. The script is
1278
+ * this project's own predicate and is KEPT rather than shipped (DR-0029), so
1279
+ * the kernel cannot import it; but the two lists screening the same rows must
1280
+ * not diverge, so `test/cutover.test.ts` reads the script's
1281
+ * `ALLOWED_FIRST_TOKENS` and requires this set to be no wider.
1282
+ *
1283
+ * WHAT THE LIST BUYS AND WHAT IT DOES NOT. Every tool on it is one with no
1284
+ * option for writing a file, so a row cannot modify the tree this command is
1285
+ * auditing. It is a TOOL allowlist, not a sandbox: the child still runs with
1286
+ * this process's privileges and can read anything this process can read.
1287
+ */
1288
+ export const RETIREMENT_COMMAND_TOKENS = new Set([
1289
+ "grep",
1290
+ "test",
1291
+ "ls",
1292
+ "wc",
1293
+ "comm",
1294
+ "diff",
1295
+ "head",
1296
+ "tail",
1297
+ "cat",
1298
+ ]);
1299
+ const RETIREMENT_COMMAND_FORBIDDEN = [
1300
+ { re: /[<>]/, why: "redirection" },
1301
+ { re: /\$\(/, why: "command substitution" },
1302
+ { re: /`/, why: "backtick substitution" },
1303
+ ];
1304
+ /**
1305
+ * Screen one inventory command. The rows are DATA FROM A FILE, so the command
1306
+ * is screened before anything spawns it, and the check is on the EXECUTABLE
1307
+ * POSITION of every segment rather than on the whole string: a whole-string
1308
+ * denylist refuses `grep -c 'npm ci' gate-registry.yaml`, which runs no npm at
1309
+ * all and merely searches for those characters.
1310
+ */
1311
+ export function screenRetirementCommand(command) {
1312
+ if (!nonEmptyString(command)) {
1313
+ return ["negative-witness command is missing or empty"];
1314
+ }
1315
+ const problems = [];
1316
+ for (const forbidden of RETIREMENT_COMMAND_FORBIDDEN) {
1317
+ if (forbidden.re.test(command)) {
1318
+ problems.push(`negative-witness command uses ${forbidden.why}`);
1319
+ }
1320
+ }
1321
+ for (const segment of command.split(/\|\||&&|[|;&\n]/)) {
1322
+ const first = segment.trim().split(/\s+/)[0];
1323
+ if (first === undefined || first === "") {
1324
+ continue;
1325
+ }
1326
+ if (!RETIREMENT_COMMAND_TOKENS.has(first)) {
1327
+ problems.push(`negative-witness command segment starts with ${JSON.stringify(first)}, which is not on the allowlist`);
1328
+ }
1329
+ }
1330
+ return problems;
1331
+ }
1332
+ /**
1333
+ * Turn one row of the SHIPPED inventory into the shape `evaluatePortRow`
1334
+ * takes.
1335
+ *
1336
+ * THIS ADAPTER EXISTS BECAUSE THE TWO SHAPES REALLY ARE DIFFERENT, and the
1337
+ * difference is silent in the dangerous direction. `RetirementRow` declares
1338
+ * `negativeWitness` as an argv ARRAY (src/cutover.ts:1128), and the M4-P23
1339
+ * inventory writes `negative-witness` as an OBJECT carrying a SHELL STRING
1340
+ * (delivery/plan/cutover/retirement-inventory.json:1). Handing the shipped
1341
+ * document straight to `evaluatePortRow` therefore returns `unported` with
1342
+ * "PORT row carries no negative-witness command" for every row in it: a
1343
+ * verdict that looks like a finding about the kernel and is a finding about a
1344
+ * key spelling. The adapter is named, tested and refuses rather than
1345
+ * defaulting, so the mismatch cannot come back as a silent all-red.
1346
+ */
1347
+ export function retirementRowFromDocument(raw) {
1348
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1349
+ return {
1350
+ kind: "refused",
1351
+ result: {
1352
+ id: `(row ${JSON.stringify(raw)})`,
1353
+ verdict: "unported",
1354
+ reason: "inventory row is not an object, so its disposition could not be read",
1355
+ },
1356
+ };
1357
+ }
1358
+ const source = raw;
1359
+ const id = nonEmptyString(source["id"]) ? source["id"] : "(row with no id)";
1360
+ const row = {
1361
+ id,
1362
+ disposition: source["disposition"],
1363
+ };
1364
+ if (nonEmptyString(source["destination"])) {
1365
+ row.destination = source["destination"];
1366
+ }
1367
+ const witness = source["negative-witness"];
1368
+ if (source["disposition"] !== "PORT") {
1369
+ return { kind: "row", row };
1370
+ }
1371
+ if (typeof witness !== "object" || witness === null || Array.isArray(witness)) {
1372
+ return {
1373
+ kind: "refused",
1374
+ result: {
1375
+ id,
1376
+ verdict: "unported",
1377
+ reason: "PORT row carries no negative-witness object",
1378
+ },
1379
+ };
1380
+ }
1381
+ const command = witness["command"];
1382
+ const problems = screenRetirementCommand(command);
1383
+ if (problems.length > 0) {
1384
+ return {
1385
+ kind: "refused",
1386
+ result: { id, verdict: "unported", reason: problems.join("; ") },
1387
+ };
1388
+ }
1389
+ /* `sh -c` is how the row's own checker runs it
1390
+ (scripts/check-retirement-inventory.mjs:377), so the command that was
1391
+ screened is the command that runs. */
1392
+ row.negativeWitness = ["sh", "-c", command];
1393
+ const recorded = witness["exit"];
1394
+ if (typeof recorded === "number" && Number.isInteger(recorded)) {
1395
+ row.expectedWitnessExit = recorded;
1396
+ }
1397
+ return { kind: "row", row };
1398
+ }
1399
+ /**
1400
+ * Evaluate every PORT row of the inventory.
1401
+ *
1402
+ * THE VACUOUS VERDICT IS THE ONE THIS GUARDS AGAINST. `ported` is not "the
1403
+ * named kernel artifact exists"; a file can exist and say nothing. Both halves
1404
+ * are required and the second is the one that matters: the row's negative
1405
+ * witness was RED against a subject that does not carry the rule, so a witness
1406
+ * exiting 0 means the probe discriminates nothing and the row is `unported`.
1407
+ * That derivation is M4-P26's `evaluatePortRow` and is REUSED here rather than
1408
+ * reimplemented; this function supplies the reading, the adaptation and the
1409
+ * screen.
1410
+ */
1411
+ export function evaluateRetirementInventory(inventoryPath, repoRoot) {
1412
+ const read = readRetirementInventory(inventoryPath);
1413
+ if (read.kind === "absent") {
1414
+ return {
1415
+ kind: "refused",
1416
+ reason: `${inventoryPath} is absent, so no retirement criterion could be evaluated`,
1417
+ };
1418
+ }
1419
+ if (read.kind === "refused") {
1420
+ return { kind: "refused", reason: read.reason };
1421
+ }
1422
+ const results = [];
1423
+ for (const raw of read.rows) {
1424
+ const source = typeof raw === "object" && raw !== null
1425
+ ? raw
1426
+ : {};
1427
+ /* Only PORT rows are printed (criterion 6). A KEEP or DELETE row is not a
1428
+ retirement that can be incomplete, and an UNREADABLE disposition is not
1429
+ a KEEP: it goes to `evaluatePortRow`, which names it `unported`. */
1430
+ if (source["disposition"] === "KEEP" || source["disposition"] === "DELETE") {
1431
+ continue;
1432
+ }
1433
+ const adapted = retirementRowFromDocument(raw);
1434
+ if (adapted.kind === "refused") {
1435
+ results.push(adapted.result);
1436
+ continue;
1437
+ }
1438
+ results.push(evaluatePortRow(adapted.row, repoRoot));
1439
+ }
1440
+ return {
1441
+ kind: "read",
1442
+ report: { results, unported: results.filter((r) => r.verdict === "unported").length },
1443
+ };
1444
+ }