@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/lock.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { linkSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { linkSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { hostname } from "node:os";
4
4
  import { setTimeout as sleep } from "node:timers/promises";
5
+ import { buildRegisterDocument, casFailureLine, casWrite, ensureEnvironmentId, fleetRootForLockPath, preflightShared, readSharedExclusion, } from "./exclusion.js";
6
+ import { readRegularPathIfPresent } from "./fleet.js";
5
7
  /**
6
8
  * Lease-based session lock (kernel plan v1, M1-P3 step 1; DR-0007; plan
7
9
  * constraint C-2). One orchestrator per fleet: the lock file at
@@ -55,9 +57,19 @@ import { setTimeout as sleep } from "node:timers/promises";
55
57
  *
56
58
  * Exclusion domain (PR-201, DR-0007 stated honestly): the lease excludes
57
59
  * within one filesystem and one clock, the fleet home the lock file lives
58
- * in. Cross-environment exclusion for a fleet shared through a git remote
59
- * is M4 residue and is not claimed here. Mutations of the lock file made
60
- * outside this module (manual edits) are not covered by the contract.
60
+ * in. Mutations of the lock file made outside this module (manual edits)
61
+ * are not covered by the contract.
62
+ *
63
+ * CROSS-ENVIRONMENT EXCLUSION IS A SECOND LAYER ABOVE THIS ONE (M4-P21),
64
+ * and it is OFF unless the fleet home declares it. When the fleet's own
65
+ * `package.json` carries `tiphys.sharedExclusion`, every mutation below
66
+ * first asks `src/exclusion.ts` for a verdict from the shared register on
67
+ * the fleet's git remote, and only a won verdict reaches the local lease.
68
+ * With the field ABSENT, `readSharedExclusion` returns before spawning
69
+ * anything and every path in this module behaves exactly as it did, which
70
+ * is the property M4-P21 criterion 1 asserts. The refusal never touches the
71
+ * lock file, which is what makes the fail-closed behaviour on an
72
+ * unreachable register observable (criterion 7): no local lease appears.
61
73
  *
62
74
  * Renewal discipline (PR-203): the default lease lasts 900 seconds and
63
75
  * the holder renews at or before half-life (renewByMs). Holdership on
@@ -92,37 +104,68 @@ function parseLease(raw) {
92
104
  export function renderLease(lease) {
93
105
  return `${JSON.stringify(lease, null, 2)}\n`;
94
106
  }
95
- /** Read the current lock file state: absent, or present with raw bytes. */
107
+ /**
108
+ * Read the current lock file state: absent, or present with raw bytes.
109
+ *
110
+ * THE ENTRY TYPE IS ESTABLISHED BEFORE THE OPEN (T-008's shape in shipped
111
+ * code). A bare `readFileSync` here blocked FOREVER with zero output on a
112
+ * named pipe at the lease path, and took `lock status`, `lock acquire`,
113
+ * `lock renew` and `lock release` with it, while `tiphys doctor` returned in
114
+ * the same second against the same FIFO with "is a named pipe, not a regular
115
+ * file, so it was not opened". Two readers of one path, one of which
116
+ * established the type; this is now the same reader.
117
+ *
118
+ * A refusal THROWS rather than returning a fourth `ObservedLease` variant.
119
+ * The function already threw on every non-ENOENT error, so the contract its
120
+ * callers were written against is unchanged, and bin/tiphys.ts turns the
121
+ * throw into one diagnostic line and a nonzero exit.
122
+ */
96
123
  export function observeLease(lockPath) {
97
- let raw;
98
- try {
99
- raw = readFileSync(lockPath, "utf8");
124
+ const read = readRegularPathIfPresent(lockPath);
125
+ if (read.kind === "absent") {
126
+ return { kind: "absent" };
100
127
  }
101
- catch (error) {
102
- if (error.code === "ENOENT") {
103
- return { kind: "absent" };
104
- }
105
- throw error;
128
+ if (read.kind === "refused") {
129
+ throw new Error(read.reason);
106
130
  }
107
- return { kind: "present", raw, lease: parseLease(raw) };
131
+ return { kind: "present", raw: read.body, lease: parseLease(read.body) };
132
+ }
133
+ /**
134
+ * THE ONE EXPIRY COMPARISON IN THE KERNEL (M4-P17 criterion 2).
135
+ *
136
+ * `isExpired` below needs a whole `Lease`, and doctor's lock check does not
137
+ * have one: it reads the lease file defensively and holds only `holderId` and
138
+ * `expiresAt`, because a lease file that fails `parseLease` must still produce
139
+ * a diagnosis rather than nothing. Before this phase that forced doctor to
140
+ * carry its own `Date.parse(...) <= Date.now()`, which is a SECOND comparison
141
+ * of the same property, free to drift from this one. The boundary is where
142
+ * that drift shows: `<=` makes expiry INCLUSIVE, so a lease whose `expiresAt`
143
+ * is exactly the current millisecond is expired, and a second copy written
144
+ * with `<` disagrees for exactly one millisecond and agrees everywhere else.
145
+ * A disagreement that narrow is not something a reviewer finds by reading.
146
+ *
147
+ * So the comparison lives here once and both callers reach it.
148
+ */
149
+ export function expiryHasPassed(expiresAt, nowMs) {
150
+ return Date.parse(expiresAt) <= nowMs;
108
151
  }
109
152
  export function isExpired(lease, nowMs) {
110
- return Date.parse(lease.expiresAt) <= nowMs;
153
+ return expiryHasPassed(lease.expiresAt, nowMs);
111
154
  }
112
155
  /** The renew-by deadline (half-life of the current term), for holders. */
113
156
  export function renewByMs(lease) {
114
157
  return Date.parse(lease.expiresAt) - (lease.durationSeconds * 1000) / 2;
115
158
  }
159
+ /** The same guarded read as observeLease, for the inside of the claim. */
116
160
  function readCurrent(lockPath) {
117
- try {
118
- return { present: true, raw: readFileSync(lockPath, "utf8") };
161
+ const read = readRegularPathIfPresent(lockPath);
162
+ if (read.kind === "absent") {
163
+ return { present: false, raw: "" };
119
164
  }
120
- catch (error) {
121
- if (error.code === "ENOENT") {
122
- return { present: false, raw: "" };
123
- }
124
- throw error;
165
+ if (read.kind === "refused") {
166
+ throw new Error(read.reason);
125
167
  }
168
+ return { present: true, raw: read.body };
126
169
  }
127
170
  /**
128
171
  * Re-read the lock file and re-compare it against the state a mutation
@@ -265,6 +308,16 @@ export async function applyLeaseMutation(lockPath, observed, next, token) {
265
308
  // lock path use the same discipline, which is the asymmetry the
266
309
  // module previously left unjustified.
267
310
  const stagePath = stagePathFor(lockPath);
311
+ /* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
312
+ measurement rather than an oversight. open(2) for writing on a FIFO
313
+ blocks exactly as reading one does, so this looked like a member of
314
+ the class this round closed. It is not: the claim-held sweep above
315
+ (CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
316
+ writes it, so a planted FIFO is gone by the time this line runs.
317
+ Measured at this head with a real mkfifo at
318
+ `state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
319
+ under a second and the lease is taken. A guard here would therefore
320
+ be code no witness can redden. */
268
321
  writeFileSync(stagePath, next);
269
322
  try {
270
323
  linkSync(stagePath, lockPath);
@@ -299,6 +352,16 @@ export async function applyLeaseMutation(lockPath, observed, next, token) {
299
352
  // confirmation read both still happen inside the claim, and the
300
353
  // rename remains atomic within one directory.
301
354
  const stagePath = stagePathFor(lockPath);
355
+ /* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
356
+ measurement rather than an oversight. open(2) for writing on a FIFO
357
+ blocks exactly as reading one does, so this looked like a member of
358
+ the class this round closed. It is not: the claim-held sweep above
359
+ (CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
360
+ writes it, so a planted FIFO is gone by the time this line runs.
361
+ Measured at this head with a real mkfifo at
362
+ `state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
363
+ under a second and the lease is taken. A guard here would therefore
364
+ be code no witness can redden. */
302
365
  writeFileSync(stagePath, next);
303
366
  try {
304
367
  renameSync(stagePath, lockPath);
@@ -343,6 +406,100 @@ export async function applyLeaseMutation(lockPath, observed, next, token) {
343
406
  }
344
407
  }
345
408
  }
409
+ /**
410
+ * Resolve the fleet home's declaration. The ONLY entry into the shared
411
+ * layer, and the only place a fleet root is derived: a lock path is
412
+ * `<root>/state/orchestrator.lock` (src/fleet.ts:37), so the root is two
413
+ * directories up unless a caller names it. A declaration that is present
414
+ * and unusable is reported rather than treated as absent, because reading a
415
+ * typo as "off" is the failure that would make this layer green everywhere.
416
+ */
417
+ function sharedGate(lockPath, fleetRoot) {
418
+ const root = fleetRoot ?? fleetRootForLockPath(lockPath);
419
+ const declaration = readSharedExclusion(root);
420
+ if (declaration.kind === "absent") {
421
+ return { kind: "off" };
422
+ }
423
+ if (declaration.kind === "invalid") {
424
+ return { kind: "invalid", reason: declaration.reason };
425
+ }
426
+ const identity = ensureEnvironmentId(root);
427
+ return {
428
+ kind: "on",
429
+ ctx: { config: declaration.config, envId: identity.envId, fleetRoot: root },
430
+ };
431
+ }
432
+ function invalidDeclarationOutcome(reason) {
433
+ return {
434
+ ok: false,
435
+ reason: `shared exclusion is declared and unusable, refusing rather than running ` +
436
+ `local-only: ${reason}`,
437
+ };
438
+ }
439
+ /** Ask the shared register for a verdict, without touching anything. */
440
+ function sharedPreflight(ctx, intent, takeover, nowMs) {
441
+ return preflightShared({
442
+ fleetRoot: ctx.fleetRoot,
443
+ config: ctx.config,
444
+ envId: ctx.envId,
445
+ intent,
446
+ takeover,
447
+ nowMs,
448
+ });
449
+ }
450
+ function sharedNote(ctx, preflight) {
451
+ return { line: preflight.line, envId: ctx.envId };
452
+ }
453
+ /**
454
+ * Publish the won verdict to the register. Called only AFTER the local
455
+ * mutation succeeded, so the two layers agree or the local one is rolled
456
+ * back by the caller: a register entry with no local lease behind it would
457
+ * exclude every environment including the one that wrote it.
458
+ */
459
+ function sharedCommit(ctx, preflight, state, nowMs, durationSeconds) {
460
+ const document = buildRegisterDocument({
461
+ state,
462
+ envId: ctx.envId,
463
+ counter: preflight.nextCounter,
464
+ nowMs,
465
+ durationSeconds,
466
+ ref: ctx.config.ref,
467
+ ...(preflight.current !== undefined && state === "held" && !preflight.takingOver
468
+ ? { acquiredAt: preflight.current.acquiredAt }
469
+ : {}),
470
+ });
471
+ const outcome = casWrite(ctx.fleetRoot, ctx.config, preflight.expectedSha, document);
472
+ if (outcome.kind === "won") {
473
+ return { ok: true, sha: outcome.sha };
474
+ }
475
+ return { ok: false, reason: casFailureLine(ctx.config.ref, outcome) };
476
+ }
477
+ /**
478
+ * UNDO A LOCAL LEASE MUTATION WHOSE REGISTER PUBLISH LOST.
479
+ *
480
+ * `current` is what the lock file holds NOW (the state this mutation left),
481
+ * and `restore` is the observation the mutation was decided against, whose
482
+ * raw bytes are written back verbatim so the restored file is BYTE-IDENTICAL
483
+ * to the one the command claimed not to have changed.
484
+ *
485
+ * The token handed to the primitive is the RESTORED lease's own token, not a
486
+ * fresh one: the primitive confirms an application by re-reading the token in
487
+ * the file it just wrote, and a fresh token would fail that confirmation
488
+ * against bytes that carry the old one.
489
+ *
490
+ * Returns the sentence appended to the failure line. A rollback that ITSELF
491
+ * loses is stated rather than swallowed, because "the local lease was
492
+ * restored" is a claim a later reader will act on.
493
+ */
494
+ async function restoreLocal(lockPath, current, restore) {
495
+ if (restore.lease === undefined) {
496
+ return "; the local lease could NOT be restored: the observed lease does not parse";
497
+ }
498
+ const undone = await applyLeaseMutation(lockPath, current, restore.raw, restore.lease.token);
499
+ return undone.won
500
+ ? "; the local lease was restored"
501
+ : `; the local lease could NOT be restored (${undone.reason}), so ${lockPath} and the register now disagree`;
502
+ }
346
503
  function buildLease(nowMs, durationSeconds) {
347
504
  return {
348
505
  holderId: randomUUID(),
@@ -361,6 +518,27 @@ function buildLease(nowMs, durationSeconds) {
361
518
  export async function acquireLease(lockPath, options = {}) {
362
519
  const nowMs = options.nowMs ?? Date.now();
363
520
  const durationSeconds = options.durationSeconds ?? DEFAULT_LEASE_DURATION_SECONDS;
521
+ /* THE SHARED LAYER RUNS FIRST AND MUTATES NOTHING (M4-P21 criteria 1 and
522
+ 7). Off unless declared, so the read below is the whole cost for every
523
+ fleet that has not opted in. A refusal returns here, before the local
524
+ lease file can be created, which is the fail-closed property: an
525
+ unreachable register leaves no local lock behind to be mistaken for
526
+ exclusion that is not there. */
527
+ const gate = sharedGate(lockPath, options.fleetRoot);
528
+ if (gate.kind === "invalid") {
529
+ return invalidDeclarationOutcome(gate.reason);
530
+ }
531
+ let preflight;
532
+ if (gate.kind === "on") {
533
+ preflight = sharedPreflight(gate.ctx, "acquire", options.takeover === true, nowMs);
534
+ if (preflight.kind === "refused") {
535
+ return {
536
+ ok: false,
537
+ reason: preflight.line,
538
+ shared: sharedNote(gate.ctx, preflight),
539
+ };
540
+ }
541
+ }
364
542
  const observed = options.observed ?? observeLease(lockPath);
365
543
  if (observed.kind === "present") {
366
544
  if (observed.lease === undefined) {
@@ -397,6 +575,31 @@ export async function acquireLease(lockPath, options = {}) {
397
575
  }
398
576
  return { ok: false, reason: `lock held (${result.reason})` };
399
577
  }
578
+ if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
579
+ const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
580
+ if (!published.ok) {
581
+ /* The register refused after the local lease was written, so the local
582
+ lease is rolled back through the SAME mutation primitive. Leaving it
583
+ would make this environment believe it holds a fleet another
584
+ environment holds, which is the exact state this layer exists to
585
+ prevent. */
586
+ await applyLeaseMutation(lockPath, { kind: "present", raw: renderLease(lease), lease }, null, randomUUID());
587
+ const line = `shared exclusion refused acquire: ${published.reason}; the local lease was rolled back`;
588
+ return {
589
+ ok: false,
590
+ reason: line,
591
+ shared: { line, envId: gate.ctx.envId },
592
+ };
593
+ }
594
+ return {
595
+ ok: true,
596
+ lease,
597
+ shared: {
598
+ line: `${preflight.line}; register now ${published.sha}`,
599
+ envId: gate.ctx.envId,
600
+ },
601
+ };
602
+ }
400
603
  return { ok: true, lease };
401
604
  }
402
605
  /**
@@ -408,6 +611,21 @@ export async function acquireLease(lockPath, options = {}) {
408
611
  */
409
612
  export async function renewLease(lockPath, holderId, options = {}) {
410
613
  const nowMs = options.nowMs ?? Date.now();
614
+ const gate = sharedGate(lockPath, options.fleetRoot);
615
+ if (gate.kind === "invalid") {
616
+ return invalidDeclarationOutcome(gate.reason);
617
+ }
618
+ let preflight;
619
+ if (gate.kind === "on") {
620
+ preflight = sharedPreflight(gate.ctx, "renew", false, nowMs);
621
+ if (preflight.kind === "refused") {
622
+ return {
623
+ ok: false,
624
+ reason: preflight.line,
625
+ shared: sharedNote(gate.ctx, preflight),
626
+ };
627
+ }
628
+ }
411
629
  const observed = options.observed ?? observeLease(lockPath);
412
630
  if (observed.kind === "absent") {
413
631
  return { ok: false, reason: "renew refused: no lease present" };
@@ -446,6 +664,36 @@ export async function renewLease(lockPath, holderId, options = {}) {
446
664
  }
447
665
  return { ok: false, reason: `renew ${result.reason}` };
448
666
  }
667
+ if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
668
+ /* A RENEW ADVANCES THE FENCING COUNTER, and that is the whole of what
669
+ makes another environment's staleness judgement safe under clock skew
670
+ (criterion 5). The observer sees an increment, never a timestamp. */
671
+ const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
672
+ if (!published.ok) {
673
+ /* THE LOCAL LAYER IS ROLLED BACK, exactly as acquire's failure arm
674
+ does it and through the SAME primitive. src/lock.ts:537 states the
675
+ invariant: the two layers agree, or the local one is undone by the
676
+ caller. Until this round `acquireLease` was the only one of the
677
+ three publishing callers that honoured it, so a failed renew
678
+ reported failure while the lease file had ALREADY been extended
679
+ under a new token, and a failed release reported failure while the
680
+ lease file had already been DELETED. */
681
+ const line = `shared exclusion refused renew: ${published.reason}${await restoreLocal(lockPath, { kind: "present", raw: renderLease(lease), lease }, observed)}`;
682
+ return {
683
+ ok: false,
684
+ reason: line,
685
+ shared: { line, envId: gate.ctx.envId },
686
+ };
687
+ }
688
+ return {
689
+ ok: true,
690
+ lease,
691
+ shared: {
692
+ line: `${preflight.line}; register now ${published.sha}`,
693
+ envId: gate.ctx.envId,
694
+ },
695
+ };
696
+ }
449
697
  return { ok: true, lease };
450
698
  }
451
699
  /**
@@ -456,6 +704,27 @@ export async function renewLease(lockPath, holderId, options = {}) {
456
704
  * staged before a takeover completes loses to it.
457
705
  */
458
706
  export async function releaseLease(lockPath, holderId, options = {}) {
707
+ const nowMs = options.nowMs ?? Date.now();
708
+ const gate = sharedGate(lockPath, options.fleetRoot);
709
+ if (gate.kind === "invalid") {
710
+ return invalidDeclarationOutcome(gate.reason);
711
+ }
712
+ let preflight;
713
+ if (gate.kind === "on") {
714
+ /* CRITERION 8. A release by a non-holder is refused HERE, before any
715
+ write, so the register sha is byte-identical before and after. The
716
+ refusal is the register's own comparison, not a local one: a clone
717
+ whose local lease says it holds the fleet still loses to a register
718
+ that names another environment. */
719
+ preflight = sharedPreflight(gate.ctx, "release", false, nowMs);
720
+ if (preflight.kind === "refused") {
721
+ return {
722
+ ok: false,
723
+ reason: preflight.line,
724
+ shared: sharedNote(gate.ctx, preflight),
725
+ };
726
+ }
727
+ }
459
728
  const observed = options.observed ?? observeLease(lockPath);
460
729
  if (observed.kind === "absent") {
461
730
  return { ok: false, reason: "release refused: no lease present" };
@@ -479,6 +748,29 @@ export async function releaseLease(lockPath, holderId, options = {}) {
479
748
  }
480
749
  return { ok: false, reason: `release ${result.reason}` };
481
750
  }
751
+ if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
752
+ const published = sharedCommit(gate.ctx, preflight, "free", nowMs, observed.lease.durationSeconds);
753
+ if (!published.ok) {
754
+ /* See renewLease above: the local lease was REMOVED before this
755
+ publish was attempted, and the holdership guard keys on that file's
756
+ PRESENCE, so leaving it removed reopens the dual-writer window
757
+ M1-P4 criterion 12 closed. */
758
+ const line = `shared exclusion could not publish the release: ${published.reason}${await restoreLocal(lockPath, { kind: "absent" }, observed)}`;
759
+ return {
760
+ ok: false,
761
+ reason: line,
762
+ shared: { line, envId: gate.ctx.envId },
763
+ };
764
+ }
765
+ return {
766
+ ok: true,
767
+ lease: null,
768
+ shared: {
769
+ line: `${preflight.line}; register now ${published.sha}`,
770
+ envId: gate.ctx.envId,
771
+ },
772
+ };
773
+ }
482
774
  return { ok: true, lease: null };
483
775
  }
484
776
  /** Report the lock state; reading only, never mutating (always safe). */
@@ -0,0 +1,159 @@
1
+ import type { Fleet } from "./fleet.ts";
2
+ /**
3
+ * THE MODEL-RESOLUTION RECORD, KERNEL SIDE (kernel plan M4, M4-P7;
4
+ * delivery/plan/kernel-plan-m4.md:1018, discharging M4-D-06's four parts at
5
+ * delivery/plan/m4-intake.md:732).
6
+ *
7
+ * WHAT THIS MODULE IS AND, MORE IMPORTANTLY, WHAT IT IS NOT. It READS a
8
+ * record a harness adapter wrote. It holds NO vocabulary: no tier-to-model
9
+ * table, no model-to-family table, and no vendor name of any kind. The whole
10
+ * vocabulary lives in the plugin (plugin/src/vocabulary.ts) and this module
11
+ * dereferences a vocabulary's IDENTITY and never its CONTENT. That single
12
+ * property is what makes a second harness plugin possible at all: a mapping
13
+ * here would close off every harness that is not the one it names
14
+ * (delivery/plan/m4-intake.md:377), and `test/schemas.test.ts` already asserts
15
+ * the absence over the whole shipped surface rather than trusting this
16
+ * paragraph.
17
+ *
18
+ * THE ORDER OF THE CHECKS IS PART OF THE CONTRACT, and it is the release
19
+ * record's order (src/gates/release.ts): the SUBJECT ECHO is compared field by
20
+ * field BEFORE anything reads the resolved identity. A record about another
21
+ * task is not a record with a wrong answer, it is a record about something
22
+ * else, and reading its answer first is how a misattributed family token
23
+ * reaches a decorrelation check as though it belonged there.
24
+ *
25
+ * WHY `observed` IS NOT `attested`, stated here because the vocabulary is the
26
+ * thing most likely to be widened later by someone who did not read M4-P1.
27
+ * The probe measured that the observation channel is harness-written, that it
28
+ * can be EMPTY at hook time under load, and that a process at the agent's own
29
+ * uid can rewrite it in place so the result is byte-shaped exactly like the
30
+ * truth (delivery/verification/m4-prototype-probes.md:410). So `observed`
31
+ * ranks above `self-reported`, which costs nothing to forge, and below any
32
+ * notion of attestation, which would need a signer this environment has not
33
+ * been shown to have.
34
+ */
35
+ /** The record's file name inside the task directory. */
36
+ export declare const MODEL_RESOLUTION_RECORD_NAME = "model-resolution.json";
37
+ /** The contract versions this kernel accepts. */
38
+ export declare const MODEL_RESOLUTION_CONTRACT_VERSIONS: readonly string[];
39
+ /**
40
+ * Where the record lives: `tasks/<id>/model-resolution.json`, NEVER inside the
41
+ * worktree (M4-P7 criterion 5, src/task.ts:31 and FM-059). The task directory
42
+ * sits outside the worktree precisely so the pool's dirty check needs no
43
+ * exemption list for the kernel's own injected files, and a record written
44
+ * into the worktree would be indistinguishable from the agent's own work.
45
+ */
46
+ export declare function modelResolutionPath(fleet: Fleet, taskId: string): string;
47
+ /** The launch request fields the record's subject echo is compared against. */
48
+ export interface ModelResolutionSubject {
49
+ taskId: string;
50
+ role: string;
51
+ requestedTier: string;
52
+ }
53
+ export interface VocabularyIdentity {
54
+ id: string;
55
+ version: number;
56
+ }
57
+ /** What one read of the record produced. */
58
+ export type ModelResolutionRead = {
59
+ kind: "read";
60
+ record: Record<string, unknown>;
61
+ } | {
62
+ kind: "error";
63
+ reason: string;
64
+ };
65
+ /** What one acceptance decision produced. */
66
+ export type ModelResolutionAcceptance = {
67
+ kind: "accepted";
68
+ family: string | undefined;
69
+ provenance: string;
70
+ } | {
71
+ kind: "refused";
72
+ reason: string;
73
+ };
74
+ /** What one cross-record family comparison produced. */
75
+ export type FamilyComparison = {
76
+ kind: "compared";
77
+ differ: boolean;
78
+ families: [string, string];
79
+ } | {
80
+ kind: "refused";
81
+ reason: string;
82
+ };
83
+ /**
84
+ * Read the record for a task.
85
+ *
86
+ * AN ABSENT RECORD IS AN ERROR AND IS NEVER GREEN AND NEVER NOT-APPLICABLE
87
+ * (M4-P7 criterion 5). This is not a new rule and deliberately not a second
88
+ * implementation of one: src/gates/release.ts:609 already produces the
89
+ * sentence for the same hazard on the release seam, and the wording is kept
90
+ * in the same shape so that a reader who has met one meets the other.
91
+ * `test/model-resolution.test.ts` drives the real release gate to capture that
92
+ * sentence and derives its assertion from the capture, rather than pinning a
93
+ * hand-written copy of it.
94
+ *
95
+ * THE READ GOES THROUGH `readRegularFileIfPresent`, which is M2-C-6 and the
96
+ * mechanism-index row `reading-a-path-whose-type-is-not-established`: a named
97
+ * pipe at this path is a REPORTED refusal, never a blocked open.
98
+ */
99
+ export declare function readModelResolutionRecord(path: string, writerExitCode: number): ModelResolutionRead;
100
+ /**
101
+ * The vocabulary IDENTITY of a record, and nothing else about the vocabulary.
102
+ *
103
+ * TWO FIELDS ARE READ AND THEY ARE NAMED HERE RATHER THAN SPREAD, which is
104
+ * the whole of criterion 4's mechanical half. A spread, an `Object.keys`, a
105
+ * `JSON.stringify` or a `structuredClone` of the vocabulary object would
106
+ * ENUMERATE it, and enumeration is dereferencing the content whatever the
107
+ * intent. `test/model-resolution.test.ts` hands this function a Proxy that
108
+ * records every property read and asserts the recorded set against these two
109
+ * names, so a later edit that reaches for a third reddens.
110
+ */
111
+ export declare function vocabularyIdentity(record: Record<string, unknown>): VocabularyIdentity | undefined;
112
+ /**
113
+ * Accept or refuse one record against the launch request the kernel holds.
114
+ *
115
+ * FOUR REFUSALS, IN THIS ORDER, and the order is the point.
116
+ *
117
+ * 1. THE CONTRACT VERSION, because a record written to a contract this kernel
118
+ * does not know is not a record this kernel can read fields out of.
119
+ * 2. THE SUBJECT ECHO, FIELD BY FIELD, BEFORE THE OUTCOME IS READ. This is
120
+ * src/gates/schemas/release-record.schema.json:26's misattribution guard.
121
+ * 3. THE VOCABULARY IDENTITY, because a family token with no vocabulary is a
122
+ * token nobody can say the meaning of.
123
+ * 4. THE PROVENANCE, which is M4-P7 criterion 6 member TWO. Member one (a
124
+ * record claiming `observed` with no observation at all) is the schema's
125
+ * and is a missing field. THIS is the present-but-inconsistent half, and a
126
+ * schema-only guard passes it green: an observation about another task, or
127
+ * naming a model other than the one the record claims, is a self-report
128
+ * with a decoration on it.
129
+ */
130
+ export declare function acceptModelResolution(record: Record<string, unknown>, subject: ModelResolutionSubject): ModelResolutionAcceptance;
131
+ /**
132
+ * Compare two accepted records' family tokens (M4-P7 criterion 3).
133
+ *
134
+ * TWO VOCABULARIES ARE NOT COMPARABLE AND THE REFUSAL NAMES BOTH IDS. A family
135
+ * token means whatever the vocabulary that minted it says it means, so
136
+ * comparing a token from one against a token from another is comparing two
137
+ * strings and calling the result a fact about models. That comparison is what
138
+ * `check-dual-review`'s decorrelation assertion rests on, and DR-0038 exists
139
+ * for exactly the environment where the two reviews come from different
140
+ * places, so a silent cross-vocabulary compare would make the assertion
141
+ * meaningless precisely where it is load-bearing.
142
+ *
143
+ * ONLY `id` IS READ HERE. The version is part of the identity a reader may
144
+ * print and is deliberately NOT part of the comparison: a vocabulary that
145
+ * added a family in v2 did not change what its v1 tokens mean, and refusing
146
+ * on version would turn a routine vocabulary revision into an outage.
147
+ */
148
+ export declare function compareResolvedFamilies(left: Record<string, unknown>, right: Record<string, unknown>): FamilyComparison;
149
+ /**
150
+ * The value closeout copies into a verdict's `produced-by` (M4-P7 criterion 8).
151
+ *
152
+ * VERBATIM, AND THE FUNCTION EXISTS SO THAT "VERBATIM" IS TESTABLE. DR-0031
153
+ * requires a pull request to carry all its own evidence, so the family token
154
+ * has to reach the verdict document rather than being left in a task
155
+ * directory that no reviewer of the pull request can see. A copy that
156
+ * normalised, lowercased or prefixed the token would satisfy every reading of
157
+ * that sentence and would break the one comparison the token exists for.
158
+ */
159
+ export declare function producedByFromRecord(record: Record<string, unknown>): string | undefined;