@tiphys/kernel 0.1.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 (94) hide show
  1. package/AGENTS.md +56 -4
  2. package/assurance-modes.yaml +23 -2
  3. package/dist/bin/tiphys.js +86 -8
  4. package/dist/src/adapters/load.d.ts +202 -0
  5. package/dist/src/adapters/load.js +440 -0
  6. package/dist/src/brief.js +27 -20
  7. package/dist/src/checks.d.ts +720 -9
  8. package/dist/src/checks.js +1874 -163
  9. package/dist/src/cli.js +11 -0
  10. package/dist/src/commands/brief.js +27 -4
  11. package/dist/src/commands/cutover.d.ts +35 -0
  12. package/dist/src/commands/cutover.js +448 -0
  13. package/dist/src/commands/doctor.d.ts +229 -0
  14. package/dist/src/commands/doctor.js +968 -27
  15. package/dist/src/commands/init.d.ts +3 -3
  16. package/dist/src/commands/init.js +57 -8
  17. package/dist/src/commands/lock.d.ts +33 -0
  18. package/dist/src/commands/lock.js +117 -6
  19. package/dist/src/commands/next.d.ts +130 -0
  20. package/dist/src/commands/next.js +597 -0
  21. package/dist/src/commands/pool.js +12 -1
  22. package/dist/src/commands/resume.d.ts +1 -0
  23. package/dist/src/commands/resume.js +88 -0
  24. package/dist/src/commands/spawn.js +51 -2
  25. package/dist/src/commands/status.d.ts +6 -4
  26. package/dist/src/commands/status.js +6 -4
  27. package/dist/src/commands/sync.d.ts +47 -0
  28. package/dist/src/commands/sync.js +341 -0
  29. package/dist/src/commands/teardown.js +10 -2
  30. package/dist/src/commands/validate.js +70 -0
  31. package/dist/src/cutover.d.ts +584 -0
  32. package/dist/src/cutover.js +1444 -0
  33. package/dist/src/exclusion.d.ts +389 -0
  34. package/dist/src/exclusion.js +843 -0
  35. package/dist/src/exec/env.d.ts +152 -2
  36. package/dist/src/exec/env.js +146 -2
  37. package/dist/src/fleet.d.ts +172 -0
  38. package/dist/src/fleet.js +219 -1
  39. package/dist/src/gates/citations.js +7 -1
  40. package/dist/src/gates/coverage.d.ts +113 -22
  41. package/dist/src/gates/coverage.js +166 -31
  42. package/dist/src/gates/credentials.d.ts +159 -0
  43. package/dist/src/gates/credentials.js +221 -2
  44. package/dist/src/gates/gate-classes.d.ts +56 -0
  45. package/dist/src/gates/gate-classes.js +633 -0
  46. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  47. package/dist/src/gates/merge-preconditions.js +932 -0
  48. package/dist/src/gates/red-witness.js +105 -13
  49. package/dist/src/gates/run.d.ts +49 -1
  50. package/dist/src/gates/run.js +83 -5
  51. package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
  52. package/dist/src/gates/suite.js +48 -7
  53. package/dist/src/hooks.d.ts +55 -3
  54. package/dist/src/hooks.js +69 -6
  55. package/dist/src/index.d.ts +31 -0
  56. package/dist/src/index.js +30 -0
  57. package/dist/src/lock.d.ts +82 -4
  58. package/dist/src/lock.js +314 -22
  59. package/dist/src/model-resolution.d.ts +159 -0
  60. package/dist/src/model-resolution.js +307 -0
  61. package/dist/src/path-identity.d.ts +32 -0
  62. package/dist/src/path-identity.js +38 -0
  63. package/dist/src/pool.d.ts +197 -1
  64. package/dist/src/pool.js +289 -22
  65. package/dist/src/roles.d.ts +31 -0
  66. package/dist/src/roles.js +42 -0
  67. package/dist/src/spawn.d.ts +307 -2
  68. package/dist/src/spawn.js +690 -19
  69. package/dist/src/status.d.ts +27 -2
  70. package/dist/src/status.js +34 -5
  71. package/dist/src/task.d.ts +295 -55
  72. package/dist/src/task.js +125 -123
  73. package/dist/src/teardown.d.ts +7 -0
  74. package/dist/src/teardown.js +120 -12
  75. package/dist/src/validate.d.ts +44 -11
  76. package/dist/src/validate.js +44 -34
  77. package/dist/src/watcher.js +1 -11
  78. package/dist/src/witness/run.d.ts +32 -7
  79. package/dist/src/witness/run.js +76 -30
  80. package/dist/src/witness/spec.d.ts +168 -0
  81. package/dist/src/witness/spec.js +240 -18
  82. package/dist/tsconfig.src.tsbuildinfo +1 -1
  83. package/gate-registry.yaml +136 -0
  84. package/gates.manifest.json +63 -1
  85. package/package.json +18 -3
  86. package/roles/implementer.md +3 -0
  87. package/schemas/README.md +1 -0
  88. package/schemas/assurance-modes.schema.json +1 -1
  89. package/schemas/charter.schema.json +19 -0
  90. package/schemas/cutover-state.schema.json +64 -0
  91. package/schemas/executor-record.schema.json +36 -0
  92. package/schemas/model-resolution.schema.json +362 -0
  93. package/schemas/verdict.schema.json +9 -3
  94. package/schemas/write-bypass.schema.json +69 -0
package/dist/src/spawn.js CHANGED
@@ -1,11 +1,268 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { constants } from "node:os";
4
+ import { BUILT_IN_ADAPTER_NAME, selectAdapter } from "./adapters/load.js";
4
5
  import { assembleBrief } from "./brief.js";
5
- import { buildChildEnv, scrubRoot } from "./exec/env.js";
6
+ import { CREDENTIAL_STORE_REDIRECTIONS, buildChildEnv, extensionName, extensionReason, refuseExtraAllowlist, scrubRoot, } from "./exec/env.js";
7
+ import { guardSharedRegister } from "./exclusion.js";
6
8
  import { writeTurnEndHook } from "./hooks.js";
7
9
  import { poolCreate, poolDestroy, worktreePath } from "./pool.js";
