@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
@@ -0,0 +1,843 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { LOCK_FILE, readRegularPathIfPresent, refuseOpenPathForWrite, } from "./fleet.js";
6
+ /**
7
+ * THE SHARED EXCLUSION REGISTER (kernel plan M4, M4-P21).
8
+ *
9
+ * `src/lock.ts` states its own exclusion domain honestly at src/lock.ts:63:
10
+ * the lease excludes within ONE filesystem and ONE clock. Two environments
11
+ * that clone one fleet remote each get their own `state/orchestrator.lock`
12
+ * and BOTH acquire it, because `state/` is gitignored (src/fleet.ts:28) so
13
+ * the lease artifact never travels. M4-P20 measured that dangerous state and
14
+ * committed the captures; this module is the second exclusion layer that
15
+ * closes it.
16
+ *
17
+ * WHAT THE REGISTER IS. A compare-and-swap register on a dedicated git ref
18
+ * of the fleet's shared remote. The ref's value is a commit whose only file
19
+ * is `lease.json`; a write is `git push --force-with-lease=<ref>:<exact sha>`
20
+ * and the remote decides the race. M4-D-11 was PROTOTYPE-BLOCKED and M4-P20's
21
+ * probe closed it (delivery/verification/cross-environment-exclusion-probe.md:1).
22
+ * Three of that probe's findings are load-bearing here and are implemented
23
+ * rather than remembered:
24
+ *
25
+ * 1. ONLY `refs/heads/*` IS PUSHABLE. Tags, notes and custom namespaces are
26
+ * refused with HTTP 403 (CLAUDE.md standing warning 14, re-measured for
27
+ * this phase). So the "dedicated ref" is a dedicated BRANCH and the
28
+ * default is `refs/heads/tiphys/lease`, which is visible in branch
29
+ * listings and subject to any `refs/heads/**` ruleset.
30
+ * 2. THE EXPECTATION MUST BE AN EXPLICIT SHA. The bare `--force-with-lease`
31
+ * form takes its expectation from the remote-tracking ref, so a routine
32
+ * fetch re-arms it; the probe measured it CLOBBERING a live holder with
33
+ * exit 0. `casWrite` below never emits the bare form.
34
+ * 3. NONZERO DOES NOT MEAN "I LOST". A transport failure exits 1 too, so
35
+ * the result has THREE states and the indeterminate one is resolved by
36
+ * re-reading the register rather than assumed either way.
37
+ *
38
+ * WHY THE STALENESS SIGNAL IS A COUNTER AND NOT A CLOCK (M4-P21 criterion 6).
39
+ * Two environments bring two clocks and `isExpired` compares a lease
40
+ * timestamp against the local one (src/lock.ts:171). Comparing one
41
+ * environment's wall clock against another's is exactly the measurement this
42
+ * layer must not make. So the register carries a MONOTONIC FENCING COUNTER
43
+ * that every write increments, and a holder is judged stale only when that
44
+ * counter has not moved across a duration measured entirely on the OBSERVING
45
+ * environment's own clock (two readings of one clock, never one reading of
46
+ * two). Where the register is reachable the counter decides and the command
47
+ * says `signal=counter`. Where it is not reachable there is no counter to
48
+ * read, only the local clock, and a clock is not a cross-environment signal:
49
+ * the command says `signal=clock` and REFUSES rather than falling back to
50
+ * local-only exclusion, which is the vacuous green this layer exists to
51
+ * prevent (criterion 7).
52
+ *
53
+ * C-2 (binding): nothing here reads a run identifier of a running program,
54
+ * probes liveness, sends a signal, or reads the kernel's virtual filesystem.
55
+ * Environment identity is a random id written once to a TRACKED fleet file
56
+ * (criterion 3), so it survives a reclaim and travels with a clone of the
57
+ * fleet; exclusion is decided by the register's counter and by git's own
58
+ * compare-and-swap verdict, never by anything about a machine.
59
+ *
60
+ * DECLARATION, NOT INFERENCE (criterion 1). The layer is entered only when
61
+ * the fleet home's own `package.json` declares it. With the field absent
62
+ * every function here returns "absent" before any subprocess is spawned, so
63
+ * a fleet that cannot reach a remote is not forced to switch the layer off
64
+ * globally and today's behaviour is unchanged for everyone else.
65
+ */
66
+ /** The dotted path of the opt-in field inside the fleet home's package.json. */
67
+ export const SHARED_EXCLUSION_FIELD = "tiphys.sharedExclusion";
68
+ /** The remote a declaration defaults to. */
69
+ export const DEFAULT_SHARED_REMOTE = "origin";
70
+ /**
71
+ * The register ref a declaration defaults to. A BRANCH, deliberately: see
72
+ * finding 1 in this file's header. The plan's prose names `refs/tiphys/lease`
73
+ * and that namespace is refused by the shared remote this kernel is built
74
+ * against, so the default is the pushable form and the ref stays
75
+ * configurable for a remote with different rules.
76
+ */
77
+ export const DEFAULT_SHARED_REF = "refs/heads/tiphys/lease";
78
+ /** The file inside the register commit that carries the lease document. */
79
+ export const REGISTER_DOCUMENT_NAME = "lease.json";
80
+ /**
81
+ * The TRACKED fleet file carrying this environment's identity (criterion 3).
82
+ * It sits at the fleet root, outside the gitignored set at src/fleet.ts:28,
83
+ * so an environment that commits it keeps its identity across a reclaim and
84
+ * a clone of that commit reads the same id.
85
+ */
86
+ export const ENVIRONMENT_ID_FILE = "tiphys-environment.json";
87
+ /**
88
+ * Where this environment records what it last saw in the register. It lives
89
+ * under the gitignored `state/` prefix ON PURPOSE: it is a measurement taken
90
+ * on THIS environment's clock and it must never travel, or the duration it
91
+ * carries would be compared against a clock that did not produce it.
92
+ */
93
+ export const OBSERVATION_FILE = join("state", "shared-lease.observed.json");
94
+ /** How long the counter must stand still before a holder is judged stale. */
95
+ export const DEFAULT_STALE_WINDOW_SECONDS = 900;
96
+ /* ------------------------------------------------------------------ */
97
+ /* Declaration */
98
+ /* ------------------------------------------------------------------ */
99
+ /**
100
+ * THE ENTRY TYPE IS ESTABLISHED BEFORE THE OPEN. This function reads the
101
+ * fleet `package.json` and `tiphys-environment.json`, both of them paths
102
+ * this module did not create, and a bare `readFileSync` on a named pipe at
103
+ * either blocked every lock subcommand forever with zero output. A
104
+ * non-regular entry is now a REFUSAL naming the observed type, in the same
105
+ * words doctor already uses, and it is NOT `absent`: reading "this cannot be
106
+ * opened" as "the layer is off here" is exactly the shape that makes a guard
107
+ * green everywhere and protective nowhere.
108
+ */
109
+ function readJsonFile(path) {
110
+ const read = readRegularPathIfPresent(path);
111
+ if (read.kind === "absent") {
112
+ return { ok: false, absent: true, reason: `${path} is absent` };
113
+ }
114
+ if (read.kind === "refused") {
115
+ return { ok: false, absent: false, reason: read.reason };
116
+ }
117
+ try {
118
+ return { ok: true, value: JSON.parse(read.body) };
119
+ }
120
+ catch (error) {
121
+ return { ok: false, absent: false, reason: `${path} does not parse as JSON: ${String(error)}` };
122
+ }
123
+ }
124
+ function positiveNumber(value, fallback) {
125
+ if (value === undefined) {
126
+ return fallback;
127
+ }
128
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
129
+ return undefined;
130
+ }
131
+ return value;
132
+ }
133
+ function nonEmptyString(value, fallback) {
134
+ if (value === undefined) {
135
+ return fallback;
136
+ }
137
+ if (typeof value !== "string" || value === "") {
138
+ return undefined;
139
+ }
140
+ return value;
141
+ }
142
+ /**
143
+ * Read the fleet home's declaration. Absent means the layer is off and no
144
+ * subprocess is spawned anywhere below; `false` is the same answer written
145
+ * explicitly. A field that is present and unreadable is INVALID rather than
146
+ * absent, because silently treating a typo as "off" is the shape that makes
147
+ * a guard green everywhere and protective nowhere.
148
+ */
149
+ export function readSharedExclusion(fleetRoot) {
150
+ const read = readJsonFile(join(fleetRoot, "package.json"));
151
+ if (!read.ok) {
152
+ if (read.absent) {
153
+ return { kind: "absent" };
154
+ }
155
+ return { kind: "invalid", reason: read.reason };
156
+ }
157
+ const root = read.value;
158
+ if (root === null || typeof root !== "object") {
159
+ return { kind: "absent" };
160
+ }
161
+ const section = root["tiphys"];
162
+ if (section === undefined) {
163
+ return { kind: "absent" };
164
+ }
165
+ if (section === null || typeof section !== "object" || Array.isArray(section)) {
166
+ return { kind: "invalid", reason: `${SHARED_EXCLUSION_FIELD}: the "tiphys" section must be an object` };
167
+ }
168
+ const declared = section["sharedExclusion"];
169
+ if (declared === undefined || declared === false) {
170
+ return { kind: "absent" };
171
+ }
172
+ const raw = declared === true ? {} : declared;
173
+ if (declared !== true && (raw === null || typeof raw !== "object" || Array.isArray(raw))) {
174
+ return {
175
+ kind: "invalid",
176
+ reason: `${SHARED_EXCLUSION_FIELD} must be true or an object, not ${JSON.stringify(declared)}`,
177
+ };
178
+ }
179
+ const remote = nonEmptyString(raw["remote"], DEFAULT_SHARED_REMOTE);
180
+ const ref = nonEmptyString(raw["ref"], DEFAULT_SHARED_REF);
181
+ const staleWindowSeconds = positiveNumber(raw["staleWindowSeconds"], DEFAULT_STALE_WINDOW_SECONDS);
182
+ if (remote === undefined || ref === undefined || staleWindowSeconds === undefined) {
183
+ return {
184
+ kind: "invalid",
185
+ reason: `${SHARED_EXCLUSION_FIELD} carries an unusable remote, ref or ` +
186
+ `staleWindowSeconds: ${JSON.stringify(declared)}`,
187
+ };
188
+ }
189
+ if (!ref.startsWith("refs/")) {
190
+ return {
191
+ kind: "invalid",
192
+ reason: `${SHARED_EXCLUSION_FIELD}.ref must be a full ref name, got ${ref}`,
193
+ };
194
+ }
195
+ return { kind: "declared", config: { remote, ref, staleWindowSeconds } };
196
+ }
197
+ /** The fleet root a lock path belongs to: <root>/state/orchestrator.lock. */
198
+ export function fleetRootForLockPath(lockPath) {
199
+ return dirname(dirname(lockPath));
200
+ }
201
+ /**
202
+ * Read the environment id, or generate one and write it. The id is random
203
+ * and is generated EXACTLY ONCE per fleet home: every later call reads the
204
+ * file. Nothing about the machine enters it.
205
+ */
206
+ export function ensureEnvironmentId(fleetRoot) {
207
+ const path = join(fleetRoot, ENVIRONMENT_ID_FILE);
208
+ const read = readJsonFile(path);
209
+ if (read.ok) {
210
+ const value = read.value;
211
+ const existing = value === null ? undefined : value["envId"];
212
+ if (typeof existing === "string" && existing !== "") {
213
+ return { envId: existing, generated: false, path };
214
+ }
215
+ }
216
+ const envId = randomUUID();
217
+ const document = {
218
+ envId,
219
+ purpose: "Tiphys environment identity for the shared exclusion register. Random, " +
220
+ "generated once, and tracked so it survives a reclaim. It says nothing " +
221
+ "about any machine.",
222
+ };
223
+ const refusal = refuseOpenPathForWrite(path);
224
+ if (refusal !== undefined) {
225
+ throw new Error(refusal);
226
+ }
227
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, "utf8");
228
+ return { envId, generated: true, path };
229
+ }
230
+ /* ------------------------------------------------------------------ */
231
+ /* git plumbing */
232
+ /* ------------------------------------------------------------------ */
233
+ /**
234
+ * THE COMMAND-SCOPED IDENTITY THE REGISTER COMMIT IS WRITTEN UNDER.
235
+ *
236
+ * `git commit-tree` REFUSES without an author, and CI runners carry no git
237
+ * identity (CLAUDE.md standing warning 5), so a register write that relied
238
+ * on ambient configuration would work on a developer's machine and fail on
239
+ * every runner. These are the same two strings `tiphys init` already uses
240
+ * for the fleet bootstrap commit (EXT-F-02 option B): set as command-scoped
241
+ * environment variables on the invocation only, never written to user or
242
+ * global git configuration. They are repeated here rather than imported
243
+ * because `src/commands/init.ts` imports THIS module for the opt-in field,
244
+ * and a test pins the two copies equal so a drift reddens instead of
245
+ * surfacing as a runner-only failure.
246
+ */
247
+ export const REGISTER_IDENTITY_NAME = "Tiphys Fleet";
248
+ export const REGISTER_IDENTITY_EMAIL = "fleet@tiphys.invalid";
249
+ function git(cwd, args, input) {
250
+ const result = spawnSync("git", ["-C", cwd, ...args], {
251
+ encoding: "utf8",
252
+ input,
253
+ env: {
254
+ ...process.env,
255
+ GIT_TERMINAL_PROMPT: "0",
256
+ GIT_AUTHOR_NAME: REGISTER_IDENTITY_NAME,
257
+ GIT_AUTHOR_EMAIL: REGISTER_IDENTITY_EMAIL,
258
+ GIT_COMMITTER_NAME: REGISTER_IDENTITY_NAME,
259
+ GIT_COMMITTER_EMAIL: REGISTER_IDENTITY_EMAIL,
260
+ },
261
+ });
262
+ return {
263
+ status: result.status,
264
+ stdout: result.stdout ?? "",
265
+ stderr: result.stderr ?? "",
266
+ };
267
+ }
268
+ /**
269
+ * The line of a git stderr block carrying git's OWN rejection marker.
270
+ *
271
+ * NOT "the first line", and M4-P20 paid for the difference: with
272
+ * `push.negotiate` true, git 2.43.0 emits a negotiation warning as the first
273
+ * stderr line of EVERY file-transport push, accepted and refused alike, so a
274
+ * signature taken from the first line cannot tell accept from refuse. The
275
+ * marker below is git's own text, never this module's, so the refusal
276
+ * signature stays captured rather than hand-written (T-003).
277
+ */
278
+ export function rejectionLine(stderr) {
279
+ const lines = stderr.split("\n").map((line) => line.trim()).filter(Boolean);
280
+ return lines.find((line) => line.startsWith("! [rejected]"));
281
+ }
282
+ function firstLine(text) {
283
+ const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
284
+ return lines[0] ?? "git printed nothing";
285
+ }
286
+ /* ------------------------------------------------------------------ */
287
+ /* Reading the register */
288
+ /* ------------------------------------------------------------------ */
289
+ function parseRegisterDocument(raw) {
290
+ let parsed;
291
+ try {
292
+ parsed = JSON.parse(raw);
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ const candidate = parsed;
298
+ if ((candidate.state !== "held" && candidate.state !== "free") ||
299
+ typeof candidate.envId !== "string" ||
300
+ candidate.envId === "" ||
301
+ typeof candidate.counter !== "number" ||
302
+ !Number.isInteger(candidate.counter) ||
303
+ candidate.counter < 1 ||
304
+ typeof candidate.acquiredAt !== "string" ||
305
+ typeof candidate.expiresAt !== "string" ||
306
+ typeof candidate.durationSeconds !== "number" ||
307
+ typeof candidate.ref !== "string") {
308
+ return undefined;
309
+ }
310
+ return candidate;
311
+ }
312
+ export function renderRegisterDocument(document) {
313
+ return `${JSON.stringify(document, null, 2)}\n`;
314
+ }
315
+ /**
316
+ * Read the register. `ls-remote` establishes the value the compare-and-swap
317
+ * will be armed against, then the object is fetched so the document can be
318
+ * read. A failure at either step is UNREACHABLE, never "absent": the two
319
+ * must not be conflated, because "absent" means "nobody holds the lease" and
320
+ * would license an acquire.
321
+ */
322
+ export function readRegister(fleetRoot, config) {
323
+ const listed = git(fleetRoot, ["ls-remote", config.remote, config.ref]);
324
+ if (listed.status !== 0) {
325
+ return { kind: "unreachable", reason: firstLine(listed.stderr) };
326
+ }
327
+ const line = listed.stdout.split("\n").map((entry) => entry.trim()).find(Boolean);
328
+ if (line === undefined) {
329
+ return { kind: "absent" };
330
+ }
331
+ const sha = line.split(/\s+/)[0] ?? "";
332
+ if (!/^[0-9a-f]{40}$/.test(sha)) {
333
+ return { kind: "unreachable", reason: `ls-remote returned an unreadable line: ${line}` };
334
+ }
335
+ const fetched = git(fleetRoot, ["fetch", "--quiet", config.remote, config.ref]);
336
+ if (fetched.status !== 0) {
337
+ return { kind: "unreachable", reason: firstLine(fetched.stderr) };
338
+ }
339
+ const shown = git(fleetRoot, ["cat-file", "-p", `${sha}:${REGISTER_DOCUMENT_NAME}`]);
340
+ if (shown.status !== 0) {
341
+ return {
342
+ kind: "corrupt",
343
+ sha,
344
+ reason: `${config.ref} at ${sha} carries no ${REGISTER_DOCUMENT_NAME}: ${firstLine(shown.stderr)}`,
345
+ };
346
+ }
347
+ const document = parseRegisterDocument(shown.stdout);
348
+ if (document === undefined) {
349
+ return { kind: "corrupt", sha, reason: `${config.ref} at ${sha} does not parse as a lease document` };
350
+ }
351
+ return { kind: "present", sha, document };
352
+ }
353
+ /**
354
+ * One compare-and-swap write. `expectedSha` is the value the caller read;
355
+ * the empty string means "I expect the register to be absent". The EXACT-SHA
356
+ * form is the only form emitted (finding 2 in this file's header).
357
+ */
358
+ export function casWrite(fleetRoot, config, expectedSha, document) {
359
+ const blob = git(fleetRoot, ["hash-object", "-w", "--stdin"], renderRegisterDocument(document));
360
+ if (blob.status !== 0) {
361
+ return { kind: "indeterminate", reason: `could not write the lease blob: ${firstLine(blob.stderr)}` };
362
+ }
363
+ const tree = git(fleetRoot, ["mktree"], `100644 blob ${blob.stdout.trim()}\t${REGISTER_DOCUMENT_NAME}\n`);
364
+ if (tree.status !== 0) {
365
+ return { kind: "indeterminate", reason: `could not write the lease tree: ${firstLine(tree.stderr)}` };
366
+ }
367
+ const commit = git(fleetRoot, [
368
+ "commit-tree",
369
+ tree.stdout.trim(),
370
+ "-m",
371
+ `tiphys shared lease ${document.state} ${document.envId} counter ${String(document.counter)}`,
372
+ ]);
373
+ if (commit.status !== 0) {
374
+ return { kind: "indeterminate", reason: `could not write the lease commit: ${firstLine(commit.stderr)}` };
375
+ }
376
+ const sha = commit.stdout.trim();
377
+ const pushed = git(fleetRoot, [
378
+ "push",
379
+ `--force-with-lease=${config.ref}:${expectedSha}`,
380
+ config.remote,
381
+ `${sha}:${config.ref}`,
382
+ ]);
383
+ if (pushed.status === 0) {
384
+ return { kind: "won", sha };
385
+ }
386
+ const rejected = rejectionLine(pushed.stderr);
387
+ if (rejected !== undefined) {
388
+ return { kind: "lost", reason: rejected };
389
+ }
390
+ /* NONZERO DOES NOT MEAN "I LOST" (finding 3). A transport failure exits 1
391
+ with no rejection marker, so the register is re-read: a value that is no
392
+ longer the expected one settles it as a loss, and anything else is
393
+ reported as INDETERMINATE rather than guessed in either direction. */
394
+ const after = readRegister(fleetRoot, config);
395
+ if (after.kind === "present" && after.sha !== expectedSha && after.sha !== sha) {
396
+ return { kind: "lost", reason: `${config.ref} moved to ${after.sha} while this write was in flight` };
397
+ }
398
+ if (after.kind === "present" && after.sha === sha) {
399
+ return { kind: "won", sha };
400
+ }
401
+ return {
402
+ kind: "indeterminate",
403
+ reason: `push failed without a rejection marker: ${firstLine(pushed.stderr)}`,
404
+ };
405
+ }
406
+ /* ------------------------------------------------------------------ */
407
+ /* The counter signal */
408
+ /* ------------------------------------------------------------------ */
409
+ export function readObservation(fleetRoot) {
410
+ const read = readJsonFile(join(fleetRoot, OBSERVATION_FILE));
411
+ if (!read.ok) {
412
+ return undefined;
413
+ }
414
+ const value = read.value;
415
+ if (value === null ||
416
+ typeof value.sha !== "string" ||
417
+ typeof value.counter !== "number" ||
418
+ typeof value.firstSeenMs !== "number") {
419
+ return undefined;
420
+ }
421
+ return { sha: value.sha, counter: value.counter, firstSeenMs: value.firstSeenMs };
422
+ }
423
+ export function writeObservation(fleetRoot, observation) {
424
+ const path = join(fleetRoot, OBSERVATION_FILE);
425
+ mkdirSync(dirname(path), { recursive: true });
426
+ const refusal = refuseOpenPathForWrite(path);
427
+ if (refusal !== undefined) {
428
+ throw new Error(refusal);
429
+ }
430
+ writeFileSync(path, `${JSON.stringify(observation, null, 2)}\n`, "utf8");
431
+ }
432
+ /**
433
+ * Judge a holder by the FENCING COUNTER, using two readings of ONE clock.
434
+ *
435
+ * A holder is stale only when the register's sha and counter are the same
436
+ * ones this environment first saw at `firstSeenMs` and that much of ITS OWN
437
+ * time has passed. Any advance of the counter resets the measurement, which
438
+ * is what makes a renewing holder safe no matter how far its clock is from
439
+ * this one: the renewal is visible as an increment, and an increment is not
440
+ * a timestamp.
441
+ */
442
+ export function judgeByCounter(previous, sha, counter, nowMs, staleWindowMs) {
443
+ if (previous !== undefined && previous.sha === sha && previous.counter === counter) {
444
+ const unchangedForMs = Math.max(0, nowMs - previous.firstSeenMs);
445
+ return { stale: unchangedForMs >= staleWindowMs, unchangedForMs, observation: previous };
446
+ }
447
+ return {
448
+ stale: false,
449
+ unchangedForMs: 0,
450
+ observation: { sha, counter, firstSeenMs: nowMs },
451
+ };
452
+ }
453
+ function unreachableLine(config, reason, intent) {
454
+ return (`shared exclusion refused ${intent}: register ${config.ref} on ${config.remote} is ` +
455
+ `unreachable (${reason}); signal=clock, because no fencing counter could be read and ` +
456
+ `a local clock is not a cross-environment signal, so this refuses instead of falling ` +
457
+ `back to local-only exclusion`);
458
+ }
459
+ /**
460
+ * Decide whether the caller may proceed, WITHOUT touching anything. Every
461
+ * refusal here happens before the local lease file is created, which is what
462
+ * makes criterion 7's fail-closed assertion observable: an unreachable
463
+ * register leaves no local lock behind.
464
+ */
465
+ export function preflightShared(input) {
466
+ const { config, envId, intent, nowMs } = input;
467
+ const read = readRegister(input.fleetRoot, config);
468
+ if (read.kind === "unreachable") {
469
+ return { kind: "refused", signal: "clock", line: unreachableLine(config, read.reason, intent) };
470
+ }
471
+ if (read.kind === "corrupt") {
472
+ return {
473
+ kind: "refused",
474
+ signal: "counter",
475
+ line: `shared exclusion refused ${intent}: register ${config.ref} at ${read.sha} is not a ` +
476
+ `lease document (${read.reason}); signal=counter, no counter could be established, ` +
477
+ `inspect the ref manually`,
478
+ };
479
+ }
480
+ if (read.kind === "absent") {
481
+ if (intent !== "acquire") {
482
+ return {
483
+ kind: "refused",
484
+ signal: "counter",
485
+ line: `shared exclusion refused ${intent}: register ${config.ref} on ${config.remote} ` +
486
+ `holds no lease, so there is nothing for environment ${envId} to ${intent}; ` +
487
+ `signal=counter`,
488
+ };
489
+ }
490
+ return {
491
+ kind: "proceed",
492
+ signal: "counter",
493
+ expectedSha: "",
494
+ nextCounter: 1,
495
+ takingOver: false,
496
+ line: `shared exclusion: register ${config.ref} on ${config.remote} was absent, claiming ` +
497
+ `it for environment ${envId} at counter 1; signal=counter`,
498
+ };
499
+ }
500
+ const document = read.document;
501
+ const judged = judgeByCounter(readObservation(input.fleetRoot), read.sha, document.counter, nowMs, config.staleWindowSeconds * 1000);
502
+ writeObservation(input.fleetRoot, judged.observation);
503
+ const nextCounter = document.counter + 1;
504
+ const held = document.state === "held";
505
+ if (intent === "renew" || intent === "release") {
506
+ if (!held || document.envId !== envId) {
507
+ return {
508
+ kind: "refused",
509
+ signal: "counter",
510
+ line: `shared exclusion refused ${intent}: register ${config.ref} is ${document.state} by ` +
511
+ `environment ${document.envId} at counter ${String(document.counter)}, not by ${envId}; ` +
512
+ `signal=counter, the register sha ${read.sha} is left unchanged`,
513
+ };
514
+ }
515
+ return {
516
+ kind: "proceed",
517
+ signal: "counter",
518
+ expectedSha: read.sha,
519
+ nextCounter,
520
+ takingOver: false,
521
+ current: document,
522
+ line: `shared exclusion: ${intent} by environment ${envId} advances ${config.ref} to counter ` +
523
+ `${String(nextCounter)}; signal=counter`,
524
+ };
525
+ }
526
+ if (!held) {
527
+ return {
528
+ kind: "proceed",
529
+ signal: "counter",
530
+ expectedSha: read.sha,
531
+ nextCounter,
532
+ takingOver: false,
533
+ current: document,
534
+ line: `shared exclusion: register ${config.ref} was released by environment ${document.envId}, ` +
535
+ `claiming it for ${envId} at counter ${String(nextCounter)}; signal=counter`,
536
+ };
537
+ }
538
+ if (!judged.stale) {
539
+ return {
540
+ kind: "refused",
541
+ signal: "counter",
542
+ line: `shared exclusion refused acquire: register ${config.ref} is held by environment ` +
543
+ `${document.envId} until ${document.expiresAt}; signal=counter, fencing counter ` +
544
+ `${String(document.counter)} has stood still for ${String(judged.unchangedForMs)}ms of the ` +
545
+ `${String(config.staleWindowSeconds * 1000)}ms this environment requires, and no clock ` +
546
+ `comparison was made`,
547
+ };
548
+ }
549
+ if (!input.takeover) {
550
+ return {
551
+ kind: "refused",
552
+ signal: "counter",
553
+ line: `shared exclusion refused acquire: register ${config.ref} is held by environment ` +
554
+ `${document.envId} and its fencing counter ${String(document.counter)} has stood still for ` +
555
+ `${String(judged.unchangedForMs)}ms, which is stale; signal=counter, takeover is explicit: ` +
556
+ `lock acquire --take-over`,
557
+ };
558
+ }
559
+ return {
560
+ kind: "proceed",
561
+ signal: "counter",
562
+ expectedSha: read.sha,
563
+ nextCounter,
564
+ takingOver: true,
565
+ current: document,
566
+ line: `shared exclusion: taking over ${config.ref} from environment ${document.envId}, whose ` +
567
+ `fencing counter ${String(document.counter)} stood still for ${String(judged.unchangedForMs)}ms; ` +
568
+ `environment ${envId} advances it to ${String(nextCounter)}; signal=counter`,
569
+ };
570
+ }
571
+ /**
572
+ * The one line a register write that was NOT won reports. It lives here
573
+ * rather than at the call site in `src/lock.ts` because it names the
574
+ * staleness basis, and the C-2 structural inspection over that file
575
+ * (test/lock.test.ts:534) forbids that vocabulary there; keeping the
576
+ * sentence in one place also means a reader sees the same wording whichever
577
+ * mutation lost.
578
+ */
579
+ export function casFailureLine(ref, outcome) {
580
+ if (outcome.kind === "won") {
581
+ return `the compare-and-swap on ${ref} was won`;
582
+ }
583
+ return (`the compare-and-swap on ${ref} was ${outcome.kind}: ${outcome.reason}; ` +
584
+ `signal=counter`);
585
+ }
586
+ /** Build the document a won preflight should publish. */
587
+ export function buildRegisterDocument(input) {
588
+ return {
589
+ state: input.state,
590
+ envId: input.envId,
591
+ counter: input.counter,
592
+ acquiredAt: input.acquiredAt ?? new Date(input.nowMs).toISOString(),
593
+ expiresAt: new Date(input.nowMs + input.durationSeconds * 1000).toISOString(),
594
+ durationSeconds: input.durationSeconds,
595
+ ref: input.ref,
596
+ };
597
+ }
598
+ /* ------------------------------------------------------------------ */
599
+ /* What the rest of the kernel asks the register (M4-P22) */
600
+ /* ------------------------------------------------------------------ */
601
+ /**
602
+ * THE FOUR STATUSES, AS A CLOSED SET (M4-P22 criterion 1).
603
+ *
604
+ * `doctor` prints exactly one of them and a test compares the printed
605
+ * leading token against this array, so a fifth status cannot be added by
606
+ * writing a new sentence somewhere: it has to be added here, where the
607
+ * comparison sees it.
608
+ *
609
+ * THE FOURTH IS NEVER PASS. That is the DR-0038 shape reused rather than
610
+ * reinvented: a check whose question could not be asked reports a third
611
+ * state instead of being forced into a binary, because an unreachable
612
+ * register reported as `free` would license exactly the second live
613
+ * orchestrator this layer exists to refuse
614
+ * (delivery/decisions/DR-0038-the-declared-single-family-review-exception.md:1).
615
+ */
616
+ export const SHARED_LOCK_STATUS_TOKENS = [
617
+ "not-declared",
618
+ "free",
619
+ "held",
620
+ "unreachable",
621
+ ];
622
+ function unreachableStatus(reason) {
623
+ return { token: "unreachable", reason, text: `unreachable ${reason}` };
624
+ }
625
+ /**
626
+ * A PURE READ of this environment's identity: it never generates one.
627
+ *
628
+ * `ensureEnvironmentId` above writes a file when none is there, which is
629
+ * right for `lock acquire`, the command that legitimately enters the layer
630
+ * and is about to publish a register document. It is wrong for a GUARD: a
631
+ * refusal must create nothing, and `doctor` must diagnose a fleet without
632
+ * changing it. An absent identity file therefore reads as "this environment
633
+ * is not the one the register names", which is the fail-closed answer.
634
+ */
635
+ export function readEnvironmentId(fleetRoot) {
636
+ const read = readJsonFile(join(fleetRoot, ENVIRONMENT_ID_FILE));
637
+ if (!read.ok) {
638
+ return undefined;
639
+ }
640
+ const value = read.value;
641
+ const existing = value === null ? undefined : value["envId"];
642
+ return typeof existing === "string" && existing !== "" ? existing : undefined;
643
+ }
644
+ /**
645
+ * The register's state as one of the four statuses, WITHOUT writing an
646
+ * observation and WITHOUT generating an identity.
647
+ *
648
+ * `preflightShared` is deliberately not used here even though it answers a
649
+ * similar question, because it WRITES `state/shared-lease.observed.json` as
650
+ * part of judging staleness. A diagnosis that moves the thing it diagnoses
651
+ * is not a diagnosis, and the staleness judgement is not wanted here anyway:
652
+ * see `guardSharedRegister` below for why a stale holder still refuses.
653
+ *
654
+ * It DOES fetch, because `readRegister` fetches: the register object has to
655
+ * be local before its document can be read. That touches `.git/FETCH_HEAD`
656
+ * in the fleet home and nothing else, and it is stated here rather than left
657
+ * for a reader to discover.
658
+ *
659
+ * TWO CONDITIONS COLLAPSE INTO `unreachable` AND BOTH ARE NAMED IN THE
660
+ * REASON. A declaration that is present and unusable, and a register whose
661
+ * document does not parse, are not `free` and cannot be rendered as
662
+ * `held <envId> until <t>` because neither yields an envId or an expiry. The
663
+ * criterion's set is closed at four, so they take the one status that means
664
+ * "this question could not be answered", and the reason says which of them
665
+ * it was.
666
+ */
667
+ export function sharedLockStatus(fleetRoot) {
668
+ const declaration = readSharedExclusion(fleetRoot);
669
+ if (declaration.kind === "absent") {
670
+ return {
671
+ token: "not-declared",
672
+ text: `not-declared (${SHARED_EXCLUSION_FIELD} is absent from this fleet ` +
673
+ `home's package.json, so the cross-environment layer is off here)`,
674
+ };
675
+ }
676
+ if (declaration.kind === "invalid") {
677
+ return unreachableStatus(`the declaration naming the register is unusable: ${declaration.reason}`);
678
+ }
679
+ const config = declaration.config;
680
+ const where = `register ${config.ref} on ${config.remote}`;
681
+ const read = readRegister(fleetRoot, config);
682
+ if (read.kind === "unreachable") {
683
+ return unreachableStatus(`${where} could not be read: ${read.reason}`);
684
+ }
685
+ if (read.kind === "corrupt") {
686
+ return unreachableStatus(`${where} is not a lease document: ${read.reason}`);
687
+ }
688
+ if (read.kind === "absent") {
689
+ return { token: "free", text: `free (${where} holds no lease yet)` };
690
+ }
691
+ const document = read.document;
692
+ if (document.state === "free") {
693
+ return {
694
+ token: "free",
695
+ text: `free (${where} was released by environment ${document.envId} at ` +
696
+ `fencing counter ${String(document.counter)})`,
697
+ };
698
+ }
699
+ return {
700
+ token: "held",
701
+ envId: document.envId,
702
+ expiresAt: document.expiresAt,
703
+ text: `held ${document.envId} until ${document.expiresAt} (${where}, fencing ` +
704
+ `counter ${String(document.counter)})`,
705
+ };
706
+ }
707
+ /**
708
+ * THE CROSS-ENVIRONMENT HALF OF THE HOLDERSHIP GUARD (M4-P22 criteria 2
709
+ * and 3).
710
+ *
711
+ * `checkHoldership` (src/task.ts:439) answers "does THIS process hold THIS
712
+ * filesystem's lease". That question is answered entirely inside one fleet
713
+ * home, and src/lock.ts:63 says so: the local lease excludes within one
714
+ * filesystem and one clock. So in the state this function exists for, the
715
+ * local lease held by THIS environment and the shared register naming
716
+ * ANOTHER one, the old guard is GREEN and the fleet has two orchestrators
717
+ * mutating one set of tasks. That is the dangerous state, and a test that
718
+ * holds neither lease is green without this function and proves nothing.
719
+ *
720
+ * WHY A STALE HOLDER STILL REFUSES. `judgeByCounter` exists so that a lease
721
+ * whose fencing counter has stood still can be taken over, and that takeover
722
+ * is a LEASE operation: `tiphys lock acquire --take-over` advances the
723
+ * counter under the taking-over environment's id, and only then does the
724
+ * register name this environment. Reading staleness here instead would give
725
+ * `spawn` and `teardown` their own opinion about who holds the fleet, which
726
+ * is a second verdict about one lease from a second place, and it would have
727
+ * to write the observation file to reach it. The refusal names the command
728
+ * that resolves it, so the remedy is reachable rather than merely correct.
729
+ *
730
+ * THE REASON IS ONE LINE, and the callers hand it straight to the same
731
+ * single-reason path every other refusal uses.
732
+ */
733
+ export function guardSharedRegister(fleetRoot, command) {
734
+ const status = sharedLockStatus(fleetRoot);
735
+ if (status.token === "not-declared") {
736
+ return { kind: "off" };
737
+ }
738
+ if (status.token === "unreachable") {
739
+ return {
740
+ kind: "refused",
741
+ reason: `shared exclusion refused ${command}: ${status.text}; a register that ` +
742
+ `cannot be read cannot show whether another environment is running this ` +
743
+ `fleet, so this refuses rather than falling back to local-only ` +
744
+ `exclusion; signal=clock, because no fencing counter could be read`,
745
+ };
746
+ }
747
+ if (status.token === "free") {
748
+ return { kind: "allowed", status };
749
+ }
750
+ const mine = readEnvironmentId(fleetRoot);
751
+ if (mine !== undefined && mine === status.envId) {
752
+ /* THE IDENTITY IS NOT ENOUGH, AND THE PLAN SAYS WHY IN BOTH DIRECTIONS.
753
+ M4-P21 criterion 3 requires `tiphys-environment.json` to be TRACKED so
754
+ it survives a reclaim and TRAVELS WITH THE CLONE, and
755
+ test/cross-environment-lock.test.ts:328 asserts exactly that. M4-P22
756
+ criteria 2 and 3 require a clone to be refused as a different
757
+ environment. Both are delivered, and composed they cancel: `tiphys
758
+ sync` commits and pushes that file (it is not under any prefix in
759
+ FLEET_IGNORED), so every clone of a synced fleet reads the SAME id and
760
+ this comparison is TRUE in a place that holds nothing.
761
+
762
+ WHAT DOES NOT TRAVEL is `state/`, which IS in FLEET_IGNORED, so the
763
+ lease artifact is per environment BY CONSTRUCTION rather than by a
764
+ rule someone has to remember. Requiring it here breaks no criterion's
765
+ letter: the tracked file is unchanged and still travels, and nothing
766
+ in `src/` or `bin/` consumed the travels-with-the-clone property for
767
+ anything but this comparison.
768
+
769
+ THE COST, stated rather than discovered: an environment whose `state/`
770
+ is lost while its container continues is refused here until the stale
771
+ window lets it take the register over, which is the smoothing
772
+ criterion 3's tracking was meant to provide. That is a deliberate
773
+ trade of convenience after a reclaim for a guard that a clone cannot
774
+ walk through.
775
+
776
+ EXPIRY IS DELIBERATELY NOT JUDGED HERE. `checkHoldership`
777
+ (src/task.ts:518) runs BEFORE this guard in both callers
778
+ (src/spawn.ts:1005 and src/teardown.ts:438) and refuses an expired or
779
+ wrongly-held lease already. A second expiry comparison in a second
780
+ place is the drift src/lock.ts:217 exists to prevent, and this guard
781
+ does not need it: the question it asks is whether this filesystem
782
+ carries the lease artifact the register entry stands on. */
783
+ const local = localLeaseArtifact(fleetRoot);
784
+ if (local.kind === "present") {
785
+ return { kind: "allowed", status };
786
+ }
787
+ return {
788
+ kind: "refused",
789
+ reason: `shared exclusion refused ${command}: the shared register names ` +
790
+ `environment ${status.envId} as holding this fleet until ` +
791
+ `${status.expiresAt}, and this fleet home carries that environment's ` +
792
+ `tracked identity but not its lease (${local.reason}); ` +
793
+ `${ENVIRONMENT_ID_FILE} is tracked and travels with a clone, so it ` +
794
+ `names the FLEET's environment and not THIS one, while ${LOCK_FILE} ` +
795
+ `is gitignored and cannot travel; acquire the fleet here with: ` +
796
+ `tiphys lock acquire, or take it over with: ` +
797
+ `tiphys lock acquire --take-over; signal=counter`,
798
+ };
799
+ }
800
+ return {
801
+ kind: "refused",
802
+ reason: `shared exclusion refused ${command}: the shared register names ` +
803
+ `environment ${status.envId} as holding this fleet until ` +
804
+ `${status.expiresAt}, and this environment is ` +
805
+ `${mine ?? "not identified: " + ENVIRONMENT_ID_FILE + " is absent"}; ` +
806
+ `the local lease is evidence about this filesystem only, so mutating ` +
807
+ `tasks here would run a second orchestrator over one fleet; take the ` +
808
+ `fleet over with: tiphys lock acquire --take-over; signal=counter`,
809
+ };
810
+ }
811
+ /**
812
+ * IS THE LEASE ARTIFACT THE REGISTER ENTRY STANDS ON PRESENT ON THIS
813
+ * FILESYSTEM?
814
+ *
815
+ * Deliberately NOT `leaseStatus` from src/lock.ts. That module imports this
816
+ * one, and the `src/` import graph is a strict DAG at this head; importing it
817
+ * back would make the first cycle in the kernel to answer a question that
818
+ * needs one field. So this reads the lease file through the same guarded
819
+ * read every other path in this module now uses, and asks only whether a
820
+ * parsed lease with a holder is there.
821
+ */
822
+ function localLeaseArtifact(fleetRoot) {
823
+ const path = join(fleetRoot, LOCK_FILE);
824
+ const read = readRegularPathIfPresent(path);
825
+ if (read.kind === "absent") {
826
+ return { kind: "absent", reason: `${LOCK_FILE} is absent here` };
827
+ }
828
+ if (read.kind === "refused") {
829
+ return { kind: "absent", reason: read.reason };
830
+ }
831
+ let parsed;
832
+ try {
833
+ parsed = JSON.parse(read.body);
834
+ }
835
+ catch {
836
+ return { kind: "absent", reason: `${LOCK_FILE} does not parse as a lease` };
837
+ }
838
+ const holderId = parsed?.holderId;
839
+ if (typeof holderId !== "string" || holderId === "") {
840
+ return { kind: "absent", reason: `${LOCK_FILE} names no holder` };
841
+ }
842
+ return { kind: "present", holderId };
843
+ }