@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
@@ -1,10 +1,11 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, relative, resolve } from "node:path";
3
- import { pathToFileURL } from "node:url";
3
+ import { fileURLToPath } from "node:url";
4
+ import { pathsIdentifySameObject } from "../path-identity.js";
4
5
  import { refuseOpenForWrite, singleLine } from "../task.js";
5
6
  import { loadManifest } from "./manifest.js";
6
7
  import { exitCodeForStatus, makeGateResult, renderGateResult } from "./result.js";
7
- import { listWitnessSpecFiles, loadWitnessSpec, memberTouchedFiles, } from "../witness/spec.js";
8
+ import { listWitnessSpecFiles, loadWitnessSpec, memberTouchedFiles, parseWitnessSpec, phaseOwnedMemberIndices, } from "../witness/spec.js";
8
9
  import { SPAWN_GREP, computePhaseDiff, evaluateWitness, gitIn, makeScratchRoot, readTestFilesAtHead, removeScratchRoot, resolveRepoRoot, shellSpawnsAndParses, } from "../witness/run.js";
9
10
  /**
10
11
  * THE RED-WITNESS GATE (kernel plan M2, M2-P2 steps 6 and 7).
@@ -86,9 +87,27 @@ function errorOutcome(startedAt, detail) {
86
87
  reEvaluationMs: 0,
87
88
  };
88
89
  }
89
- /** True when the repo-relative path is a phase-audited source path. */
90
+ /**
91
+ * True when the repo-relative path is a phase-audited source path.
92
+ *
93
+ * WHY `plugin/src/` AND NOT `plugin/`. This list is the COVERAGE OBLIGATION:
94
+ * a changed path here must be touched by some witness's dangerous state or the
95
+ * gate reports it uncovered. `src/` and `bin/` are source trees, and the plugin
96
+ * package's source tree is `plugin/src/`. Adding bare `plugin/` would pull in
97
+ * `plugin/package.json` and `plugin/tsconfig.json`, which no witness mutates
98
+ * and which would therefore redden every plugin phase for its own packaging.
99
+ *
100
+ * WHY IT IS NOT THE SAME LIST AS THE GATE'S PRECONDITION, which reads
101
+ * `src/`, `bin/`, `plugin/`. The precondition decides whether the gate RUNS.
102
+ * This decides what it REQUIRES. T-038 widened the first and left the second,
103
+ * so from that fix until this one a diff touching only `plugin/` ran the gate
104
+ * and took no obligation from it: a plugin phase shipping ZERO witnesses was
105
+ * green. Found by M4-P29 while reading this file for a different reason.
106
+ */
90
107
  function isAuditedSource(path) {
91
- return path.startsWith("src/") || path.startsWith("bin/");
108
+ return (path.startsWith("src/") ||
109
+ path.startsWith("bin/") ||
110
+ path.startsWith("plugin/src/"));
92
111
  }
93
112
  /**
94
113
  * Run the red-witness gate against a repository. Exported so tests can
@@ -192,6 +211,47 @@ export function runRedWitnessGate(run) {
192
211
  };
193
212
  const own = specs.filter((entry) => diff.files.has(entry.repoRelative));
194
213
  const stored = specs.filter((entry) => !diff.files.has(entry.repoRelative));
214
+ /**
215
+ * A patch member's body at the MERGE BASE, the old side of the ownership
216
+ * comparison. `readPatchAtHead` above is the new side. Two readers rather
217
+ * than one because the whole point of reading the body is that the same path
218
+ * can hold different content on the two revisions.
219
+ */
220
+ const readPatchAtMergeBase = (patchPath) => {
221
+ const shown = gitIn(repoRoot, ["show", `${diff.mergeBaseSha}:${patchPath}`]);
222
+ return shown.ok ? shown.stdout : undefined;
223
+ };
224
+ /**
225
+ * The members of an own spec that THIS PHASE AUTHORED, which is rule (d)'s
226
+ * scope. Membership in `own` is file-granular ("some byte of this spec
227
+ * changed") and rule (d)'s obligation is member-granular ("this declared
228
+ * dangerous state must intersect the diff"), so the two are reconciled here
229
+ * rather than by handing the harness a boolean for the whole file.
230
+ *
231
+ * The old side is read at the MERGE BASE, which is the revision the diff
232
+ * itself is taken against. A baseline that is absent, unreadable or invalid
233
+ * yields `undefined`, and `phaseOwnedMemberIndices` then owns every member:
234
+ * an added spec is wholly the phase's, and so is one whose previous version
235
+ * cannot be established.
236
+ *
237
+ * The WHOLE spec goes in, not just its members, because the spec's claim
238
+ * (`behavior` and `tests`) is part of what rule (d) is an obligation on. See
239
+ * `claimRePointed` for which fields are in that set, why the other four are
240
+ * not, and why the comparison is DIRECTIONAL: re-pointing a claim takes the
241
+ * obligation, extending its named tests does not.
242
+ */
243
+ const ownedMembersOf = (entry) => {
244
+ const readers = { head: readPatchAtHead, baseline: readPatchAtMergeBase };
245
+ const shown = gitIn(repoRoot, [
246
+ "show",
247
+ `${diff.mergeBaseSha}:${entry.repoRelative}`,
248
+ ]);
249
+ if (!shown.ok) {
250
+ return phaseOwnedMemberIndices(entry.spec, undefined, readers);
251
+ }
252
+ const baseline = parseWitnessSpec(shown.stdout, `${diff.mergeBaseSha}:${entry.repoRelative}`);
253
+ return phaseOwnedMemberIndices(entry.spec, baseline.ok ? baseline.spec : undefined, readers);
254
+ };
195
255
  const triggeredStored = stored.filter((entry) => entry.spec.dangerousStates.some((member) => memberTouchedFiles(member, readPatchAtHead).some((file) => diff.files.has(file))));