8
- import { checkHoldership, executorRecordPath, metaPath, runStep, taskDir, taskDirExists, taskDirOccupied, writeTaskMeta, } from "./task.js";
10
+ import { checkHoldership, executorRecordPath, metaPath, readRegularFileIfPresent, runStep, runStepAsync, taskDir, taskDirExists, taskDirOccupied, turnEndPath, writeTaskMeta, } from "./task.js";
11
+ /**
12
+ * THE FIELDS AN ADAPTER MAY NAME IN `requires`, AND WHETHER EACH ONE CAN BE
13
+ * ABSENT (M4-P3 criteria 2 and 3).
14
+ *
15
+ * This is the ONE source for both halves of the requirement check, and it is
16
+ * one source on purpose: a list of legal names maintained beside a separate
17
+ * list of presence tests is two things that drift, and the drift is silent in
18
+ * exactly the direction that matters (a new request field nobody can require,
19
+ * or a requirable name nothing can satisfy). `requirableRequestFields` reads
20
+ * its answer back out of this function, so there is nothing to keep in step.
21
+ *
22
+ * The six names set to `true` unconditionally are the ones the KERNEL
23
+ * produces: it has a task id because it was given one, a worktree and a
24
+ * record path because it computed them, a command because it refused an empty
25
+ * one, a hook path because it wrote the hook, and a brief path because
26
+ * `assembleBrief` returned one. None of them can be absent by the time the
27
+ * request is built, and `test/spawn.test.ts` checks that claim against the
28
+ * request an adapter is actually handed rather than leaving it asserted here.
29
+ */
30
+ function requestFieldPresence(options) {
31
+ const presence = new Map();
32
+ for (const name of ["taskId", "worktree", "command", "hookPath", "recordPath", "briefPath"]) {
33
+ presence.set(name, true);
34
+ }
35
+ presence.set("deadlineSeconds", options.deadlineSeconds !== undefined);
36
+ presence.set("env", options.allowPrCredentials !== true);
37
+ presence.set("role", options.role !== undefined);
38
+ presence.set("declaredTier", options.declaredTier !== undefined);
39
+ presence.set("phaseId", options.phaseId !== undefined);
40
+ return presence;
41
+ }
42
+ /** The closed set of names an adapter may name in `requires`, in field order. */
43
+ export function requirableRequestFields() {
44
+ return [
45
+ ...requestFieldPresence({
46
+ taskId: "",
47
+ project: "",
48
+ briefFile: "",
49
+ shape: "ship",
50
+ exec: "",
51
+ deadlineSeconds: undefined,
52
+ offline: false,
53
+ // M4-P8: a REQUIRED SpawnOptions field, and deliberately NOT a
54
+ // requirable request field. `requestFieldPresence` maps names an
55
+ // adapter may declare in `requires`, and every one of them is a field
56
+ // of `ExecutorRequest`; the payload class never crosses that seam, so
57
+ // it appears here only because the literal must typecheck.
58
+ payloadClass: "project",
59
+ role: undefined,
60
+ declaredTier: undefined,
61
+ phaseId: undefined,
62
+ }).keys(),
63
+ ];
64
+ }
65
+ /**
66
+ * THE ADAPTER CONTRACT CHECK (M4-P3 criterion 3): a requirement naming a
67
+ * field that does not exist is a DEFECT IN THE ADAPTER, refused when the
68
+ * adapter is taken up and before anything is created.
69
+ *
70
+ * IT IS A DIFFERENT QUESTION FROM `checkAdapterRequirements` BELOW, AND THE
71
+ * ORDER IS LOAD-BEARING. The presence test there answers "is this field
72
+ * absent", and an unknown key is absent too, so a single check written that
73
+ * way would answer a defect in the adapter with a message about a missing
74
+ * flag: an operator would go looking for a `--modelName` that the kernel has
75
+ * no field for and could never accept. Running this one FIRST is what keeps
76
+ * the two answers distinct, and `test/spawn.test.ts` asserts that they say
77
+ * structurally different things rather than merely both refusing.
78
+ */
79
+ export function checkAdapterContract(adapter) {
80
+ const known = requirableRequestFields();
81
+ // THE DECLARATION IS FOREIGN INPUT, not a value this module produced. From
82
+ // M4-P4 an adapter is resolved from the fleet home, so `requires` arrives
83
+ // from a module the kernel did not write and the TYPE IS A PROMISE, not a
84
+ // guarantee. A missing or non-array declaration reaching `.filter` would be
85
+ // a TypeError raised out of `spawnTask` with no reason line, which is the
86
+ // same "refusal that arrives as a crash" shape the rest of this check
87
+ // exists to prevent, one level lower.
88
+ if (!Array.isArray(adapter.requires)) {
89
+ return {
90
+ ok: false,
91
+ reason: `the ${adapter.name} adapter does not declare requires as an array, so the ` +
92
+ `kernel cannot tell what it needs; an adapter that needs nothing declares []`,
93
+ };
94
+ }
95
+ const unknown = adapter.requires.filter((name) => !known.includes(name));
96
+ if (unknown.length === 0) {
97
+ return { ok: true };
98
+ }
99
+ return {
100
+ ok: false,
101
+ reason: `the ${adapter.name} adapter declares a requirement on ${unknown.join(", ")}, ` +
102
+ `which the executor request contract has no field for; the requirable ` +
103
+ `fields are ${known.join(", ")}`,
104
+ };
105
+ }
106
+ /**
107
+ * THE DECLARED-REQUIREMENT CHECK (M4-P3 criterion 2): a field the adapter
108
+ * declared it needs, and the spawn was not given, is a USAGE ERROR, and a
109
+ * usage error creates nothing.
110
+ *
111
+ * THE DANGEROUS STATE THIS EXISTS FOR is not "an adapter gets undefined". It
112
+ * is an adapter DISCOVERING that it got undefined, and raising, after pool
113
+ * create has already made a worktree, a branch and a pool record. That is the
114
+ * measured `--deadline` defect at src/commands/spawn.ts:81 with a new field:
115
+ * a value the kernel could not represent used to raise inside the adapter,
116
+ * after the creation, and the repair was to refuse at parse time. This is the
117
+ * same repair one field along, and it is the reason the check runs at the top
118
+ * of `spawnTask` rather than beside the launch where the request is built.
119
+ */
120
+ export function checkAdapterRequirements(adapter, options) {
121
+ const presence = requestFieldPresence(options);
122
+ const unmet = adapter.requires.filter((name) => presence.get(name) !== true);
123
+ if (unmet.length === 0) {
124
+ return { ok: true };
125
+ }
126
+ return {
127
+ ok: false,
128
+ reason: `the ${adapter.name} adapter requires ${unmet.join(", ")}, and this spawn ` +
129
+ `supplied no value for ${unmet.length === 1 ? "it" : "them"}; nothing was created`,
130
+ };
131
+ }
132
+ /**
133
+ * THE CREDENTIAL POLICY CHECK (M4-P8 steps 2, 3 and 5), and it runs before
134
+ * ANYTHING is resolved, loaded or created.
135
+ *
136
+ * Three refusals, in this order, and the order is the fail-closed one:
137
+ *
138
+ * 1. an absent or unrecognised `payloadClass`. Checked first so that
139
+ * refusal 2 never has to reason about an unknown value: without this,
140
+ * `payloadClass !== "project"` would read an omission as permission.
141
+ * 2. the declared escape hatch asked for on a PROJECT payload. This is
142
+ * the pairing the phase exists to refuse: `allowPrCredentials` hands
143
+ * the parent environment over UNCHANGED (see ExecutorRequest.env),
144
+ * credentials and all, and M2-P8 criterion 1 wrote it for the
145
+ * orchestrator's own spawns only. Until this phase the option was
146
+ * reachable from the library seam with nothing between it and a
147
+ * project payload.
148
+ * 3. an extension entry the child must not carry, which is the same
149
+ * vocabulary check `buildChildEnv` makes, made EARLIER. The
150
+ * duplication is deliberate and is not two implementations: both call
151
+ * `refuseExtraAllowlist`. Making it here as well is what keeps a
152
+ * rejected widening from costing a worktree, a branch and a pool
153
+ * record, because `buildChildEnv` does not run until after pool
154
+ * create.
155
+ *
156
+ * It returns a reason rather than throwing, because every refusal on this
157
+ * path must be able to say "nothing was created" in the same sentence.
158
+ */
159
+ export function checkCredentialPolicy(options) {
160
+ const declared = ["orchestrator", "project"];
161
+ if (!declared.includes(options.payloadClass)) {
162
+ return {
163
+ ok: false,
164
+ reason: `this spawn declares no payload class (payloadClass was ` +
165
+ `${JSON.stringify(options.payloadClass)}); it is required and has no ` +
166
+ `default, because an omitted class would otherwise take the ` +
167
+ `orchestrator's authority by default: pass one of ` +
168
+ `${declared.join(", ")}; nothing was created`,
169
+ };
170
+ }
171
+ if (options.allowPrCredentials === true && options.payloadClass === "project") {
172
+ return {
173
+ ok: false,
174
+ reason: `allowPrCredentials is the declared escape hatch from the credential ` +
175
+ `scrub and hands the parent environment over unchanged, so it may not ` +
176
+ `be combined with payloadClass "project": a project payload never ` +
177
+ `receives the orchestrator's credentials; nothing was created`,
178
+ };
179
+ }
180
+ // `reason-required`: THIS is the audited route (DR-0039 condition 2, M4-P8
181
+ // criterion 4), and the argument is passed explicitly rather than
182
+ // defaulted, because a default would be the same "absent takes the
183
+ // permissive arm" shape CR-B-002 is.
184
+ const extensionRefusal = refuseExtraAllowlist(options.extraAllowlist ?? [], "reason-required");
185
+ if (extensionRefusal !== undefined) {
186
+ return { ok: false, reason: `${extensionRefusal}; nothing was created` };
187
+ }
188
+ return { ok: true };
189
+ }
190
+ /**
191
+ * Render `redirectionSource` into the refusal sentence. It names the ARTIFACT
192
+ * the values were read from, never a party that observed them: the turn-end
193
+ * record is adapter-reachable (src/hooks.ts), so a phrase like "observed
194
+ * child-side", which is what this said until the DR-0047 sweep fix round,
195
+ * asserts more than the kernel checked (CR-F-CRED-001).
196
+ */
197
+ function redirectionSourcePhrase(source) {
198
+ if (source === "turn-end-record") {
199
+ return "read from the turn-end record, which the kernel generates the hook for and an adapter can also write";
200
+ }
201
+ if (source === "adapter") {
202
+ return "read from the adapter's own launch report";
203
+ }
204
+ return "read from no pointer evidence at all";
205
+ }
206
+ /**
207
+ * COMPARE THE HANDOVER (M4-P8 criterion 6, repaired for CR-B-001).
208
+ *
209
+ * `handed` is what the kernel built and passed, `reported` is the name set
210
+ * the adapter says it launched with, `pointers` is what the five
211
+ * CREDENTIAL_STORE_REDIRECTIONS actually were where the payload ran.
212
+ *
213
+ * TWO PROPERTIES, AND THE STATUS SAYS WHICH WERE CHECKED. The name-set
214
+ * comparison alone used to be written down as `compared`, which an operator
215
+ * reads as "the handover was verified"; an adapter that keeps the name set
216
+ * byte-identical and puts `HOME` and `XDG_CONFIG_HOME` back to their real
217
+ * paths defeats the M2R-004 defense entirely and was recorded as clean. See
218
+ * `CredentialHandoverRecord` for the five status values and for why the
219
+ * VALUES are still never written into the record.
220
+ *
221
+ * The pointer comparison is by value and the values are DISCARDED: only the
222
+ * names that differ survive into `changedRedirections`. A name the kernel
223
+ * never handed over is not compared, because there is no handed value to
224
+ * compare it against, and the name-set arms are what speak to that case.
225
+ */
226
+ export function compareHandover(handed, reported, pointers) {
227
+ if (handed === undefined) {
228
+ return {
229
+ status: "not-applicable",
230
+ added: [],
231
+ removed: [],
232
+ changedRedirections: [],
233
+ };
234
+ }
235
+ const changedRedirections = pointers === undefined
236
+ ? []
237
+ : CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name)
238
+ .filter((name) => {
239
+ const handedValue = handed[name];
240
+ if (handedValue === undefined) {
241
+ return false;
242
+ }
243
+ return pointers.values[name] !== handedValue;
244
+ })
245
+ .sort();
246
+ const pointerPart = pointers === undefined ? {} : { redirectionSource: pointers.source };
247
+ if (reported === undefined) {
248
+ return {
249
+ status: pointers === undefined ? "unreported" : "pointers-compared",
250
+ added: [],
251
+ removed: [],
252
+ changedRedirections,
253
+ ...pointerPart,
254
+ };
255
+ }
256
+ const handedNames = new Set(Object.keys(handed));
257
+ const reportedNames = new Set(reported);
258
+ return {
259
+ status: pointers === undefined ? "names-compared" : "compared",
260
+ added: [...reportedNames].filter((name) => !handedNames.has(name)).sort(),
261
+ removed: [...handedNames].filter((name) => !reportedNames.has(name)).sort(),
262
+ changedRedirections,
263
+ ...pointerPart,
264
+ };
265
+ }
9
266
  /**
10
267
  * A payload terminated by a signal has no exit code. The shell's
11
268
  * convention (128 + signal number) is used so the turn-end record always
@@ -28,22 +285,55 @@ function payloadExitCode(status, signal) {
28
285
  * auto-backgrounds anything (plan constraint C-3, FM-054), so there is
29
286
  * no daemonize path here to forget to guard.
30
287
  *
288
+ * `async` since M4-P2, and its BODY IS UNCHANGED: every statement below
289
+ * is still synchronous, `spawnSync` is still what runs both children, and
290
+ * the promise this now returns is already settled by the time the first
291
+ * `await` on it runs. The keyword is there because the INTERFACE is async
292
+ * for the adapters that need it, not because this adapter gained a
293
+ * concurrency path to get wrong.
294
+ *
31
295
  * It runs without a shell on purpose. Under a shell a missing payload
32
296
  * binary arrives as an ordinary exit code 127, indistinguishable from a
33
297
  * payload that ran and failed, and spawn's rollback rule turns on
34
298
  * exactly that distinction.
35
299
  */
