@tiphys/kernel 0.0.0 → 0.1.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 (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -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 +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,820 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { setTimeout as sleep } from "node:timers/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
8
+ import { exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
9
+ import { loadSchema, validate, formatDiagnostics } from "./validate.js";
10
+ /**
11
+ * THE RELEASE-VERIFICATION CONTRACT (kernel plan M2, M2-P7 step 3; DR-0014;
12
+ * delivery/verification/release-verification-interface.md).
13
+ *
14
+ * ONE post-merge verification contract with two registry entries (`deploy`
15
+ * and `migrations`). The kernel owns the SUBJECT, the CLOCK, the LOOP and
16
+ * the OUTCOME MAPPING; an adapter owns the platform mapping and nothing
17
+ * else. An adapter is an executable named by committed configuration,
18
+ * spawned as a subprocess with a request file path in argv, performing ONE
19
+ * bounded observation per invocation and writing its response to a
20
+ * kernel-supplied record path. It returns an OUTCOME, never a status path:
21
+ * JSON-pointer extraction exists only inside the http-json adapter's own
22
+ * configuration, and the kernel never learns what a statusPath is.
23
+ *
24
+ * WHY THE BOUNDARY IS A PROCESS (M2-D-07, investigation section 5): it
25
+ * works for any language, and it makes the per-attempt timeout ENFORCEABLE
26
+ * rather than promised. The kernel terminates an attempt that overruns
27
+ * (`spawnSync`'s `timeout` and `killSignal` options) and records that
28
+ * attempt as `error`, so an adapter that hangs cannot hang the kernel.
29
+ *
30
+ * C-2 EXEMPTION, STATED WHERE THE TERMINATION HAPPENS AND NOWHERE ELSE.
31
+ * C-2 forbids pid, process liveness, signals and /proc FOR IDENTITY OR
32
+ * EXCLUSION. The per-attempt termination above is neither: it is a timeout
33
+ * bound on a child this kernel itself spawned and still holds the handle
34
+ * of, it probes nothing, identifies nothing and excludes nothing, and no
35
+ * pid is ever read, recorded or compared. That is the one place any kill
36
+ * may appear in this module or its adapters, and it appears as a spawn
37
+ * option, not as a process.kill call.
38
+ *
39
+ * NO FAILURE VOCABULARY SHIPS (plan step 2, T-003 lesson 4). No
40
+ * non-success platform state was ever captured, so none is named anywhere
41
+ * in this module or its adapters. The safe rule instead: one satisfying
42
+ * value per configured adapter; every other observed value is recorded
43
+ * verbatim and treated as `pending` until the deadline, at which point it
44
+ * becomes red naming the last observed value. Unknown never becomes green.
45
+ *
46
+ * NEVER auto-background (C-3): the loop is a foreground await, every spawn
47
+ * is spawnSync, nothing is detached and nothing is unref'd.
48
+ */
49
+ /** The one declaration path (design decision D-p7-1 in the work history). */
50
+ export const DECLARATION_PATH = "release-verification.json";
51
+ /** The contract versions this kernel accepts (fail-closed rule 6). */
52
+ export const ACCEPTED_CONTRACT_VERSIONS = ["1"];
53
+ export const SUBJECT_FIELDS = [
54
+ "repository",
55
+ "integrationRef",
56
+ "mergedSha",
57
+ "mergedAt",
58
+ "phaseId",
59
+ ];
60
+ export const VERIFICATION_OUTCOMES = [
61
+ "satisfied",
62
+ "failed",
63
+ "pending",
64
+ "absent",
65
+ "not-applicable",
66
+ "error",
67
+ ];
68
+ /**
69
+ * THE TOTAL MAPPING to GateResult status, implemented in exactly one place
70
+ * (plan step 3). `satisfied` is green with `units` 1 per verification
71
+ * satisfied (or the adapter's own examined count, D-p7-3). `pending` and
72
+ * `absent` are NEVER terminal: they are loop states, and the two entries
73
+ * below are reachable only through the deadline conversion in
74
+ * `runVerification`, which turns both into red with TEXTUALLY DISTINCT
75
+ * reasons: `deadline reached, last observed <value>` for pending and
76
+ * `deadline reached, no release object for subject` for absent. An adapter
77
+ * cannot make pending mean pass.
78
+ */
79
+ export const OUTCOME_TO_STATUS = {
80
+ satisfied: "green",
81
+ failed: "red",
82
+ pending: "red",
83
+ absent: "red",
84
+ "not-applicable": "not-applicable",
85
+ error: "error",
86
+ };
87
+ export const DEADLINE_REASON_ABSENT = "deadline reached, no release object for subject";
88
+ export function deadlineReasonPending(lastObserved) {
89
+ return `deadline reached, last observed ${lastObserved}`;
90
+ }
91
+ /** Schema loading, same resolution pattern as src/gates/manifest.ts. */
92
+ const schemaDirectory = new URL("./schemas/", import.meta.url);
93
+ let cachedResponseSchema;
94
+ let cachedDeclarationSchema;
95
+ function readSchemaDocument(name) {
96
+ const path = fileURLToPath(new URL(name, schemaDirectory));
97
+ const read = readRegularFileIfPresent(path);
98
+ if (read.kind !== "read") {
99
+ throw new Error(read.kind === "absent"
100
+ ? `schema document ${path} is missing from this installation`
101
+ : read.reason);
102
+ }
103
+ const loaded = loadSchema(JSON.parse(read.body), name);
104
+ if (!loaded.ok) {
105
+ throw new Error(loaded.reason);
106
+ }
107
+ return loaded.schema;
108
+ }
109
+ export function responseSchema() {
110
+ if (cachedResponseSchema === undefined) {
111
+ cachedResponseSchema = readSchemaDocument("release-record.schema.json");
112
+ }
113
+ return cachedResponseSchema;
114
+ }
115
+ export function declarationSchema() {
116
+ if (cachedDeclarationSchema === undefined) {
117
+ cachedDeclarationSchema = readSchemaDocument("verifier-config.schema.json");
118
+ }
119
+ return cachedDeclarationSchema;
120
+ }
121
+ export function validateAdapterResponse(body, subject) {
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(body);
125
+ }
126
+ catch (error) {
127
+ return {
128
+ ok: false,
129
+ rule: 2,
130
+ reason: `fail-closed rule 2: response does not parse as JSON: ${error.message}`,
131
+ };
132
+ }
133
+ const schemaDiagnostics = validate(responseSchema(), parsed);
134
+ const conditional = conditionalResponseDiagnostics(parsed);
135
+ const diagnostics = formatDiagnostics([...schemaDiagnostics, ...conditional]);
136
+ if (diagnostics.length > 0) {
137
+ return {
138
+ ok: false,
139
+ rule: 2,
140
+ reason: `fail-closed rule 2: response fails schema validation: ${diagnostics.join("; ")}`,
141
+ };
142
+ }
143
+ const response = parsed;
144
+ if (!ACCEPTED_CONTRACT_VERSIONS.includes(response.contractVersion)) {
145
+ return {
146
+ ok: false,
147
+ rule: 6,
148
+ reason: `fail-closed rule 6: contractVersion ${JSON.stringify(response.contractVersion)} ` +
149
+ `is not recognized; this kernel accepts ${ACCEPTED_CONTRACT_VERSIONS.join(", ")}`,
150
+ };
151
+ }
152
+ // Rule 3, BEFORE the outcome is read (field by field, never a serialized
153
+ // comparison, so key order cannot fake a mismatch or hide one).
154
+ for (const field of SUBJECT_FIELDS) {
155
+ if (response.subject[field] !== subject[field]) {
156
+ return {
157
+ ok: false,
158
+ rule: 3,
159
+ reason: `fail-closed rule 3: echoed subject differs from the passed subject at ` +
160
+ `${field}: passed ${JSON.stringify(subject[field])}, ` +
161
+ `echoed ${JSON.stringify(response.subject[field])}; ` +
162
+ `the outcome was not read`,
163
+ };
164
+ }
165
+ }
166
+ if (!VERIFICATION_OUTCOMES.includes(response.outcome)) {
167
+ return {
168
+ ok: false,
169
+ rule: 7,
170
+ reason: `fail-closed rule 7: outcome ${JSON.stringify(response.outcome)} is outside the ` +
171
+ `enum (${VERIFICATION_OUTCOMES.join(", ")}) and is never coerced`,
172
+ };
173
+ }
174
+ if (response.outcome === "satisfied" && response.resolved === undefined) {
175
+ return {
176
+ ok: false,
177
+ rule: 4,
178
+ reason: "fail-closed rule 4: satisfied with no resolved identity; a verifier " +
179
+ "that cannot say what it looked at did not look",
180
+ };
181
+ }
182
+ if (response.outcome === "satisfied" && response.observation === undefined) {
183
+ return {
184
+ ok: false,
185
+ rule: 5,
186
+ reason: "fail-closed rule 5: satisfied with no observation; a claim with no " +
187
+ "captured evidence behind it is treated as unknown",
188
+ };
189
+ }
190
+ return { ok: true, response };
191
+ }
192
+ /**
193
+ * The conditional half of the response schema (reason required for failed,
194
+ * absent, not-applicable and error), checked in code because conditional
195
+ * composition is outside the closed keyword set. Same diagnostic shape as
196
+ * the schema's own, so rule 2's message names the field either way.
197
+ */
198
+ function conditionalResponseDiagnostics(parsed) {
199
+ const found = [];
200
+ if (typeof parsed !== "object" || parsed === null) {
201
+ return found;
202
+ }
203
+ const record = parsed;
204
+ const outcome = record["outcome"];
205
+ const needsReason = outcome === "failed" ||
206
+ outcome === "absent" ||
207
+ outcome === "not-applicable" ||
208
+ outcome === "error";
209
+ if (needsReason && typeof record["reason"] !== "string") {
210
+ found.push({
211
+ pointer: "#/reason",
212
+ message: `required property reason is missing for outcome ${String(outcome)}`,
213
+ });
214
+ }
215
+ if (needsReason && typeof record["reason"] === "string" && record["reason"] === "") {
216
+ found.push({
217
+ pointer: "#/reason",
218
+ message: `required property reason is empty for outcome ${String(outcome)}`,
219
+ });
220
+ }
221
+ return found;
222
+ }
223
+ /**
224
+ * The encoded forms of a credential value the kernel can derive from the
225
+ * value alone (CR-P7H-3). Redacting only the verbatim bytes let a trivially
226
+ * reversible copy through: an adapter emitting the token as a standalone
227
+ * base64 blob (an HTTP Basic `Authorization: Basic <base64(token)>` of the
228
+ * token) leaked a recoverable secret into the stderr evidence. Each form here
229
+ * is a DETERMINISTIC, enumerable transform of the same value, so the set is
230
+ * bounded and cheap.
231
+ *
232
+ * WHAT THIS DOES NOT COVER, stated so a green is auditable rather than
233
+ * silently partial (never soften a work history): forms that fold in bytes
234
+ * the kernel does not hold, e.g. base64 of `"user:" + value` for a full Basic
235
+ * credential PAIR (the username is the project's, not the kernel's), or a
236
+ * value re-encoded by a transport the kernel never sees (gzip, hex, a second
237
+ * base64 round). Those are residue the reference adapters do not produce; a
238
+ * third-party adapter that composes a credential with unknown surrounding
239
+ * bytes owns that redaction, and the guarantee scoped here is the value and
240
+ * its own single-step base64 and percent encodings.
241
+ */
242
+ export function secretForms(value) {
243
+ const forms = new Set([value]);
244
+ forms.add(Buffer.from(value, "utf8").toString("base64"));
245
+ forms.add(encodeURIComponent(value));
246
+ return [...forms].filter((form) => form !== "");
247
+ }
248
+ /**
249
+ * Replace every resolved credential VALUE, and its enumerable encoded forms
250
+ * (see secretForms), with a named placeholder, everywhere in the text.
251
+ */
252
+ export function redactSecrets(text, secrets) {
253
+ let redacted = text;
254
+ for (const secret of secrets) {
255
+ if (secret.value === "") {
256
+ continue;
257
+ }
258
+ const placeholder = `<redacted:${secret.name}>`;
259
+ for (const form of secretForms(secret.value)) {
260
+ redacted = redacted.split(form).join(placeholder);
261
+ }
262
+ }
263
+ return redacted;
264
+ }
265
+ /**
266
+ * THE ONE KERNEL-SIDE WRITE into the evidence directory. The mechanism the
267
+ * request-file write already obeyed, now applied to EVERY write the kernel
268
+ * makes there without exception: establish the path's TYPE before opening it.
269
+ *
270
+ * WHY (CR-P7H-1). A hostile adapter is handed a record path inside the
271
+ * evidence directory, so it can derive and pre-create any deterministic
272
+ * sibling path (the next attempt's stdout, stderr or attempt record). An
273
+ * open-for-write of a FIFO with no reader BLOCKS forever, and the per-attempt
274
+ * `spawnSync` timeout bounds only the CHILD, not these kernel-side writes that
275
+ * happen after it returns. So `refuseOpenForWrite` gates the open: an
276
+ * irregular or unexaminable entry is refused by name and observed type and the
277
+ * caller returns a bounded error, never a blocking `writeFileSync`. Returns
278
+ * undefined on a completed write, or the reason on refusal or a raised write
279
+ * error. This is the single writer; no kernel-side evidence write bypasses it.
280
+ */
281
+ function guardedEvidenceWrite(path, body) {
282
+ const refusal = refuseOpenForWrite(path);
283
+ if (refusal !== undefined) {
284
+ return refusal;
285
+ }
286
+ const wrote = runStep(`writing ${path}`, () => writeFileSync(path, body));
287
+ return wrote.ok ? undefined : wrote.reason;
288
+ }
289
+ /**
290
+ * THE KERNEL-OWNED LOOP. One bounded adapter observation per attempt, one
291
+ * record per attempt, foreground polling (C-3), verdict at the deadline.
292
+ * `pending` and `absent` loop; everything else is terminal, including every
293
+ * fail-closed violation (design decision D-p7-5: a broken adapter cannot
294
+ * reach a verdict, and M2-C-3 makes that `error` now, not red later).
295
+ */
296
+ export async function runVerification(options) {
297
+ const attempts = [];
298
+ const evidence = [];
299
+ const secrets = options.secrets ?? [];
300
+ const startMs = Date.now();
301
+ const deadlineAtMs = startMs + options.clock.deadlineMs;
302
+ const deadlineIso = new Date(deadlineAtMs).toISOString();
303
+ let lastLoopOutcome;
304
+ let lastObservedDetail = "pending";
305
+ let attempt = 0;
306
+ const finish = (verdict) => ({
307
+ verdict,
308
+ attempts,
309
+ evidence,
310
+ });
311
+ for (;;) {
312
+ attempt += 1;
313
+ const at = new Date().toISOString();
314
+ const requestName = `${options.verification}-request-${String(attempt)}.json`;
315
+ const responseName = `${options.verification}-response-${String(attempt)}.json`;
316
+ const attemptName = `${options.verification}-attempt-${String(attempt)}.json`;
317
+ const requestPath = join(options.evidenceDir, requestName);
318
+ const responsePath = join(options.evidenceDir, responseName);
319
+ const request = {
320
+ contractVersion: ACCEPTED_CONTRACT_VERSIONS[0],
321
+ verification: options.verification,
322
+ subject: options.subject,
323
+ config: options.config,
324
+ attempt: { number: attempt, deadline: deadlineIso },
325
+ recordPath: responsePath,
326
+ };
327
+ // M2-C-6 both ways: the request path is probed before writing, and the
328
+ // stale-response clear removes whatever a previous attempt (or a hostile
329
+ // adapter) left at the response path, FIFO included, so the read below
330
+ // never opens an entry this attempt's adapter did not just write.
331
+ const requestRefusal = guardedEvidenceWrite(requestPath, `${JSON.stringify(request, null, 2)}\n`);
332
+ if (requestRefusal !== undefined) {
333
+ return finish({ kind: "error", reason: requestRefusal });
334
+ }
335
+ evidence.push(requestName);
336
+ const cleared = runStep(`clearing ${responsePath}`, () => rmSync(responsePath, { force: true }));
337
+ if (!cleared.ok) {
338
+ return finish({ kind: "error", reason: cleared.reason });
339
+ }
340
+ // The per-attempt bound. The kill signal is 9 (SIGKILL) BY NUMBER, and
341
+ // the number is load-bearing twice over. MEASURED 2026-08-06 (probe in
342
+ // the phase work history): with the default signal, a child that traps
343
+ // SIGTERM makes spawnSync never return, so the timeout would be
344
+ // advisory exactly where it must be enforceable ("an adapter that
345
+ // hangs cannot hang the kernel", plan M2-P7 step 3); with 9 the call
346
+ // returns at the timeout with the child dead. And it is numeric
347
+ // because the delivered M2-P1 structural witness (test/gates.test.ts,
348
+ // "the gate runner uses no pid, process liveness, signals or proc")
349
+ // forbids signal NAMES in any src/gates code, having been written
350
+ // before this phase's plan-mandated bound existed; the C-2 exemption
351
+ // in this module's header is the documented carve-out, and the seam is
352
+ // reported to the orchestrator in the phase work history rather than
353
+ // resolved by editing another phase's test.
354
+ const child = spawnSync(options.adapter[0], [...options.adapter.slice(1), requestPath], {
355
+ encoding: "utf8",
356
+ timeout: options.clock.attemptTimeoutMs,
357
+ killSignal: 9,
358
+ });
359
+ const terminatedByTimeout = child.signal !== null ||
360
+ child.error?.code === "ETIMEDOUT";
361
+ const stdoutName = `${options.verification}-attempt-${String(attempt)}-stdout.txt`;
362
+ const stderrName = `${options.verification}-attempt-${String(attempt)}-stderr.txt`;
363
+ const stdoutRefusal = guardedEvidenceWrite(join(options.evidenceDir, stdoutName), redactSecrets(child.stdout ?? "", secrets));
364
+ if (stdoutRefusal !== undefined) {
365
+ return finish({ kind: "error", reason: stdoutRefusal });
366
+ }
367
+ const stderrRefusal = guardedEvidenceWrite(join(options.evidenceDir, stderrName), redactSecrets(child.stderr ?? "", secrets));
368
+ if (stderrRefusal !== undefined) {
369
+ return finish({ kind: "error", reason: stderrRefusal });
370
+ }
371
+ evidence.push(stdoutName, stderrName);
372
+ const record = {
373
+ attempt,
374
+ at,
375
+ outcome: "error",
376
+ detail: "",
377
+ transport: {
378
+ exitCode: child.status,
379
+ signal: child.signal,
380
+ terminatedByTimeout,
381
+ },
382
+ };
383
+ const writeAttempt = () => {
384
+ attempts.push(record);
385
+ const refusal = guardedEvidenceWrite(join(options.evidenceDir, attemptName), `${JSON.stringify(record, null, 2)}\n`);
386
+ if (refusal !== undefined) {
387
+ return refusal;
388
+ }
389
+ evidence.push(attemptName);
390
+ return undefined;
391
+ };
392
+ // Write the attempt record, then return the given verdict, EXCEPT when the
393
+ // attempt-record path is itself a planted FIFO or other non-regular entry:
394
+ // the guarded write refuses it (no block), and the bounded return names
395
+ // that hazard rather than the terminal verdict it displaced.
396
+ const recordAndReturn = (verdict) => {
397
+ const refusal = writeAttempt();
398
+ if (refusal !== undefined) {
399
+ return finish({ kind: "error", reason: refusal });
400
+ }
401
+ return finish(verdict);
402
+ };
403
+ if (terminatedByTimeout) {
404
+ record.detail =
405
+ `adapter overran the per-attempt timeout of ` +
406
+ `${String(options.clock.attemptTimeoutMs)} ms and was terminated; ` +
407
+ `the attempt is error and the kernel returns`;
408
+ return recordAndReturn({ kind: "error", reason: record.detail });
409
+ }
410
+ if (child.error !== undefined) {
411
+ record.detail = `adapter could not be run: ${singleLine(String(child.error))}`;
412
+ return recordAndReturn({ kind: "error", reason: record.detail });
413
+ }
414
+ const read = readRegularFileIfPresent(responsePath);
415
+ if (read.kind === "absent") {
416
+ record.detail =
417
+ `fail-closed rule 1: adapter exited ${String(child.status)} without ` +
418
+ `writing a response record at ${responsePath}; exit 0 with no ` +
419
+ `response is error, not success`;
420
+ return recordAndReturn({ kind: "error", reason: record.detail });
421
+ }
422
+ if (read.kind === "refused") {
423
+ // M2-C-6: present and not a regular file. Named, never opened.
424
+ record.detail = read.reason;
425
+ return recordAndReturn({ kind: "error", reason: record.detail });
426
+ }
427
+ // Redaction before anything else touches the body: the response was
428
+ // written by another program, and if a credential value leaked into it,
429
+ // the leak must not survive under the evidence directory (criterion 11).
430
+ const redactedBody = redactSecrets(read.body, secrets);
431
+ if (redactedBody !== read.body) {
432
+ // The response path was just read as a regular file, but the rewrite is
433
+ // routed through the one guarded writer too, so no kernel-side evidence
434
+ // write is an exception to the type-before-open rule.
435
+ const rewriteRefusal = guardedEvidenceWrite(responsePath, redactedBody);
436
+ if (rewriteRefusal !== undefined) {
437
+ return finish({ kind: "error", reason: rewriteRefusal });
438
+ }
439
+ }
440
+ evidence.push(responseName);
441
+ const validation = validateAdapterResponse(redactedBody, options.subject);
442
+ if (!validation.ok) {
443
+ record.outcome = "invalid";
444
+ record.detail = validation.reason;
445
+ return recordAndReturn({ kind: "error", reason: validation.reason });
446
+ }
447
+ const response = validation.response;
448
+ record.outcome = response.outcome;
449
+ record.detail =
450
+ response.observation?.detail ?? response.reason ?? response.outcome;
451
+ if (response.resolved !== undefined) {
452
+ record.resolved = response.resolved;
453
+ }
454
+ if (response.transport?.httpStatus !== undefined) {
455
+ record.transport.httpStatus = response.transport.httpStatus;
456
+ }
457
+ const older = releaseObjectOlderThanMerge(response, options.subject);
458
+ if (older !== undefined) {
459
+ record.releaseObjectOlderThanMerge = older;
460
+ }
461
+ const attemptRefusal = writeAttempt();
462
+ if (attemptRefusal !== undefined) {
463
+ return finish({ kind: "error", reason: attemptRefusal });
464
+ }
465
+ if (response.outcome === "satisfied") {
466
+ return finish({
467
+ kind: "satisfied",
468
+ units: response.units ?? 1,
469
+ detail: record.detail,
470
+ resolved: response.resolved,
471
+ });
472
+ }
473
+ if (response.outcome === "failed") {
474
+ return finish({ kind: "failed", reason: response.reason });
475
+ }
476
+ if (response.outcome === "not-applicable") {
477
+ return finish({
478
+ kind: "not-applicable",
479
+ declared: false,
480
+ reason: response.reason,
481
+ preconditionId: response.precondition?.id ?? `${options.verification}-adapter-precondition`,
482
+ evidence: response.precondition?.evidence ?? [],
483
+ });
484
+ }
485
+ if (response.outcome === "error") {
486
+ return finish({ kind: "error", reason: response.reason });
487
+ }
488
+ // pending or absent: the loop states.
489
+ lastLoopOutcome = response.outcome;
490
+ lastObservedDetail = record.detail;
491
+ if (options.clock.maxAttempts !== undefined &&
492
+ attempt >= options.clock.maxAttempts) {
493
+ return finish(deadlineVerdict(lastLoopOutcome, lastObservedDetail, `attempt budget of ${String(options.clock.maxAttempts)} exhausted`));
494
+ }
495
+ const now = Date.now();
496
+ if (now >= deadlineAtMs) {
497
+ return finish(deadlineVerdict(lastLoopOutcome, lastObservedDetail));
498
+ }
499
+ await sleep(Math.min(options.clock.intervalMs, Math.max(0, deadlineAtMs - now)));
500
+ if (Date.now() >= deadlineAtMs) {
501
+ return finish(deadlineVerdict(lastLoopOutcome, lastObservedDetail));
502
+ }
503
+ }
504
+ }
505
+ function deadlineVerdict(lastOutcome, lastObserved, cause) {
506
+ const base = lastOutcome === "absent"
507
+ ? DEADLINE_REASON_ABSENT
508
+ : deadlineReasonPending(lastObserved);
509
+ return {
510
+ kind: "deadline",
511
+ lastOutcome,
512
+ reason: cause === undefined ? base : `${base} (${cause})`,
513
+ };
514
+ }
515
+ /**
516
+ * The older-than-the-merge observation. The adapter may record the release
517
+ * object's creation instant in resolved.createdAt (epoch milliseconds or an
518
+ * ISO-8601 string, platform's choice); if it parses and precedes mergedAt,
519
+ * the attempt record flags it. An observation, never an error (plan step 3;
520
+ * investigation section 8 item 8).
521
+ */
522
+ function releaseObjectOlderThanMerge(response, subject) {
523
+ const created = response.resolved?.createdAt;
524
+ let createdMs;
525
+ if (typeof created === "number" && Number.isFinite(created)) {
526
+ createdMs = created;
527
+ }
528
+ else if (typeof created === "string") {
529
+ const parsed = Date.parse(created);
530
+ if (!Number.isNaN(parsed)) {
531
+ createdMs = parsed;
532
+ }
533
+ }
534
+ if (createdMs === undefined) {
535
+ return undefined;
536
+ }
537
+ const mergedMs = Date.parse(subject.mergedAt);
538
+ if (Number.isNaN(mergedMs)) {
539
+ return undefined;
540
+ }
541
+ return createdMs < mergedMs;
542
+ }
543
+ function git(args, cwd) {
544
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
545
+ if (result.error !== undefined) {
546
+ return { ok: false, status: null, stderr: singleLine(String(result.error)) };
547
+ }
548
+ if (result.status !== 0) {
549
+ return { ok: false, status: result.status, stderr: singleLine(result.stderr ?? "") };
550
+ }
551
+ return { ok: true, stdout: result.stdout ?? "" };
552
+ }
553
+ /**
554
+ * Read the declaration from a COMMITTED ref, never from the working tree
555
+ * (design decision D-p7-2; the anti-widening rule of plan step 7, same as
556
+ * the scope auditor's: a phase must not be able to switch off, inside its
557
+ * own branch, the check that would have caught it). The record carries the
558
+ * declaration blob's sha256 so a reviewer can see which text authorized
559
+ * whatever happened.
560
+ */
561
+ export function loadDeclaration(ref, cwd) {
562
+ const resolved = git(["rev-parse", `${ref}^{commit}`], cwd);
563
+ if (!resolved.ok) {
564
+ return {
565
+ ok: false,
566
+ reason: `cannot resolve ref ${ref}: git exited ${String(resolved.status)}: ${resolved.stderr}`,
567
+ };
568
+ }
569
+ const refSha = resolved.stdout.trim();
570
+ const shown = git(["show", `${refSha}:${DECLARATION_PATH}`], cwd);
571
+ if (!shown.ok) {
572
+ if (/does not exist|exists on disk, but not in/.test(shown.stderr)) {
573
+ return {
574
+ ok: false,
575
+ reason: `no release-verification declaration at ${refSha}:${DECLARATION_PATH}; ` +
576
+ `an absent declaration is error, not a quiet skip: silence is never permission ` +
577
+ `(plan M2-P7 step 7)`,
578
+ };
579
+ }
580
+ return {
581
+ ok: false,
582
+ reason: `git show ${refSha}:${DECLARATION_PATH} exited ${String(shown.status)}: ${shown.stderr}`,
583
+ };
584
+ }
585
+ const body = shown.stdout;
586
+ let parsed;
587
+ try {
588
+ parsed = JSON.parse(body);
589
+ }
590
+ catch (error) {
591
+ return {
592
+ ok: false,
593
+ reason: `${DECLARATION_PATH} at ${refSha} does not parse as JSON: ${error.message}`,
594
+ };
595
+ }
596
+ const diagnostics = formatDiagnostics([
597
+ ...validate(declarationSchema(), parsed),
598
+ ...conditionalDeclarationDiagnostics(parsed),
599
+ ]);
600
+ if (diagnostics.length > 0) {
601
+ return {
602
+ ok: false,
603
+ reason: `${DECLARATION_PATH} at ${refSha} is not a valid declaration: ${diagnostics.join("; ")}`,
604
+ };
605
+ }
606
+ return {
607
+ ok: true,
608
+ declaration: parsed,
609
+ sha256: createHash("sha256").update(body).digest("hex"),
610
+ ref,
611
+ refSha,
612
+ };
613
+ }
614
+ /** Mode-conditional required fields, outside the closed keyword set. */
615
+ function conditionalDeclarationDiagnostics(parsed) {
616
+ const found = [];
617
+ const verifications = parsed?.verifications;
618
+ if (typeof verifications !== "object" || verifications === null) {
619
+ return found;
620
+ }
621
+ for (const [name, value] of Object.entries(verifications)) {
622
+ if (typeof value !== "object" || value === null) {
623
+ continue;
624
+ }
625
+ const entry = value;
626
+ const pointer = (field) => `#/verifications/${name}/${field}`;
627
+ if (entry["mode"] === "none") {
628
+ if (typeof entry["reason"] !== "string" || entry["reason"] === "") {
629
+ found.push({
630
+ pointer: pointer("reason"),
631
+ message: "required property reason is missing or empty for mode none; " +
632
+ "disabling verification costs a reason",
633
+ });
634
+ }
635
+ }
636
+ if (entry["mode"] === "adapter") {
637
+ for (const field of ["adapter", "config", "clock"]) {
638
+ if (entry[field] === undefined) {
639
+ found.push({
640
+ pointer: pointer(field),
641
+ message: `required property ${field} is missing for mode adapter`,
642
+ });
643
+ }
644
+ }
645
+ }
646
+ }
647
+ return found;
648
+ }
649
+ const UNIT_LABELS = {
650
+ deploy: "release verifications satisfied",
651
+ migrations: "migrations compared",
652
+ };
653
+ function usage(name) {
654
+ return (`usage: node src/gates/${name}.ts --result <file> --evidence <dir> ` +
655
+ `[--base <ref>] [--phase <id>]`);
656
+ }
657
+ function parseGateFlags(args) {
658
+ const flags = {};
659
+ const names = ["--result", "--evidence", "--base", "--phase", "--head"];
660
+ for (let i = 0; i < args.length; i += 1) {
661
+ const flag = args[i];
662
+ const value = args[i + 1];
663
+ if (flag === undefined || !names.includes(flag) || value === undefined) {
664
+ return undefined;
665
+ }
666
+ if (flag === "--result") {
667
+ flags.result = value;
668
+ }
669
+ else if (flag === "--evidence") {
670
+ flags.evidence = value;
671
+ }
672
+ else if (flag === "--base") {
673
+ flags.base = value;
674
+ }
675
+ else if (flag === "--phase") {
676
+ flags.phase = value;
677
+ }
678
+ else {
679
+ flags.head = value;
680
+ }
681
+ i += 1;
682
+ }
683
+ return flags;
684
+ }
685
+ function emit(resultPath, result) {
686
+ const refusal = refuseOpenForWrite(resultPath);
687
+ if (refusal !== undefined) {
688
+ process.stderr.write(`${result.gate}: ${refusal}\n`);
689
+ return exitCodeForStatus("error");
690
+ }
691
+ const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
692
+ if (!written.ok) {
693
+ process.stderr.write(`${result.gate}: ${written.reason}\n`);
694
+ return exitCodeForStatus("error");
695
+ }
696
+ process.stdout.write(`${result.gate}: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
697
+ if (result.detail !== "") {
698
+ process.stdout.write(`${result.detail}\n`);
699
+ }
700
+ return exitCodeForStatus(result.status);
701
+ }
702
+ /**
703
+ * THE GATE ENTRY, shared by the two thin entry points. The manifest keeps
704
+ * two static entries exactly as section 1.4 declares them; this function is
705
+ * what each names. Returns the process exit code.
706
+ *
707
+ * The governing ref is `--base` when supplied (the anti-widening read),
708
+ * else HEAD: always a COMMITTED state, so the subject's mergedSha and
709
+ * mergedAt are the resolved commit's, and an uncommitted edit can neither
710
+ * enable, disable nor reconfigure a verification. R-032's blocking half:
711
+ * the verdict record this gate writes is keyed to that sha in its detail
712
+ * line, and the consumption contract is that THE NEXT DISPATCH REQUIRES A
713
+ * GREEN VERDICT RECORD FOR THE MERGED SHA; the enforcement is wired at M4
714
+ * with the pilot (M2-D-11), and M2 adds no dispatch block to spawn.
715
+ */
716
+ export async function runReleaseGate(name, args) {
717
+ const flags = parseGateFlags(args);
718
+ if (flags === undefined || flags.result === undefined || flags.evidence === undefined) {
719
+ process.stderr.write(`${usage(name)}\n`);
720
+ return 64;
721
+ }
722
+ const startedAt = new Date().toISOString();
723
+ const unitLabel = UNIT_LABELS[name];
724
+ const finish = (status, units, detail, precondition, evidence) => emit(flags.result, makeGateResult({
725
+ gate: name,
726
+ status,
727
+ units,
728
+ unitLabel,
729
+ startedAt,
730
+ endedAt: new Date().toISOString(),
731
+ detail,
732
+ precondition,
733
+ evidence,
734
+ }));
735
+ const cwd = process.cwd();
736
+ const ref = flags.base ?? "HEAD";
737
+ const loaded = loadDeclaration(ref, cwd);
738
+ if (!loaded.ok) {
739
+ return finish("error", 0, loaded.reason);
740
+ }
741
+ const provenance = `declaration ${DECLARATION_PATH} read from ${loaded.ref} ` +
742
+ `(${loaded.refSha}), blob sha256 ${loaded.sha256}`;
743
+ const declared = loaded.declaration.verifications[name];
744
+ if (declared === undefined) {
745
+ return finish("error", 0, `${DECLARATION_PATH} at ${loaded.refSha} does not configure verification ` +
746
+ `${name}; an absent configuration field is error, not none: silence is ` +
747
+ `never permission (plan M2-P7 step 7); ${provenance}`);
748
+ }
749
+ if (declared.mode === "none") {
750
+ return finish("not-applicable", 0, `not-applicable by declaration (declared: true): ${declared.reason}; ${provenance}`, {
751
+ id: `${name}-release-verification-declared-none`,
752
+ met: false,
753
+ reason: `declared none: ${declared.reason}`,
754
+ evidence: [
755
+ `declared: true`,
756
+ `declaration: ${DECLARATION_PATH} at ${loaded.refSha}`,
757
+ `blob sha256: ${loaded.sha256}`,
758
+ ],
759
+ });
760
+ }
761
+ // mode adapter. Credentials are NAMES; an unresolvable name is error,
762
+ // never a silent unauthenticated request (plan step 8). The values are
763
+ // used for redaction only and are never written anywhere.
764
+ const secrets = [];
765
+ for (const credential of declared.credentials ?? []) {
766
+ const value = process.env[credential];
767
+ if (value === undefined || value === "") {
768
+ return finish("error", 0, `credential variable ${credential} is declared and not resolvable; a ` +
769
+ `named credential that cannot be resolved is error, never a silent ` +
770
+ `unauthenticated request (plan M2-P7 step 8); ${provenance}`);
771
+ }
772
+ secrets.push({ name: credential, value });
773
+ }
774
+ const mergedAtResult = git(["show", "-s", "--format=%cI", loaded.refSha], cwd);
775
+ if (!mergedAtResult.ok) {
776
+ return finish("error", 0, `cannot read the commit instant of ${loaded.refSha}: ${mergedAtResult.stderr}`);
777
+ }
778
+ const subject = {
779
+ repository: loaded.declaration.repository,
780
+ integrationRef: loaded.declaration.integrationRef,
781
+ mergedSha: loaded.refSha,
782
+ mergedAt: mergedAtResult.stdout.trim(),
783
+ phaseId: flags.phase ?? "unspecified",
784
+ };
785
+ const run = await runVerification({
786
+ verification: name,
787
+ subject,
788
+ adapter: declared.adapter,
789
+ config: declared.config,
790
+ clock: declared.clock,
791
+ evidenceDir: flags.evidence,
792
+ secrets,
793
+ });
794
+ const attemptsNote = `${String(run.attempts.length)} attempt(s)`;
795
+ const verdict = run.verdict;
796
+ if (verdict.kind === "satisfied") {
797
+ return finish("green", verdict.units, `verification ${name} satisfied for subject ${subject.mergedSha}: ` +
798
+ `${verdict.detail}; resolved ${JSON.stringify(verdict.resolved)}; ` +
799
+ `${attemptsNote}; ${provenance}`, undefined, run.evidence);
800
+ }
801
+ if (verdict.kind === "failed") {
802
+ return finish("red", 0, `verification ${name} failed for subject ${subject.mergedSha}: ` +
803
+ `${verdict.reason}; ${attemptsNote}; ${provenance}`, undefined, run.evidence);
804
+ }
805
+ if (verdict.kind === "deadline") {
806
+ return finish("red", 0, `verification ${name} for subject ${subject.mergedSha}: ${verdict.reason}; ` +
807
+ `${attemptsNote}; ${provenance}`, undefined, run.evidence);
808
+ }
809
+ if (verdict.kind === "not-applicable") {
810
+ return finish("not-applicable", 0, `not-applicable by adapter-evaluated precondition (declared: false): ` +
811
+ `${verdict.reason}; ${attemptsNote}; ${provenance}`, {
812
+ id: verdict.preconditionId,
813
+ met: false,
814
+ reason: verdict.reason,
815
+ evidence: [`declared: false`, ...verdict.evidence],
816
+ }, run.evidence);
817
+ }
818
+ return finish("error", 0, `verification ${name} for subject ${subject.mergedSha}: ${verdict.reason}; ` +
819
+ `${attemptsNote}; ${provenance}`, undefined, run.evidence);
820
+ }