196
256
  // Coverage (step 7): source changed with no witness spec covering it is
197
257
  // red, never not-applicable. Coverage semantics are decision D-P2-2 in
@@ -233,7 +293,10 @@ export function runRedWitnessGate(run) {
233
293
  scratchRoot,
234
294
  };
235
295
  for (const entry of own) {
236
- const inputs = { ...baseInputs, phaseOwn: true };
296
+ const inputs = {
297
+ ...baseInputs,
298
+ phaseOwnedMembers: ownedMembersOf(entry),
299
+ };
237
300
  if (run.hooks !== undefined) {
238
301
  inputs.hooks = run.hooks;
239
302
  }
@@ -245,7 +308,12 @@ export function runRedWitnessGate(run) {
245
308
  }
246
309
  const reEvaluationStart = Date.now();
247
310
  for (const entry of triggeredStored) {
248
- const inputs = { ...baseInputs, phaseOwn: false };
311
+ // A stored witness is one the phase diff does not touch at all, so no
312
+ // member of it is the phase's and rule (d) has nothing to apply to.
313
+ const inputs = {
314
+ ...baseInputs,
315
+ phaseOwnedMembers: new Set(),
316
+ };
249
317
  if (run.hooks !== undefined) {
250
318
  inputs.hooks = run.hooks;
251
319
  }
@@ -378,13 +446,37 @@ const invokedDirectly = (() => {
378
446
  if (entry === undefined) {
379
447
  return false;
380
448
  }
381
- try {
382
- return import.meta.url === pathToFileURL(resolve(entry)).href;
383
- }
384
- catch {
385
- return false;
386
- }
449
+ // IDENTITY, NOT STRING EQUALITY (M4-P2 fix round, 2026-09-16). Going
450
+ // through a URL does not change what is compared: `resolve` leaves the
451
+ // caller's spelling intact while `import.meta.url` is canonical, so an
452
+ // invocation through a symlink leaves this gate silently not running.
453
+ return pathsIdentifySameObject(fileURLToPath(import.meta.url), entry);
387
454
  })();
388
455
  if (invokedDirectly) {
389
- process.exit(main(process.argv.slice(2)));
456
+ // `process.exitCode`, NEVER `process.exit(main(...))` (M4-P29). This gate
457
+ // runs as a SUBPROCESS, so fd 1 is a buffered stream the parent owns and a
458
+ // write to one is QUEUED rather than completed. `process.exit` ends the
459
+ // process without draining that queue, so everything past the buffer is
460
+ // DISCARDED, while the exit code survives and the loss is silent.
461
+ //
462
+ // BE EXACT ABOUT WHICH STREAM, because the ceiling differs by a factor of
463
+ // two and the obvious word is the wrong one. `spawnSync`, which is how the
464
+ // registry runner invokes gates (src/gates/run.ts:1528), hands a child
465
+ // SOCKETPAIRS, not pipes; a shell pipeline, a `tee` or a CI log collector
466
+ // hands it a real pipe. Measured at the pre-fix parent commit on this
467
+ // container, same invocation, only the reader changed:
468
+ //
469
+ // reader wrote delivered
470
+ // regular file 176,530 176,530
471
+ // shell pipe 176,530 65,536 (one pipe buffer)
472
+ // spawnSync 434,530 146,176 (the socket send buffer)
473
+ //
474
+ // A regular file never truncates, which is why this survives casual
475
+ // testing. This gate is the measured instance rather than a hypothetical
476
+ // one: its single stdout line carries the whole `detail`, and `detail`
477
+ // grows with the number of uncovered sources and of red witnesses.
478
+ // Assigning `process.exitCode` lets the process end normally, which drains
479
+ // the queue first, and the full capture is
480
+ // witness/captures/m4-p29-gate-cli-stdio.txt.
481
+ process.exitCode = main(process.argv.slice(2));
390
482
  }
@@ -1,5 +1,5 @@
1
1
  import type { GateEntry, GateManifest, RunParameter } from "./manifest.ts";
2
- import type { GateStatus } from "./result.ts";
2
+ import type { GateStatus, PreconditionRecord } from "./result.ts";
3
3
  /**
4
4
  * THE GATE RUNNER (kernel plan M2, M2-P1 step 7 and step 8).
5
5
  *
@@ -143,6 +143,13 @@ export interface GateSummaryRow {
143
143
  unitLabel: string;
144
144
  vacuous: boolean;
145
145
  applicable: boolean;
146
+ /**
147
+ * M4-P11, DR-0038. True when this gate's `not-applicable` carries a
148
+ * declaration in its precondition evidence. Present on the ROW as well as in
149
+ * the reason line, because the reason line is one line and a reader auditing
150
+ * the bundle needs to reach the gate that declared without parsing prose.
151
+ */
152
+ declaredNotApplicable?: boolean;
146
153
  detail: string;
147
154
  record?: string;
148
155
  stdout?: string;
@@ -188,6 +195,13 @@ export interface RunSummary {
188
195
  };
189
196
  /** Named here as well as in the rows, because the reason line is one line. */
190
197
  requiredNotApplicable: string[];
198
+ /**
199
+ * M4-P11, DR-0038. Every gate whose `not-applicable` carries a declaration,
200
+ * sorted. Always present, EMPTY when there are none, so a consumer can tell
201
+ * "this run declared nothing" from "this run is from before the field
202
+ * existed"; an absent field would be the silence that reads as permission.
203
+ */
204
+ declaredNotApplicable: string[];
191
205
  /** True when a throw escaped the run and this summary is a partial record. */
192
206
  aborted: boolean;
193
207
  exitCode: number;
@@ -209,6 +223,39 @@ export interface RunOutcome {
209
223
  reason?: string;
210
224
  }
211
225
  export declare const NO_APPLICABLE_GATE = "no applicable gate";
226
+ /**
227
+ * THE EVIDENCE ENTRY THAT MARKS A NOT-APPLICABLE AS DECLARED (M4-P11, DR-0038).
228
+ *
229
+ * `src/gates/release.ts:1050` already writes this exact string into
230
+ * `precondition.evidence` when a release verification is declared `none`, and
231
+ * `scripts/check-dual-review.mjs` now writes it for the declared single-family
232
+ * review exception. The owner's requirement for that exception is that nobody
233
+ * can hide it; it is stated here as a runner-level property because the place
234
+ * an exception hides is the AGGREGATE, not the gate's own record.
235
+ *
236
+ * WHY AN ARRAY ELEMENT AND NOT A FIELD. A boolean on `PreconditionRecord` would
237
+ * be the right home, and `src/gates/schemas/gate-result.schema.json` is
238
+ * `additionalProperties: false` on that object, so adding one is a schema
239
+ * change outside M4-P11's files-to-touch list. What is done instead is the
240
+ * narrowest available thing that is still structural: an EXACT element of a
241
+ * structured array, compared with `===`, never a pattern over the detail prose.
242
+ * MECHANISMS.md's row about deciding what another program will do by
243
+ * pattern-matching the text of a file it wrote is the failure this avoids, and
244
+ * matching one whole array element is on the safe side of it because the
245
+ * producer writes the element for this purpose and nothing else.
246
+ */
247
+ export declare const DECLARED_PRECONDITION_EVIDENCE = "declared: true";
248
+ /**
249
+ * Does this record's precondition carry a declaration?
250
+ *
251
+ * A not-applicable with NO precondition is not declared, and neither is one
252
+ * whose precondition carries no evidence: silence is never permission, and it
253
+ * is never a declaration either.
254
+ */
255
+ export declare function isDeclaredNotApplicable(result: {
256
+ status: GateStatus;
257
+ precondition?: PreconditionRecord;
258
+ }): boolean;
212
259
  /** The default assurance mode when `--registry` is given without `--mode`. */
213
260
  export declare const DEFAULT_MODE = "full";
214
261
  export type RegistryLoad = {
@@ -525,6 +572,7 @@ export interface AggregateCounts {
525
572
  export declare function decideAggregate(counts: AggregateCounts, requiredNotApplicable: string[], rows: {
526
573
  id: string;
527
574
  status: GateStatus;
575
+ declaredNotApplicable?: boolean;
528
576
  }[]): {
529
577
  exitCode: number;
530
578
  reason: string;
@@ -16,6 +16,45 @@ import { loadManifest, validateManifestDocument, validateResultDocument } from "
16
16
  import { comparePins, describePinDifference } from "./pin.js";
17
17
  import { EXIT_GATE_ERROR, EXIT_GREEN, EXIT_NOT_APPLICABLE, EXIT_RED, M2_C_2_DETAIL, exitCodeForStatus, makeGateResult, renderGateResult, statusForExitCode, } from "./result.js";
18
18
  export const NO_APPLICABLE_GATE = "no applicable gate";
19
+ /**
20
+ * THE EVIDENCE ENTRY THAT MARKS A NOT-APPLICABLE AS DECLARED (M4-P11, DR-0038).
21
+ *
22
+ * `src/gates/release.ts:1050` already writes this exact string into
23
+ * `precondition.evidence` when a release verification is declared `none`, and
24
+ * `scripts/check-dual-review.mjs` now writes it for the declared single-family
25
+ * review exception. The owner's requirement for that exception is that nobody
26
+ * can hide it; it is stated here as a runner-level property because the place
27
+ * an exception hides is the AGGREGATE, not the gate's own record.
28
+ *
29
+ * WHY AN ARRAY ELEMENT AND NOT A FIELD. A boolean on `PreconditionRecord` would
30
+ * be the right home, and `src/gates/schemas/gate-result.schema.json` is
31
+ * `additionalProperties: false` on that object, so adding one is a schema
32
+ * change outside M4-P11's files-to-touch list. What is done instead is the
33
+ * narrowest available thing that is still structural: an EXACT element of a
34
+ * structured array, compared with `===`, never a pattern over the detail prose.
35
+ * MECHANISMS.md's row about deciding what another program will do by
36
+ * pattern-matching the text of a file it wrote is the failure this avoids, and
37
+ * matching one whole array element is on the safe side of it because the
38
+ * producer writes the element for this purpose and nothing else.
39
+ */
40
+ export const DECLARED_PRECONDITION_EVIDENCE = "declared: true";
41
+ /**
42
+ * Does this record's precondition carry a declaration?
43
+ *
44
+ * A not-applicable with NO precondition is not declared, and neither is one
45
+ * whose precondition carries no evidence: silence is never permission, and it
46
+ * is never a declaration either.
47
+ */
48
+ export function isDeclaredNotApplicable(result) {
49
+ if (result.status !== "not-applicable") {
50
+ return false;
51
+ }
52
+ const evidence = result.precondition?.evidence;
53
+ if (!Array.isArray(evidence)) {
54
+ return false;
55
+ }
56
+ return evidence.some((entry) => entry === DECLARED_PRECONDITION_EVIDENCE);
57
+ }
19
58
  /** The default assurance mode when `--registry` is given without `--mode`. */
20
59
  export const DEFAULT_MODE = "full";
21
60
  /**
@@ -1078,6 +1117,29 @@ function pinRefusal(record) {
1078
1117
  * at the aggregate level, M2R-012).
1079
1118
  */
1080
1119
  export function decideAggregate(counts, requiredNotApplicable, rows) {
1120
+ /* M4-P11, DR-0038. THE ONE THING A DECLARED EXCEPTION MUST NEVER BE IS
1121
+ INVISIBLE, AND THE AGGREGATE IS WHERE IT WOULD BE.
1122
+ `scripts/check-dual-review.mjs` is a CONDITIONAL gate, so its
1123
+ not-applicable never reaches `requiredNotApplicable` and never appears in
1124
+ the reason line. Before this clause, a bundle carrying a gate that had
1125
+ declined DR-0012's cross-family requirement by declaration printed "every
1126
+ applicable gate is green" and exited 0, and the exception appeared nowhere
1127
+ a reader of the bundle would look. That is the same substitution T-009
1128
+ names, one scope smaller: a bundle-level green standing in for a
1129
+ gate-level fact.
1130
+
1131
+ IT IS APPENDED TO EVERY ARM, not only the success path. A declaration is
1132
+ equally worth seeing beside a red, and an arm that reported it on one
1133
+ branch and not another would be a guard that goes quiet exactly when
1134
+ something else is also wrong. */
1135
+ const declared = rows
1136
+ .filter((row) => row.status === "not-applicable" && row.declaredNotApplicable === true)
1137
+ .map((row) => row.id)
1138
+ .sort();
1139
+ const declaredClause = declared.length === 0
1140
+ ? ""
1141
+ : `; ${String(declared.length)} gate(s) not applicable by declaration: ${declared.join(", ")}`;
1142
+ const decided = (verdict) => ({ exitCode: verdict.exitCode, reason: `${verdict.reason}${declaredClause}` });
1081
1143
  let exitCode = EXIT_GREEN;
1082
1144
  let reason = "every applicable gate is green";
1083
1145
  if (counts.error > 0) {
@@ -1124,11 +1186,11 @@ export function decideAggregate(counts, requiredNotApplicable, rows) {
1124
1186
  .map(([name]) => name)
1125
1187
  .sort();
1126
1188
  if (badCounts.length > 0) {
1127
- return {
1189
+ return decided({
1128
1190
  exitCode: EXIT_GATE_ERROR,
1129
1191
  reason: "internal inconsistency: count(s) that are not non-negative integers: " +
1130
1192
  `${badCounts.join(", ")} (${JSON.stringify(counts)})`,
1131
- };
1193
+ });
1132
1194
  }
1133
1195
  for (const name of [
1134
1196
  "declared",
@@ -1141,10 +1203,10 @@ export function decideAggregate(counts, requiredNotApplicable, rows) {
1141
1203
  "vacuous",
1142
1204
  ]) {
1143
1205
  if (!Object.prototype.hasOwnProperty.call(counts, name)) {
1144
- return {
1206
+ return decided({
1145
1207
  exitCode: EXIT_GATE_ERROR,
1146
1208
  reason: `internal inconsistency: the count ${name} is missing (${JSON.stringify(counts)})`,
1147
- };
1209
+ });
1148
1210
  }
1149
1211
  }
1150
1212
  // THE SUCCESS PATH CANNOT DESCRIBE AN EMPTY GREEN BUCKET. The branches
@@ -1181,7 +1243,7 @@ export function decideAggregate(counts, requiredNotApplicable, rows) {
1181
1243
  `internal inconsistency: vacuous ${String(counts.vacuous)} exceeds ` +
1182
1244
  `error ${String(counts.error)}, and vacuous is a strict subset of error`;
1183
1245
  }
1184
- return { exitCode, reason };
1246
+ return decided({ exitCode, reason });
1185
1247
  }
1186
1248
  export const RUN_CLAIM_FILE = ".tiphys-gate-run.json";
1187
1249
  /**
@@ -1362,6 +1424,11 @@ function writeAbortedSummary(options, runId, reason) {
1362
1424
  vacuous: 0,
1363
1425
  },
1364
1426
  requiredNotApplicable: [],
1427
+ /* An aborted run ran no gate, so nothing declared anything. EMPTY rather
1428
+ than omitted, for the reason the field's own comment gives: an absent
1429
+ field would be indistinguishable from an older summary that could not
1430
+ have carried one. */
1431
+ declaredNotApplicable: [],
1365
1432
  aborted: true,
1366
1433
  exitCode: EXIT_GATE_ERROR,
1367
1434
  reason,
@@ -1453,6 +1520,7 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
1453
1520
  vacuous: 0,
1454
1521
  };
1455
1522
  const requiredNotApplicable = [];
1523
+ const declaredNotApplicable = [];
1456
1524
  for (const entry of selected) {
1457
1525
  const outcome = runOneGate(entry, options, cwd, options.evidenceDir, runId);
1458
1526
  const result = outcome.result;
@@ -1469,6 +1537,14 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
1469
1537
  if (result.status === "not-applicable" && entry.applicability === "required") {
1470
1538
  requiredNotApplicable.push(entry.id);
1471
1539
  }
1540
+ /* M4-P11. READ OFF THE INGESTED RECORD, not off the manifest and not off
1541
+ the gate's own claim about itself: `ingestGateRun` has already refused a
1542
+ record whose status and exit code disagree, so by here the precondition
1543
+ belongs to a record the runner accepted. */
1544
+ const declaredHere = isDeclaredNotApplicable(result);
1545
+ if (declaredHere) {
1546
+ declaredNotApplicable.push(entry.id);
1547
+ }
1472
1548
  // The runner owns the record on disk whenever it produced or changed
1473
1549
  // one: a not-applicable gate never ran and wrote nothing, and a rewritten
1474
1550
  // vacuous green must not stay green in the evidence (criterion 4).
@@ -1485,6 +1561,7 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
1485
1561
  unitLabel: result.unitLabel,
1486
1562
  vacuous: result.vacuous === true,
1487
1563
  applicable: outcome.applicable,
1564
+ declaredNotApplicable: declaredHere,
1488
1565
  detail: result.detail,
1489
1566
  record: dirRefusalForGate === undefined ? recordPath : undefined,
1490
1567
  stdout: outcome.stdoutPath,
@@ -1523,6 +1600,7 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
1523
1600
  gates: rows,
1524
1601
  counts,
1525
1602
  requiredNotApplicable,
1603
+ declaredNotApplicable: [...declaredNotApplicable].sort(),
1526
1604
  aborted: false,
1527
1605
  exitCode,
1528
1606
  reason,
@@ -37,6 +37,51 @@
37
37
  "items": {
38
38
  "type": "string"
39
39
  }
40
+ },
41
+ "gateClasses": {
42
+ "description": "DR-0029 part 2a (M4-P14). The kernel requires a PHASE to declare at least one gate in each required CLASS and never says what the command is. THE SHAPE IS DECLARED HERE AND THE SEMANTICS ARE NOT, deliberately: an absent reason on a not-applicable class, or a missing establishing phase on a not-yet-establishable one, must reach a reader as a RED gate naming the phase and the class, and a schema rejection would instead surface as the gate's `error` status, which M2-C-3 reserves for a check that could not reach a verdict. So this property admits every shape src/gates/gate-classes.ts is able to report on, and that module refuses the ones DR-0029 forbids. The property is OPTIONAL because every declaration written before this phase lacks it and a required field would make all of them fail to load, turning a red verdict into an error for a defect this schema is not the right instrument for.",
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "properties": {
46
+ "correctness": {
47
+ "$ref": "#/$defs/classDeclaration"
48
+ },
49
+ "scope": {
50
+ "$ref": "#/$defs/classDeclaration"
51
+ },
52
+ "review": {
53
+ "$ref": "#/$defs/classDeclaration"
54
+ }
55
+ }
56
+ }
57
+ },
58
+ "$defs": {
59
+ "classDeclaration": {
60
+ "description": "One class's disposition. EITHER `gates` names one or more gate ids that assert this class, OR `status` declares an escape: `not-applicable` carrying a `reason`, or `not-yet-establishable` carrying `establishedBy`, the phase id that will establish it. DR-0029: a phase may start from nothing and may never SILENTLY have nothing, so the escape is data, it is visible, and it is carried forward.",
61
+ "type": "object",
62
+ "additionalProperties": false,
63
+ "properties": {
64
+ "gates": {
65
+ "description": "Gate ids from this repository's own gate registry that assert this class for this phase.",
66
+ "type": "array",
67
+ "items": {
68
+ "type": "string"
69
+ }
70
+ },
71
+ "status": {
72
+ "description": "The declared escape, when this phase asserts no gate in this class.",
73
+ "type": "string",
74
+ "enum": ["not-applicable", "not-yet-establishable"]
75
+ },
76
+ "reason": {
77
+ "description": "Required by src/gates/gate-classes.ts when status is not-applicable. Not required HERE: an empty or absent reason is a RED verdict naming the class, not a document that fails to load.",
78
+ "type": "string"
79
+ },
80
+ "establishedBy": {
81
+ "description": "Required by src/gates/gate-classes.ts when status is not-yet-establishable: the phase id that will establish this class. The IOU's due date.",
82
+ "type": "string"
83
+ }
84
+ }
40
85
  }
41
86
  }
42
87
  }
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { pathsIdentifySameObject } from "../path-identity.js";
5
+ import { pathsIdentifySameObject, pathsNameSameObject } from "../path-identity.js";
6
6
  import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
7
7
  import { comparePins, describePinDifference, takePin } from "./pin.js";
8
8
  import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
@@ -323,7 +323,20 @@ export const MAPPING_STATEMENT = "pass, fail and skipped map directly; cancelled
323
323
  export function isFileWrapperPhantom(point, cwd) {
324
324
  return (point.entityType === "test" &&
325
325
  point.nesting === 0 &&
326
- resolve(cwd, point.name) === point.file);
326
+ // IDENTITY, NOT STRING EQUALITY, and the paragraph above claims more
327
+ // than `===` delivers. "Invariant across every spelling node produces
328
+ // it in, by construction" is false for one spelling: `resolve` does not
329
+ // resolve SYMLINKS, and node reports the CANONICAL path in its
330
+ // reporter's `file` field whatever spelling it was invoked with.
331
+ // Measured 2026-09-16 on node v26.6.0: invoked as
332
+ // <link>/test/a.test.js, `data.file` came back as <real>/test/a.test.js,
333
+ // so `resolve(cwd, point.name)` and `point.file` were two strings for
334
+ // one file and the phantom was counted as a real test again, which is
335
+ // the CR-1306 defect through the one spelling the enumeration missed.
336
+ // The string comparison is kept and tried first (it answers without
337
+ // touching the filesystem and is the common case); the identity check
338
+ // only ever turns a false "different" into a true "same".
339
+ pathsNameSameObject(resolve(cwd, point.name), point.file));
327
340
  }
328
341
  export function bucketPoints(points) {
329
342
  const counts = {
@@ -820,21 +833,32 @@ export function runSuiteGate(argv) {
820
833
  });
821
834
  }
822
835
  // Discovery parity, both directions (step 3).
836
+ //
837
+ // NOT Set membership on the raw strings (DV2-1): `discoveredFiles` is
838
+ // composed by this gate's own walk from `resolve(cwd, root)`, which does
839
+ // not resolve a symlinked ANCESTOR of the declared root, while
840
+ // `reportedFiles` comes from node's own test reporter, which is canonical
841
+ // whatever spelling node was invoked with (the same reason
842
+ // `isFileWrapperPhantom` above already compares by identity rather than by
843
+ // string). A `--test-root` that reaches its target through a symlinked
844
+ // ancestor then produces one file discovered once and reported once,
845
+ // reported as BOTH "discovered but absent from the reporter" and
846
+ // "reported but outside the declared roots", because the two strings
847
+ // differ even though they name the same object. `pathsNameSameObject`
848
+ // (src/path-identity.ts) is the round's own primitive for exactly this.
823
849
  const reportedFiles = [
824
850
  ...new Set(points
825
851
  .filter((point) => point.entityType === "test")
826
852
  .map((point) => point.file)),
827
853
  ].sort();
828
- const discoveredSet = new Set(discoveredFiles);
829
- const reportedSet = new Set(reportedFiles);
830
854
  const findings = [];
831
855
  for (const file of discoveredFiles) {
832
- if (!reportedSet.has(file)) {
856
+ if (!reportedFiles.some((other) => pathsNameSameObject(other, file))) {
833
857
  findings.push(`test file discovered by the walk but absent from the reporter: ${relative(cwd, file)}`);
834
858
  }
835
859
  }
836
860
  for (const file of reportedFiles) {
837
- if (!discoveredSet.has(file)) {
861
+ if (!discoveredFiles.some((other) => pathsNameSameObject(other, file))) {
838
862
  findings.push(`test file reported but outside the declared roots and suffix: ${relative(cwd, file)}`);
839
863
  }
840
864
  }
@@ -923,5 +947,22 @@ export function runSuiteGate(argv) {
923
947
  const invokedDirectly = process.argv[1] !== undefined &&
924
948
  pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1]);
925
949
  if (invokedDirectly) {
926
- process.exit(runSuiteGate(process.argv.slice(2)));
950
+ // `process.exitCode`, NEVER `process.exit(runSuiteGate(...))` (M4-P29).
951
+ // This gate runs as a SUBPROCESS, so fd 1 is a buffered stream the parent
952
+ // owns and a write to one is QUEUED rather than completed. `process.exit`
953
+ // ends the process without draining that queue, so everything past the
954
+ // buffer is DISCARDED, while the exit code survives and the loss is
955
+ // silent. The plan names this gate as the plausible future trigger because
956
+ // its `detail` carries up to ten findings and every finding quotes text
957
+ // this gate read out of another program's report.
958
+ //
959
+ // Measured at the pre-fix parent commit on this container, through
960
+ // `spawnSync`, which is how the registry runner invokes gates
961
+ // (src/gates/run.ts:1528) and which hands a child SOCKETPAIRS rather than
962
+ // pipes: 451,014 bytes written, 182,750 delivered. A regular file never
963
+ // truncates, which is why this survives casual testing. Assigning
964
+ // `process.exitCode` lets the process end normally, which drains the queue
965
+ // first, and the full capture is
966
+ // witness/captures/m4-p29-gate-cli-stdio.txt.
967
+ process.exitCode = runSuiteGate(process.argv.slice(2));
927
968
  }
@@ -25,8 +25,60 @@ import type { Fleet } from "./fleet.ts";
25
25
  export declare function turnEndHookPath(fleet: Fleet, taskId: string): string;
26
26
  /**
27
27
  * Generate the hook script. The turn-end path is baked in as a literal,
28
- * so the hook needs no fleet resolution and no environment at all.
28
+ * so the hook needs no fleet resolution.
29
+ *
30
+ * THE CHILD-OBSERVED POINTER RECORD (CR-B-001, the half that closes the hole
31
+ * rather than the half that stops mis-asserting it).
32
+ *
33
+ * `observeNames`, when given, is baked in as a literal array and the hook
34
+ * writes an `env` object holding what each of those names ACTUALLY IS in the
35
+ * environment the hook was launched with, or `null` where the name is unset.
36
+ * The kernel compares those against the harness-owned paths it handed over.
37
+ *
38
+ * WHY THIS IS STRONGER THAN AN ADAPTER'S REPORT AND WHY IT IS NOT PROOF.
39
+ * This script is written BY THE KERNEL and, for every adapter that honours
40
+ * the documented contract, runs in the SAME environment as the payload (the
41
+ * built-in adapter spreads the same `request.env` into both spawnSync calls,
42
+ * and M2R-004 edit 4 is the record of why a second unscrubbed launch is
43
+ * itself the leak). Against an adapter that does not invoke this script at
44
+ * all it proves nothing, and the record no longer says otherwise.
45
+ *
46
+ * THE COST SENTENCE THAT STOOD HERE IS WITHDRAWN, BECAUSE IT WAS REFUTED BY
47
+ * MEASUREMENT (CR-F-CRED-001, MEDIUM).
48
+ *
49
+ * It read: an adapter that quietly reverted `HOME` for the payload "has to
50
+ * revert it for the payload and NOT for the hook, which means launching two
51
+ * children with two different environments and is a substantially different
52
+ * act from passing a mutated copy once". Two children is one way to do it and
53
+ * it is not the cheap way. The cheap way is ONE child with the mutated
54
+ * environment plus a single `writeFileSync` of the turn-end path, which is
55
+ * STRICTLY LESS work than the honest path, because the honest path also
56
+ * spawns the hook. The turn-end path is handed to the adapter beside
57
+ * `hookPath`, and this generated script names it as a literal, so an adapter
58
+ * that never runs it can still produce a byte-identical record.
59
+ *
60
+ * WHY A NONCE DOES NOT CLOSE THIS, stated because it is the obvious repair and
61
+ * it was considered and refused rather than overlooked. Baking a per-task
62
+ * secret into this script and requiring it in the record moves the forgery
63
+ * from "know the path" to "read the file", and the adapter is HANDED the path
64
+ * of this file: it runs at the same uid, on the same filesystem, in a
65
+ * directory it must be able to read to invoke the hook at all. A guard whose
66
+ * condition the adversary can satisfy by reading one file is green and
67
+ * worthless, which is this repository's own recorded shape (T-008's
68
+ * postscript, the red-witness rule one level up). No artifact this script can
69
+ * write is unforgeable by a party that can read this script.
70
+ *
71
+ * So the repair is on the RECORD rather than on the check: the value is
72
+ * `turn-end-record`, it names the artifact the values were read from, and
73
+ * `CredentialHandoverRecord` (src/task.ts) says the artifact is
74
+ * adapter-reachable. A dishonest adapter is still not caught here, and that
75
+ * residue is declared at delivery/work-history/m4-p8.md item 3; what changed
76
+ * is that `meta.json` no longer positively asserts a child-side observation
77
+ * that no child made.
78
+ *
79
+ * The hook still reads no environment it was not told to read, and a hook
80
+ * generated with no `observeNames` behaves exactly as it did before.
29
81
  */
30
- export declare function renderTurnEndHook(turnEndFile: string): string;
82
+ export declare function renderTurnEndHook(turnEndFile: string, observeNames?: readonly string[]): string;
31
83
  /** Write the hook for a task and return its path. */
32
- export declare function writeTurnEndHook(fleet: Fleet, taskId: string): string;
84
+ export declare function writeTurnEndHook(fleet: Fleet, taskId: string, observeNames?: readonly string[]): string;