36
300
  export const subprocessAdapter = {
37
- name: "subprocess",
38
- launch(request) {
301
+ /*
302
+ * THE NAME IS THE CONSTANT, not a second copy of the string (M4-P4
303
+ * criterion 5). The loader refuses a loaded adapter that claims this name,
304
+ * and a refusal compared against a literal spelled out in another file is
305
+ * a guard that goes quiet the day one of the two is edited.
306
+ */
307
+ name: BUILT_IN_ADAPTER_NAME,
308
+ /*
309
+ * NOTHING, and that is a statement rather than a default (M4-P3). This
310
+ * adapter runs a command in a directory; it reads no brief, plays no role
311
+ * and asks for no tier, so declaring anything here would be a requirement
312
+ * the adapter does not have. The empty declaration is what makes the
313
+ * refusal path exercisable ONLY by an adapter that genuinely needs
314
+ * something, which is the state the check exists for.
315
+ */
316
+ requires: [],
317
+ async launch(request) {
39
318
  const launchedAt = new Date();
40
319
  const record = {
41
- adapter: "subprocess",
320
+ adapter: BUILT_IN_ADAPTER_NAME,
42
321
  launchedAt: launchedAt.toISOString(),
43
322
  };
44
323
  if (request.deadlineSeconds !== undefined) {
45
324
  record.deadline = new Date(launchedAt.getTime() + request.deadlineSeconds * 1000).toISOString();
46
325
  }
326
+ // VERBATIM (M4-P3 criterion 6): the value the caller supplied, byte for
327
+ // byte, with no normalisation, no lowercasing and no vocabulary check.
328
+ // The kernel holds no tier vocabulary and no role vocabulary, so there is
329
+ // nothing here it could legitimately validate against; a kernel that
330
+ // "tidied" either value would be holding one.
331
+ if (request.declaredTier !== undefined) {
332
+ record.requestedTier = request.declaredTier;
333
+ }
334
+ if (request.role !== undefined) {
335
+ record.requestedRole = request.role;
336
+ }
47
337
  // The record write happens BEFORE the payload, so a failure here is
48
338
  // provably a launch failure and is safe to roll back. Everything
49
339
  // after the payload starts is reported as incomplete instead, which
@@ -103,9 +393,119 @@ export const subprocessAdapter = {
103
393
  `directory are left in place`,
104
394
  };
105
395
  }
106
- return { kind: "completed", exitCode };
396
+ // WHAT THIS ADAPTER ACTUALLY LAUNCHED WITH (M4-P8 criterion 6), read
397
+ // off `request.env` at the point of report rather than recomputed from
398
+ // the option object: the two spawnSync calls above spread that same
399
+ // value, so a mutation between the handover and the launch shows up
400
+ // here. Sorted so the comparison is over a set, not an insertion order.
401
+ return {
402
+ kind: "completed",
403
+ exitCode,
404
+ ...(request.env === undefined
405
+ ? {}
406
+ : {
407
+ launchedEnvNames: Object.keys(request.env).sort(),
408
+ // The five pointers as this adapter launched them, read off the
409
+ // same `request.env` the two spawnSync calls above spread
410
+ // (CR-B-001). The kernel treats this as the WEAKER of its two
411
+ // pointer sources and prefers the turn-end hook's child-written
412
+ // observation; reporting it anyway means an adapter that cannot
413
+ // run the hook still has something to be compared against.
414
+ launchedRedirections: Object.fromEntries(CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => [
415
+ redirection.name,
416
+ request.env?.[redirection.name] ?? null,
417
+ ])),
418
+ }),
419
+ };
107
420
  },
108
421
  };
422
+ /**
423
+ * THE COMPLETION PRECONDITION (M4-P2 step 5).
424
+ *
425
+ * `completed` used to be believed because the only adapter that could
426
+ * return it was the one three lines above, which invokes the turn-end hook
427
+ * itself before returning. Once `launch` is a promise that is no longer
428
+ * true: any adapter may resolve `completed` while its agent is still
429
+ * running, and `spawnTask` would then delete the scrub root out from under
430
+ * a LIVE child's HOME and report success.
431
+ *
432
+ * So the kernel stops taking the adapter's word and reads the artifact the
433
+ * payload's own exit produces. tasks/<id>/turn-end is written by the
434
+ * generated hook (src/hooks.ts:57) with the payload's exit code; it is the
435
+ * same file the watcher wakes on. A `completed` with no readable turn-end
436
+ * record is refused.
437
+ *
438
+ * FOUR distinct refusals, not one, and the distinction is the point. A
439
+ * check written as `existsSync` is green on a present-but-corrupt record,
440
+ * which is a guard whose condition does not test the property that matters.
441
+ * Absent, unreadable, unparseable and wrongly-shaped are all "this is not
442
+ * evidence that the payload ended", and each says which one it was.
443
+ *
444
+ * The read goes through `readRegularFileIfPresent` rather than
445
+ * `readFileSync` so a FIFO at the turn-end path is a refusal and not a
446
+ * hang: this is the same hazard CR-520 records for meta.json, one path
447
+ * along.
448
+ *
449
+ * WHAT THIS DOES NOT DO: it never rolls anything back and it never removes
450
+ * anything. A refusal here is reported with the residue enumerated, exactly
451
+ * like the `incomplete` arm, because the payload demonstrably ran far
452
+ * enough for an adapter to claim it finished.
453
+ */
454
+ function turnEndEvidence(fleet, taskId) {
455
+ const path = turnEndPath(fleet, taskId);
456
+ const read = readRegularFileIfPresent(path);
457
+ if (read.kind === "absent") {
458
+ return { ok: false, reason: `the turn-end record ${path} was never written` };
459
+ }
460
+ if (read.kind === "refused") {
461
+ return {
462
+ ok: false,
463
+ reason: `the turn-end record ${path} could not be read (${read.reason})`,
464
+ };
465
+ }
466
+ let parsed;
467
+ try {
468
+ parsed = JSON.parse(read.body);
469
+ }
470
+ catch (error) {
471
+ const detail = error instanceof Error ? error.message : String(error);
472
+ return {
473
+ ok: false,
474
+ reason: `the turn-end record ${path} does not parse as JSON (${detail})`,
475
+ };
476
+ }
477
+ if (typeof parsed !== "object" || parsed === null) {
478
+ return {
479
+ ok: false,
480
+ reason: `the turn-end record ${path} does not parse as a turn-end record`,
481
+ };
482
+ }
483
+ const candidate = parsed;
484
+ if (typeof candidate.endedAt !== "string" || !Number.isInteger(candidate.exitCode)) {
485
+ return {
486
+ ok: false,
487
+ reason: `the turn-end record ${path} does not parse as a turn-end record ` +
488
+ `(it needs a string endedAt and an integer exitCode)`,
489
+ };
490
+ }
491
+ // THE CHILD-WRITTEN POINTER OBSERVATION (CR-B-001). Absent on a record
492
+ // written by an older hook, and the ABSENCE IS NOT A PASS: the caller turns
493
+ // it into a weaker status word (`names-compared`) rather than into silence,
494
+ // which is the whole lesson of the finding one file over. A malformed or
495
+ // partially-typed `env` is read entry by entry and anything that is neither
496
+ // a string nor null is dropped, so a hostile record cannot smuggle an
497
+ // object into the comparison.
498
+ let observed;
499
+ if (typeof candidate.env === "object" && candidate.env !== null) {
500
+ observed = {};
501
+ for (const [name, value] of Object.entries(candidate.env)) {
502
+ if (typeof value === "string" || value === null) {
503
+ observed[name] = value;
504
+ }
505
+ }
506
+ }
507
+ return observed === undefined ? { ok: true } : { ok: true, observed };
508
+ }
109
509
  /**
110
510
  * Liveness-guard seam (plan step 4). The guard itself is M1-P5 work; in
111
511
  * this phase it is a documented no-op, so that P5 adds a body here
@@ -136,6 +536,59 @@ export function parseExecCommand(command) {
136
536
  */
137
537
  export async function spawnTask(fleet, options) {
138
538
  const { taskId } = options;
539
+ // THE CREDENTIAL POLICY IS CHECKED BEFORE THE ADAPTER IS EVEN RESOLVED
540
+ // (M4-P8 step 5). Adapter selection reads the fleet home and may EVALUATE
541
+ // a module the kernel did not write (M4-P4), so a spawn whose credential
542
+ // shape is already refused must not get that far: the refusal creates
543
+ // nothing, loads nothing and runs nothing.
544
+ const credentials = checkCredentialPolicy(options);
545
+ if (!credentials.ok) {
546
+ return { ok: false, reason: credentials.reason };
547
+ }
548
+ // THE ADAPTER IS RESOLVED FIRST (M4-P3), earlier than it used to be, and
549
+ // the move is the point rather than a tidy-up: both checks below must
550
+ // refuse before ANYTHING is created, and until this phase the adapter was
551
+ // not named until the launch call site, which is after pool create has made
552
+ // a worktree, a branch and a pool record.
553
+ //
554
+ // M4-P4 PUTS THE LOAD IN THAT SAME WINDOW, and for the same reason one
555
+ // level out. Resolving, evaluating and shape-checking a module the kernel
556
+ // did not write are three more ways to discover that this spawn cannot
557
+ // happen, and every one of them must happen while there is still nothing to
558
+ // roll back: a malformed adapter found after pool create is criterion 4's
559
+ // dangerous state, which is M4-P3's refusal-after-creation shape with a
560
+ // different cause.
561
+ //
562
+ // A DIRECTLY SUPPLIED ADAPTER SHORT-CIRCUITS THE WHOLE SELECTION, including
563
+ // the fleet-home read. The kernel's own tests hand an object across this
564
+ // seam, and a test fleet that happened to declare a default would otherwise
565
+ // silently change which adapter those tests exercised.
566
+ let adapter;
567
+ if (options.adapter !== undefined) {
568
+ adapter = options.adapter;
569
+ }
570
+ else {
571
+ const selection = await selectAdapter(fleet, options.adapterSpecifier);
572
+ if (!selection.ok) {
573
+ return { ok: false, reason: selection.reason };
574
+ }
575
+ adapter = selection.adapter ?? subprocessAdapter;
576
+ }
577
+ // A requirement naming a field the contract does not have is a defect in
578
+ // the adapter, refused as the adapter is taken up (criterion 3). It runs
579
+ // BEFORE the presence check because an unknown name is also an absent one,
580
+ // and answering a contract defect with a message about a missing value
581
+ // sends the operator looking for a flag that cannot exist.
582
+ const contract = checkAdapterContract(adapter);
583
+ if (!contract.ok) {
584
+ return { ok: false, reason: contract.reason };
585
+ }
586
+ // A declared requirement the spawn cannot meet is a usage error, and a
587
+ // usage error creates nothing (criterion 2).
588
+ const requirements = checkAdapterRequirements(adapter, options);
589
+ if (!requirements.ok) {
590
+ return { ok: false, reason: requirements.reason };
591
+ }
139
592
  const liveness = livenessGuard(fleet);
140
593
  if (!liveness.ok) {
141
594
  return { ok: false, reason: liveness.reason };
@@ -144,6 +597,24 @@ export async function spawnTask(fleet, options) {
144
597
  if (!holdership.ok) {
145
598
  return { ok: false, reason: holdership.reason };
146
599
  }
600
+ /* THE CROSS-ENVIRONMENT HALF OF THE SAME GUARD (M4-P22 criterion 2), and
601
+ it runs here rather than inside `checkHoldership` because the two answer
602
+ different questions and one of them spawns git. `checkHoldership` above
603
+ has ALREADY RETURNED OK in the state this refuses: the local lease is
604
+ held by this environment, with TIPHYS_HOLDER_ID matching, while the
605
+ shared register names another environment. That is precisely M4-P20's
606
+ measured dangerous state, two clones of one fleet remote each holding a
607
+ live lease, and the local check has no evidence of it because `state/`
608
+ is gitignored so the lease artifact never travels (src/fleet.ts:29).
609
+
610
+ IT IS BEFORE pool create, before the task directory, before the brief and
611
+ before any executor, so the refusal creates nothing, which is the same
612
+ ordering rule the adapter and id-reuse refusals above follow. A fleet
613
+ with no declaration gets `off` before any subprocess is spawned. */
614
+ const sharedGuard = guardSharedRegister(fleet.root, "spawn");
615
+ if (sharedGuard.kind === "refused") {
616
+ return { ok: false, reason: sharedGuard.reason };
617
+ }
147
618
  const command = parseExecCommand(options.exec);
148
619
  if (command.length === 0) {
149
620
  return { ok: false, reason: "--exec is empty" };
@@ -237,7 +708,32 @@ export async function spawnTask(fleet, options) {
237
708
  if (!brief.value.ok) {
238
709
  return rollback(brief.value.reason);
239
710
  }
240
- createdFiles.push(brief.value.value);
711
+ const briefPath = brief.value.value;
712
+ createdFiles.push(briefPath);
713
+ // THE CREDENTIAL DECISION, WRITTEN DOWN (M4-P8 step 6). The hazard this
714
+ // phase names is a credential reaching a project payload with NO ARTIFACT
715
+ // SAYING SO, so the record is written on every spawn, including the
716
+ // boring one where nothing was widened and the scrub ran. The extensions
717
+ // are copied verbatim, reason and all: a widening whose justification
718
+ // exists only in the caller's source is not auditable from the task
719
+ // directory an operator opens.
720
+ const credentialRecord = {
721
+ payloadClass: options.payloadClass,
722
+ scrubMode: options.allowPrCredentials === true ? "inherited" : "scrubbed",
723
+ // BUILT THROUGH THE ACCESSORS (CR-B-002, the record half). Reading
724
+ // `entry.name` directly produced the literal record `{}` for a
725
+ // bare-string entry, so meta.json said a widening happened and not WHICH
726
+ // name was widened. `extensionReason` returning undefined is recorded as
727
+ // an ABSENT key rather than defaulted to "", because a blank reason and a
728
+ // missing one are different facts and the audited route refuses both.
729
+ extensions: (options.extraAllowlist ?? []).map((entry) => {
730
+ const reason = extensionReason(entry);
731
+ return {
732
+ name: extensionName(entry),
733
+ ...(typeof reason === "string" ? { reason } : {}),
734
+ };
735
+ }),
736
+ };
241
737
  const meta = {
242
738
  id: taskId,
243
739
  project: poolRecord.project,
@@ -248,6 +744,7 @@ export async function spawnTask(fleet, options) {
248
744
  baseOffline: poolRecord.offline,
249
745
  status: "open",
250
746
  createdAt: new Date().toISOString(),
747
+ credentials: credentialRecord,
251
748
  };
252
749
  const wroteMeta = runStep(`writing ${metaPath(fleet, taskId)}`, () => {
253
750
  writeTaskMeta(fleet, meta);
@@ -256,7 +753,13 @@ export async function spawnTask(fleet, options) {
256
753
  return rollback(wroteMeta.reason);
257
754
  }
258
755
  createdFiles.push(metaPath(fleet, taskId));
259
- const hook = runStep(`writing the turn-end hook for task ${taskId}`, () => writeTurnEndHook(fleet, taskId));
756
+ // THE HOOK RECORDS THE FIVE POINTERS FROM INSIDE THE CHILD (CR-B-001).
757
+ // Passed on every spawn, including under the declared escape hatch, where
758
+ // the kernel handed no environment over and so compares nothing: a hook
759
+ // whose shape depended on the escape hatch would be one more thing that
760
+ // differs between the two arms, and T-009's lesson is that the arm nobody
761
+ // witnesses is the one that breaks.
762
+ const hook = runStep(`writing the turn-end hook for task ${taskId}`, () => writeTurnEndHook(fleet, taskId, CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name)));
260
763
  if (!hook.ok) {
261
764
  return rollback(hook.reason);
262
765
  }
@@ -271,7 +774,15 @@ export async function spawnTask(fleet, options) {
271
774
  // child.
272
775
  let childEnv;
273
776
  if (options.allowPrCredentials !== true) {
274
- const built = runStep(`constructing the scrubbed child environment for task ${taskId}`, () => buildChildEnv({ parentEnv: process.env, scrubDir: scrubRoot(dir) }));
777
+ const built = runStep(`constructing the scrubbed child environment for task ${taskId}`, () => buildChildEnv({
778
+ parentEnv: process.env,
779
+ scrubDir: scrubRoot(dir),
780
+ // M4-P8 step 3: the per-invocation extension finally has a way to
781
+ // reach this call. Before this phase `extraAllowlist` existed as
782
+ // data and NOTHING passed one, so the field was unreachable from
783
+ // every production path.
784
+ extraAllowlist: options.extraAllowlist ?? [],
785
+ }));
275
786
  if (!built.ok) {
276
787
  return rollback(built.reason);
277
788
  }
@@ -280,8 +791,14 @@ export async function spawnTask(fleet, options) {
280
791
  }
281
792
  childEnv = built.value.env;
282
793
  }
283
- const adapter = options.adapter ?? subprocessAdapter;
284
- const launched = runStep(`launching the payload through the ${adapter.name} adapter`, () => adapter.launch({
794
+ // AWAITED (M4-P2 step 4), and `runStepAsync` rather than `runStep` is
795
+ // load-bearing rather than cosmetic. `runStep` over a promise-returning
796
+ // callback returns {ok: true, value: <a pending promise>} before the
797
+ // adapter has done anything: the launch-failed arm below would never be
798
+ // reached, `outcome.kind` would be undefined on every launch, and a
799
+ // rejection would leave the result type entirely as an unhandled
800
+ // rejection with no handler to roll back or refuse.
801
+ const launched = await runStepAsync(`launching the payload through the ${adapter.name} adapter`, async () => adapter.launch({
285
802
  taskId,
286
803
  worktree,
287
804
  command,
@@ -289,12 +806,27 @@ export async function spawnTask(fleet, options) {
289
806
  recordPath,
290
807
  deadlineSeconds: options.deadlineSeconds,
291
808
  env: childEnv,
809
+ // THE BRIEF THE ADAPTER LAUNCHES AGAINST (M4-P3 criterion 1). The
810
+ // path `assembleBrief` returned, which is already on `createdFiles`
811
+ // and so is already inside the rollback window.
812
+ briefPath,
813
+ role: options.role,
814
+ declaredTier: options.declaredTier,
815
+ phaseId: options.phaseId,
292
816
  }));
293
817
  if (!launched.ok) {
294
818
  // An adapter that THREW rather than returning an outcome cannot tell
295
819
  // us whether the payload started, and this rollback destroys a
296
820
  // worktree. Refusing to guess is the whole lesson of V-1: the state
297
821
  // is left in place and enumerated instead.
822
+ //
823
+ // Since M4-P2 this arm also covers a REJECTED promise, and it covers
824
+ // it for the same reason and with the same words: a rejection is an
825
+ // adapter failing to report, and WHEN it rejects tells us nothing,
826
+ // because an adapter that rejects before the payload starts and one
827
+ // that rejects after it dies are indistinguishable from here. An
828
+ // adapter that actually knows the payload never started says so, by
829
+ // RETURNING launch-failed, and that arm rolls back.
298
830
  return {
299
831
  ok: false,
300
832
  reason: `${launched.reason}; the ${adapter.name} adapter did not report whether the ` +
@@ -307,18 +839,157 @@ export async function spawnTask(fleet, options) {
307
839
  if (outcome.kind === "launch-failed") {
308
840
  return rollback(`executor launch failed: ${outcome.reason}`);
309
841
  }
842
+ // FROM HERE THE PAYLOAD HAS RUN, ON BOTH REMAINING ARMS (CR-B-003).
843
+ //
844
+ // `launch-failed` returned above and is the only arm where nothing ran.
845
+ // `completed` and `incomplete` both mean a child was launched with an
846
+ // environment, so "which environment did it actually get" is a real
847
+ // question on both, and until this round it was asked on neither the
848
+ // `incomplete` arm nor the arm where the completion precondition fails:
849
+ // the comparison sat below all three returns and `meta.json` carried no
850
+ // `handover` key at all. A widening on those arms was recorded NOWHERE
851
+ // while the same widening on the `completed` arm was recorded and refused.
852
+ // So the comparison moves up to here, ahead of every arm-specific return,
853
+ // and `meta.json` is rewritten before any of them.
854
+ //
855
+ // The turn-end record is read first because it carries the child-written
856
+ // pointer observation, and it is read on the `incomplete` arm too: an
857
+ // adapter that reports incomplete because the HOOK failed leaves no record
858
+ // and the read simply finds nothing, which is a weaker status word and not
859
+ // a pass.
860
+ const evidence = turnEndEvidence(fleet, taskId);
861
+ const pointers = (() => {
862
+ if (childEnv === undefined) {
863
+ return undefined;
864
+ }
865
+ if (evidence.ok && evidence.observed !== undefined) {
866
+ return { source: "turn-end-record", values: evidence.observed };
867
+ }
868
+ if (outcome.launchedRedirections !== undefined) {
869
+ return { source: "adapter", values: outcome.launchedRedirections };
870
+ }
871
+ return undefined;
872
+ })();
873
+ // THE HANDOVER CHECK (M4-P8 criterion 6, repaired by CR-B-001).
874
+ // src/spawn.ts's request contract has always FORBIDDEN an adapter widening
875
+ // `env` on its own, in prose, and nothing checked it. The adapter reports
876
+ // the name set it launched with and the kernel compares it; SINCE THIS
877
+ // ROUND the kernel also compares the five credential-store pointers, which
878
+ // is the half a name-set comparison structurally cannot see, because the
879
+ // M2R-004 defense works by redirecting those names rather than by dropping
880
+ // them.
881
+ //
882
+ // IT RUNS AFTER THE PAYLOAD HAS RUN, so it never rolls anything back: the
883
+ // worktree may hold real work by now (the V-1 rule), and the refusal's
884
+ // job is to make the widening impossible to miss, not to destroy
885
+ // evidence of it. The comparison is recorded in meta.json whichever way
886
+ // it goes, so a clean handover is an artifact too.
887
+ const handover = compareHandover(childEnv, outcome.launchedEnvNames, pointers);
888
+ credentialRecord.handover = handover;
889
+ const widened = handover.added.length > 0 ||
890
+ handover.removed.length > 0 ||
891
+ handover.changedRedirections.length > 0;
892
+ if (widened) {
893
+ credentialRecord.refusal =
894
+ `the ${adapter.name} adapter launched with an environment that differs ` +
895
+ `from the one the kernel handed it` +
896
+ (handover.added.length === 0 ? "" : `; added ${handover.added.join(", ")}`) +
897
+ (handover.removed.length === 0 ? "" : `; removed ${handover.removed.join(", ")}`) +
898
+ (handover.changedRedirections.length === 0
899
+ ? ""
900
+ : `; the credential-store pointer(s) ` +
901
+ `${handover.changedRedirections.join(", ")} did not have the ` +
902
+ `harness-owned value the kernel handed over, ` +
903
+ `${redirectionSourcePhrase(handover.redirectionSource)}`);
904
+ }
905
+ const rewroteMeta = runStep(`updating ${metaPath(fleet, taskId)}`, () => {
906
+ writeTaskMeta(fleet, meta);
907
+ });
908
+ if (!rewroteMeta.ok) {
909
+ // The payload ran, so nothing is rolled back here either; the record
910
+ // simply could not be completed and says so rather than being silently
911
+ // left at its pre-launch contents.
912
+ return {
913
+ ok: false,
914
+ reason: `the payload ran (the ${adapter.name} adapter reported ${outcome.kind}) ` +
915
+ `but the credential record could not be completed ` +
916
+ `(${rewroteMeta.reason}); the worktree ${worktree}, its task directory ` +
917
+ `and the pool record are left in place`,
918
+ };
919
+ }
310
920
  if (outcome.kind === "incomplete") {
311
921
  // The payload ran, so nothing is rolled back, and the reason says so.
312
922
  // The scrub root is deliberately LEFT in place here: the hook child
313
923
  // failed, and whatever the children left under the redirected paths
314
- // is part of the state an operator inspects.
315
- return { ok: false, reason: outcome.reason };
316
- }
317
- // The scrub root is ephemeral. Both children have exited (the launch is
318
- // synchronous, C-3), so the harness-owned redirect targets have no
319
- // further reader; removing them returns the task directory to its
320
- // documented records-only shape. This removal touches ONLY the scrub
321
- // root, never the worktree, so it cannot be a V-1-shaped loss.
924
+ // is part of the state an operator inspects. The handover verdict is
925
+ // now part of the reason as well as of the record, because this arm's
926
+ // reason is the only thing many callers read.
927
+ return {
928
+ ok: false,
929
+ reason: widened
930
+ ? `${outcome.reason}; and ${credentialRecord.refusal ?? ""}, recorded in ` +
931
+ `${metaPath(fleet, taskId)}`
932
+ : outcome.reason,
933
+ };
934
+ }
935
+ // THE COMPLETION PRECONDITION (M4-P2 step 5). The only arm left is
936
+ // `completed`, and it is the only arm that DESTROYS something (the
937
+ // scrub root, which is a live child's redirected HOME while that child
938
+ // lives). Before M4-P2 the destruction was safe because `launch` was
939
+ // synchronous and the sole adapter wrote the turn-end record itself; an
940
+ // async `launch` lets any adapter resolve `completed` early, so the
941
+ // kernel checks the payload's own artifact instead of believing the
942
+ // report. See turnEndEvidence above for the four refusals.
943
+ //
944
+ // THE READ ITSELF MOVED UP (CR-B-003): `evidence` is computed before the
945
+ // handover comparison, because the turn-end record is where the child's
946
+ // own pointer observation lives. Only the REFUSAL is here, so this arm
947
+ // still refuses exactly what it refused, and it now does so with the
948
+ // handover already written down.
949
+ if (!evidence.ok) {
950
+ // The scrub root is named only when there IS one. Under the declared
951
+ // escape hatch childEnv is undefined and nothing was ever staged under
952
+ // scrubRoot(dir), so naming it would enumerate a path that does not
953
+ // exist, in the one message an operator uses to find the residue.
954
+ const residue = childEnv === undefined
955
+ ? `the worktree ${worktree}, its task directory and the pool record are all`
956
+ : `the worktree ${worktree}, its task directory, the pool record and the ` +
957
+ `harness-owned redirect targets under ${scrubRoot(dir)} are all`;
958
+ return {
959
+ ok: false,
960
+ reason: `the ${adapter.name} adapter reported the payload completed with exit code ` +
961
+ `${String(outcome.exitCode)}, but ${evidence.reason}, so the kernel does not ` +
962
+ `accept that the payload ended; nothing was rolled back and nothing was ` +
963
+ `removed: ${residue} left in place for inspection; when you have inspected ` +
964
+ `them, close the task with "tiphys teardown --task ${taskId}"` +
965
+ // THE WIDENING IS NAMED ON THIS ARM TOO (CR-B-003). The record already
966
+ // carries it, and this reason is the only thing many callers read, so
967
+ // leaving it out here would make the arm's refusal say less than the
968
+ // artifact beside it.
969
+ (widened
970
+ ? `; and ${credentialRecord.refusal ?? ""}, recorded in ${metaPath(fleet, taskId)}`
971
+ : ""),
972
+ };
973
+ }
974
+ if (widened) {
975
+ return {
976
+ ok: false,
977
+ reason: `${credentialRecord.refusal ?? ""}; an adapter never widens the child ` +
978
+ `environment it was given (see ExecutorRequest.env), and the difference ` +
979
+ `is recorded in ${metaPath(fleet, taskId)}; nothing was rolled back ` +
980
+ `because the payload had already run`,
981
+ };
982
+ }
983
+ // The scrub root is ephemeral. Both children have exited: the turn-end
984
+ // record exists and parses, which is the payload's own exit writing
985
+ // itself down, and the launch promise has been awaited, so the harness-
986
+ // owned redirect targets have no further reader. That sentence used to
987
+ // read "the launch is synchronous, C-3", and it stopped being true the
988
+ // moment `launch` returned a promise; a comment asserting a dead
989
+ // invariant is how the next reader re-derives the defect, so the
990
+ // reasoning is restated rather than left. C-3 is still satisfied, by
991
+ // the await rather than by the signature. This removal touches ONLY the
992
+ // scrub root, never the worktree, so it cannot be a V-1-shaped loss.
322
993
  if (childEnv !== undefined) {
323
994
  try {
324
995
  rmSync(scrubRoot(dir), { recursive: true, force: true });