@tiphys/kernel 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,500 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { linkSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ /**
6
+ * Lease-based session lock (kernel plan v1, M1-P3 step 1; DR-0007; plan
7
+ * constraint C-2). One orchestrator per fleet: the lock file at
8
+ * state/orchestrator.lock holds a JSON lease {holderId, hostname,
9
+ * acquiredAt, expiresAt, durationSeconds, token} where holderId is an
10
+ * opaque value generated at acquire. Liveness is lease freshness only:
11
+ * this module never probes any running program in any way (FM-053), and
12
+ * the death of a holder is deliberately invisible to the lease (an
13
+ * unexpired lease excludes even when its holder is gone; recovery is the
14
+ * explicit --take-over after expiry).
15
+ *
16
+ * Mutation contract (EXT-F-01, adopted verbatim from the plan):
17
+ * - Every mutation (acquire, renew, release, takeover) goes through the
18
+ * ONE shared atomic mutation primitive, applyLeaseMutation. A mutation
19
+ * is decided against an observed lease state and applied only if the
20
+ * file still holds exactly that state; the application is confirmed by
21
+ * re-reading the unique per-mutation token it wrote. A confirmation
22
+ * showing another writer's token means the mutation lost and returns
23
+ * failure without retry. Ownership is not valid until the primitive
24
+ * completes.
25
+ * - Renew fails on an expired lease and succeeds only while holderId
26
+ * matches and expiresAt is in the future.
27
+ * - Takeover succeeds only if the observed lease is still the lease being
28
+ * replaced (compare-and-swap over the lease file content) and
29
+ * serializes with renew, release, and competing takeovers.
30
+ *
31
+ * Serialization mechanism: mutations are serialized through a claim file
32
+ * beside the lock (<lock>.mutex, created O_EXCL; FM-022's
33
+ * serialize-through-a-claim pattern), and inside that claim the primitive
34
+ * re-reads the lock, byte-compares it with the observed state, applies
35
+ * via O_EXCL create (absent lock, PR-006) or write-temp-then-rename, and
36
+ * confirms by re-reading its own token (FM-022 write-then-verify). No
37
+ * steal protocol exists on purpose (FM-058): a claim file left behind by
38
+ * a crashed mutation makes later mutations fail loudly after a bounded
39
+ * wait, naming the file for manual removal; the critical section is a
40
+ * few file operations, so this window is tiny.
41
+ *
42
+ * What the token confirmation is and is NOT (corrected per D-2; the
43
+ * previous wording here claimed a second safety net that does not
44
+ * exist). The confirmation read asserts only "my bytes are in the file
45
+ * now". That is last-writer-wins: it catches an intruder who applied
46
+ * AFTER this mutation, and it does NOT catch one who applied before and
47
+ * merely lost the race to write last. The O_EXCL claim file is
48
+ * therefore the sole serializer, and it is advisory: no handle is held
49
+ * on it and nothing checks ownership when it is unlinked. The apply is
50
+ * preceded by a second read-and-compare (stillMatches) so that a lost
51
+ * claim degrades to a clean loss instead of a double win, but that
52
+ * narrows the window rather than closing it. Deleting a live claim file
53
+ * can still produce two holders; that is why the CLI's remedy text now
54
+ * says so instead of inviting it.
55
+ *
56
+ * Exclusion domain (PR-201, DR-0007 stated honestly): the lease excludes
57
+ * 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.
61
+ *
62
+ * Renewal discipline (PR-203): the default lease lasts 900 seconds and
63
+ * the holder renews at or before half-life (renewByMs). Holdership on
64
+ * mutating kernel commands (spawn, teardown) is verified against the
65
+ * lease by M1-P4.
66
+ */
67
+ export const DEFAULT_LEASE_DURATION_SECONDS = 900;
68
+ /** Bounded wait for the mutation claim file, then fail loudly. */
69
+ const MUTEX_WAIT_TOTAL_MS = 5000;
70
+ const MUTEX_WAIT_POLL_MS = 10;
71
+ function parseLease(raw) {
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(raw);
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ const candidate = parsed;
80
+ if (typeof candidate.holderId !== "string" ||
81
+ candidate.holderId === "" ||
82
+ typeof candidate.hostname !== "string" ||
83
+ typeof candidate.acquiredAt !== "string" ||
84
+ typeof candidate.expiresAt !== "string" ||
85
+ typeof candidate.durationSeconds !== "number" ||
86
+ typeof candidate.token !== "string" ||
87
+ Number.isNaN(Date.parse(candidate.expiresAt))) {
88
+ return undefined;
89
+ }
90
+ return candidate;
91
+ }
92
+ export function renderLease(lease) {
93
+ return `${JSON.stringify(lease, null, 2)}\n`;
94
+ }
95
+ /** Read the current lock file state: absent, or present with raw bytes. */
96
+ export function observeLease(lockPath) {
97
+ let raw;
98
+ try {
99
+ raw = readFileSync(lockPath, "utf8");
100
+ }
101
+ catch (error) {
102
+ if (error.code === "ENOENT") {
103
+ return { kind: "absent" };
104
+ }
105
+ throw error;
106
+ }
107
+ return { kind: "present", raw, lease: parseLease(raw) };
108
+ }
109
+ export function isExpired(lease, nowMs) {
110
+ return Date.parse(lease.expiresAt) <= nowMs;
111
+ }
112
+ /** The renew-by deadline (half-life of the current term), for holders. */
113
+ export function renewByMs(lease) {
114
+ return Date.parse(lease.expiresAt) - (lease.durationSeconds * 1000) / 2;
115
+ }
116
+ function readCurrent(lockPath) {
117
+ try {
118
+ return { present: true, raw: readFileSync(lockPath, "utf8") };
119
+ }
120
+ catch (error) {
121
+ if (error.code === "ENOENT") {
122
+ return { present: false, raw: "" };
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+ /**
128
+ * Re-read the lock file and re-compare it against the state a mutation
129
+ * was decided on. Used immediately before every apply (D-2).
130
+ */
131
+ function stillMatches(lockPath, observed) {
132
+ const current = readCurrent(lockPath);
133
+ if (observed.kind === "absent") {
134
+ return !current.present;
135
+ }
136
+ return current.present && current.raw === observed.raw;
137
+ }
138
+ /**
139
+ * The single staging path a lease rename goes through (CR-202). One
140
+ * fixed name is safe because staging only ever happens inside the
141
+ * mutation claim, and it makes strand cleanup deterministic.
142
+ */
143
+ export function stagePathFor(lockPath) {
144
+ return `${lockPath}.stage`;
145
+ }
146
+ /**
147
+ * The one shared atomic mutation primitive (EXT-F-01). Applies next (new
148
+ * file content, or null to remove the lock) only if the lock file still
149
+ * holds exactly the observed state, and confirms the application by
150
+ * re-reading the mutation's own token. Loses without retry otherwise.
151
+ */
152
+ export async function applyLeaseMutation(lockPath, observed, next, token) {
153
+ const mutexPath = `${lockPath}.mutex`;
154
+ const deadline = Date.now() + MUTEX_WAIT_TOTAL_MS;
155
+ for (;;) {
156
+ try {
157
+ writeFileSync(mutexPath, token, { flag: "wx" });
158
+ break;
159
+ }
160
+ catch (error) {
161
+ if (error.code !== "EEXIST") {
162
+ throw error;
163
+ }
164
+ if (Date.now() >= deadline) {
165
+ // CR-204: name the lease situation first, so an operator is not
166
+ // nudged toward a takeover when the obstacle is a claim file and
167
+ // there is no lease at all.
168
+ // The remedy sentence is deliberately NOT part of this reason:
169
+ // the CLI appends it from the claimTimeout flag, so the
170
+ // classification is load-bearing at every layer rather than
171
+ // carried along as prose (CR-204).
172
+ // U-6: an expired lease is not a held one. Saying "lock held"
173
+ // here contradicted lock status in the same fleet, which calls
174
+ // the same lease expired, and reads as "the holder is alive"
175
+ // when the truth is the opposite.
176
+ const holder = observed.kind === "present" && observed.lease !== undefined
177
+ ? isExpired(observed.lease, Date.now())
178
+ ? `expired lease from ${observed.lease.holderId}`
179
+ : `lock held by ${observed.lease.holderId}`
180
+ : "no lease, no live holder";
181
+ return {
182
+ won: false,
183
+ claimTimeout: true,
184
+ reason: `${holder}; stale claim file ${mutexPath} blocking after ` +
185
+ `${String(MUTEX_WAIT_TOTAL_MS)}ms`,
186
+ };
187
+ }
188
+ await sleep(MUTEX_WAIT_POLL_MS);
189
+ }
190
+ }
191
+ try {
192
+ // CR-202: clear any stranded stage left by a mutation that died
193
+ // between its stage write and its rename. This runs inside the
194
+ // claim, so it provably cannot race a live mutation (a live one
195
+ // would hold the claim), which is why no age heuristic is needed
196
+ // or wanted. It is unconditional because the release path (unlink)
197
+ // and the absent-lock acquire path (O_EXCL create) never touch the
198
+ // stage: cleaning only in the rename branch would let a strand
199
+ // survive a release/acquire-only sequence indefinitely.
200
+ try {
201
+ unlinkSync(stagePathFor(lockPath));
202
+ }
203
+ catch {
204
+ // No strand present, which is the normal case.
205
+ }
206
+ const current = readCurrent(lockPath);
207
+ if (observed.kind === "absent") {
208
+ if (current.present) {
209
+ return {
210
+ won: false,
211
+ reason: "lost: a lease appeared after this mutation observed none",
212
+ };
213
+ }
214
+ }
215
+ else if (!current.present) {
216
+ return {
217
+ won: false,
218
+ reason: "lost: the observed lease is gone",
219
+ };
220
+ }
221
+ else if (current.raw !== observed.raw) {
222
+ return {
223
+ won: false,
224
+ reason: "lost: the lease changed after this mutation observed it",
225
+ };
226
+ }
227
+ // D-2: re-read and re-compare immediately before every apply. The
228
+ // claim file is the ONLY serializer, and it is advisory: no handle
229
+ // is held on it, and the operator remedy this CLI prints tells a
230
+ // human to delete it. If a claim is lost that way, another mutation
231
+ // can enter this section concurrently; without this second compare
232
+ // both could apply and both could believe they won. With it, the
233
+ // loser sees changed bytes and degrades to a clean loss. This
234
+ // narrows the window to the syscall gap; it does not remove it, and
235
+ // the module docs say so rather than claiming a guarantee.
236
+ if (!stillMatches(lockPath, observed)) {
237
+ return {
238
+ won: false,
239
+ reason: "lost: the lease changed while this mutation held the claim",
240
+ };
241
+ }
242
+ if (next === null) {
243
+ unlinkSync(lockPath);
244
+ const confirm = readCurrent(lockPath);
245
+ if (confirm.present) {
246
+ return { won: false, reason: "lost: removal did not stick" };
247
+ }
248
+ return { won: true };
249
+ }
250
+ if (observed.kind === "absent") {
251
+ // D-1: publish the initial lease atomically. writeFileSync with
252
+ // flag "wx" is openSync(O_EXCL) followed by a SEPARATE writeSync,
253
+ // so the lock file's NAME becomes visible at length zero before
254
+ // the lease bytes land. observeLease, leaseStatus and doctor all
255
+ // read outside the claim, so a reader landing in that window sees
256
+ // an empty file and reports a healthy fleet as corrupt; it turned
257
+ // acceptance criterion 3's own witness red on pristine code.
258
+ //
259
+ // linkSync gives both properties at once: the stage file already
260
+ // holds the complete lease, and link fails with EEXIST if the
261
+ // lock path exists, which is exactly the atomic exclusive-create
262
+ // test PR-006 asks for. So exclusion is preserved and the name
263
+ // never exists half-published. renew and takeover were already
264
+ // immune because they stage then rename; this makes the absent
265
+ // lock path use the same discipline, which is the asymmetry the
266
+ // module previously left unjustified.
267
+ const stagePath = stagePathFor(lockPath);
268
+ writeFileSync(stagePath, next);
269
+ try {
270
+ linkSync(stagePath, lockPath);
271
+ }
272
+ catch (error) {
273
+ if (error.code === "EEXIST") {
274
+ return {
275
+ won: false,
276
+ reason: "lost: a lease appeared after this mutation observed none",
277
+ };
278
+ }
279
+ throw error;
280
+ }
281
+ finally {
282
+ try {
283
+ unlinkSync(stagePath);
284
+ }
285
+ catch {
286
+ // The stage sweep at the top of the claim also covers this.
287
+ }
288
+ }
289
+ }
290
+ else {
291
+ // The stage is ONE fixed path beside the lock (CR-202). Every
292
+ // stage write happens inside the claim, so no two stages can ever
293
+ // coexist and a fixed name needs no uniqueness to be safe; the
294
+ // claim-held sweep above then makes cleanup of a crash strand
295
+ // deterministic (exactly one possible strand path, removed
296
+ // unconditionally) instead of an age-based guess over a family of
297
+ // unique names. The CAS is untouched by this choice: the
298
+ // byte-compare against the observed state and the token
299
+ // confirmation read both still happen inside the claim, and the
300
+ // rename remains atomic within one directory.
301
+ const stagePath = stagePathFor(lockPath);
302
+ writeFileSync(stagePath, next);
303
+ try {
304
+ renameSync(stagePath, lockPath);
305
+ }
306
+ catch (error) {
307
+ try {
308
+ unlinkSync(stagePath);
309
+ }
310
+ catch {
311
+ // Stage cleanup is best effort; the original error surfaces.
312
+ }
313
+ if (error.code === "ENOENT") {
314
+ // Another mutation swept this stage, which means it entered
315
+ // the critical section concurrently, which means this claim
316
+ // was lost (D-2). Report it as a loss rather than letting a
317
+ // raw ENOENT stack out of the CLI.
318
+ return {
319
+ won: false,
320
+ reason: "lost: the staged lease disappeared before it was published, " +
321
+ "which means another mutation held the claim concurrently",
322
+ };
323
+ }
324
+ throw error;
325
+ }
326
+ }
327
+ const confirm = readCurrent(lockPath);
328
+ const confirmedLease = confirm.present ? parseLease(confirm.raw) : undefined;
329
+ if (confirmedLease === undefined || confirmedLease.token !== token) {
330
+ return {
331
+ won: false,
332
+ reason: "lost: confirmation read shows another writer's token",
333
+ };
334
+ }
335
+ return { won: true };
336
+ }
337
+ finally {
338
+ try {
339
+ unlinkSync(mutexPath);
340
+ }
341
+ catch {
342
+ // Claim file already gone; nothing to release.
343
+ }
344
+ }
345
+ }
346
+ function buildLease(nowMs, durationSeconds) {
347
+ return {
348
+ holderId: randomUUID(),
349
+ hostname: hostname(),
350
+ acquiredAt: new Date(nowMs).toISOString(),
351
+ expiresAt: new Date(nowMs + durationSeconds * 1000).toISOString(),
352
+ durationSeconds,
353
+ token: randomUUID(),
354
+ };
355
+ }
356
+ /**
357
+ * Acquire the lease, or take over an expired one when takeover is set.
358
+ * Refusals (lock held, expired without takeover, unexpired takeover)
359
+ * never mutate the file; only won mutations do.
360
+ */
361
+ export async function acquireLease(lockPath, options = {}) {
362
+ const nowMs = options.nowMs ?? Date.now();
363
+ const durationSeconds = options.durationSeconds ?? DEFAULT_LEASE_DURATION_SECONDS;
364
+ const observed = options.observed ?? observeLease(lockPath);
365
+ if (observed.kind === "present") {
366
+ if (observed.lease === undefined) {
367
+ return {
368
+ ok: false,
369
+ reason: `lease file ${lockPath} is corrupt; inspect it manually`,
370
+ };
371
+ }
372
+ if (!isExpired(observed.lease, nowMs)) {
373
+ return {
374
+ ok: false,
375
+ reason: options.takeover === true
376
+ ? `takeover refused: lock held by ${observed.lease.holderId}, unexpired until ${observed.lease.expiresAt}`
377
+ : `lock held by ${observed.lease.holderId}, expires ${observed.lease.expiresAt}`,
378
+ };
379
+ }
380
+ if (options.takeover !== true) {
381
+ return {
382
+ ok: false,
383
+ reason: `lease expired (holder ${observed.lease.holderId}, expired ` +
384
+ `${observed.lease.expiresAt}); acquire refused, takeover is ` +
385
+ `explicit: lock acquire --take-over`,
386
+ };
387
+ }
388
+ }
389
+ const lease = buildLease(nowMs, durationSeconds);
390
+ const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
391
+ if (!result.won) {
392
+ // CR-204: a claim-file timeout is not a held lease. Its reason
393
+ // already states the lease situation, so it is never re-prefixed
394
+ // with "lock held", and the classification travels to the caller.
395
+ if (result.claimTimeout === true) {
396
+ return { ok: false, reason: result.reason, claimTimeout: true };
397
+ }
398
+ return { ok: false, reason: `lock held (${result.reason})` };
399
+ }
400
+ return { ok: true, lease };
401
+ }
402
+ /**
403
+ * Renew the lease held by holderId. Fails on an expired lease even when
404
+ * holderId matches (EXT-F-01: a paused holder whose lease expired cannot
405
+ * renew), fails on a holder mismatch, and never mutates the file on any
406
+ * failure. The new expiry strictly increases (a renew that does not
407
+ * extend the lease would be useless).
408
+ */
409
+ export async function renewLease(lockPath, holderId, options = {}) {
410
+ const nowMs = options.nowMs ?? Date.now();
411
+ const observed = options.observed ?? observeLease(lockPath);
412
+ if (observed.kind === "absent") {
413
+ return { ok: false, reason: "renew refused: no lease present" };
414
+ }
415
+ if (observed.lease === undefined) {
416
+ return {
417
+ ok: false,
418
+ reason: `lease file ${lockPath} is corrupt; inspect it manually`,
419
+ };
420
+ }
421
+ if (observed.lease.holderId !== holderId) {
422
+ return {
423
+ ok: false,
424
+ reason: `renew refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
425
+ };
426
+ }
427
+ if (isExpired(observed.lease, nowMs)) {
428
+ return {
429
+ ok: false,
430
+ reason: `renew refused: lease expired ${observed.lease.expiresAt}; an ` +
431
+ `expired lease cannot be renewed, re-acquire or take over instead`,
432
+ };
433
+ }
434
+ const durationSeconds = options.durationSeconds ?? observed.lease.durationSeconds;
435
+ const newExpiresMs = Math.max(nowMs + durationSeconds * 1000, Date.parse(observed.lease.expiresAt) + 1);
436
+ const lease = {
437
+ ...observed.lease,
438
+ expiresAt: new Date(newExpiresMs).toISOString(),
439
+ durationSeconds,
440
+ token: randomUUID(),
441
+ };
442
+ const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
443
+ if (!result.won) {
444
+ if (result.claimTimeout === true) {
445
+ return { ok: false, reason: result.reason, claimTimeout: true };
446
+ }
447
+ return { ok: false, reason: `renew ${result.reason}` };
448
+ }
449
+ return { ok: true, lease };
450
+ }
451
+ /**
452
+ * Release the lease held by holderId. Expiry does not block a release
453
+ * (an expired former holder may clean up its own lease), but a holder
454
+ * mismatch refuses, so a losing holder can never remove the winner's
455
+ * lease; and the compare-and-swap in the primitive means a release
456
+ * staged before a takeover completes loses to it.
457
+ */
458
+ export async function releaseLease(lockPath, holderId, options = {}) {
459
+ const observed = options.observed ?? observeLease(lockPath);
460
+ if (observed.kind === "absent") {
461
+ return { ok: false, reason: "release refused: no lease present" };
462
+ }
463
+ if (observed.lease === undefined) {
464
+ return {
465
+ ok: false,
466
+ reason: `lease file ${lockPath} is corrupt; inspect it manually`,
467
+ };
468
+ }
469
+ if (observed.lease.holderId !== holderId) {
470
+ return {
471
+ ok: false,
472
+ reason: `release refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
473
+ };
474
+ }
475
+ const result = await applyLeaseMutation(lockPath, observed, null, randomUUID());
476
+ if (!result.won) {
477
+ if (result.claimTimeout === true) {
478
+ return { ok: false, reason: result.reason, claimTimeout: true };
479
+ }
480
+ return { ok: false, reason: `release ${result.reason}` };
481
+ }
482
+ return { ok: true, lease: null };
483
+ }
484
+ /** Report the lock state; reading only, never mutating (always safe). */
485
+ export function leaseStatus(lockPath, nowMs = Date.now()) {
486
+ const observed = observeLease(lockPath);
487
+ if (observed.kind === "absent") {
488
+ return { state: "free" };
489
+ }
490
+ if (observed.lease === undefined) {
491
+ return {
492
+ state: "corrupt",
493
+ detail: `lease file ${lockPath} does not parse as a lease`,
494
+ };
495
+ }
496
+ return {
497
+ state: isExpired(observed.lease, nowMs) ? "expired" : "held",
498
+ lease: observed.lease,
499
+ };
500
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * READING THE SHIPPED ASSURANCE MODE DEFINITIONS (kernel plan M3, M3-P3
3
+ * step 5).
4
+ *
5
+ * `assurance-modes.yaml` ships at the package root, beside `gate-registry.yaml`
6
+ * and the `schemas/` directory. This module locates it, decodes it, and
7
+ * answers one question: what does a declared mode require. Nothing here
8
+ * RESOLVES a mode into behaviour and nothing here enforces one. M3 never
9
+ * executes `direct-pr` or `local-only`, and building an enforcement engine for
10
+ * a mode this milestone never enters is the M1-P3 failure the plan is trying
11
+ * not to repeat.
12
+ *
13
+ * NO VALIDATION HAPPENS HERE, deliberately. `tiphys validate --type
14
+ * assurance-modes` is the command that says whether the document is
15
+ * well-formed, and duplicating its rules in a reader would produce a second
16
+ * opinion to keep in sync. This module reads what is there and reports what it
17
+ * cannot find.
18
+ */
19
+ /** The shipped document's basename, at the package root. */
20
+ export declare const MODES_FILENAME = "assurance-modes.yaml";
21
+ /**
22
+ * Locate the package root by walking UP from this module and testing for the
23
+ * shipped document.
24
+ *
25
+ * The depth differs between the two layouts this code runs in: from source it
26
+ * is `src/` and the root is one level up, and from the built entry it is
27
+ * `dist/src/` and the root is two levels up. Counting `..` would be right in
28
+ * exactly one of them, which is the layout-dependent break
29
+ * `schemasDirectory()` already documents. Walking up and TESTING is right in
30
+ * both, and in a relocated copy as well.
31
+ */
32
+ export declare function packageRoot(): string;
33
+ /** One mode as the document declares it. Fields absent from the document stay absent. */
34
+ export interface Mode {
35
+ id: string;
36
+ declaredBy: string;
37
+ pipeline: string[];
38
+ skips: string[];
39
+ gateSets: string[];
40
+ mergeAuthority: string;
41
+ grantedBy?: string;
42
+ conditions?: string[];
43
+ reviewContracts?: string[];
44
+ escalationBounds?: Record<string, unknown>;
45
+ }
46
+ export type ModesRead =
47
+ /**
48
+ * `raw` is the DECODED DOCUMENT before this module projects it into `Mode`
49
+ * records. It is returned because a caller must be able to validate what it
50
+ * is about to serve, and the projection is lossy by design (it drops
51
+ * anything the projection does not name). Handing a caller only the
52
+ * projection would force it to validate a shape no schema describes.
53
+ */
54
+ {
55
+ ok: true;
56
+ path: string;
57
+ raw: unknown;
58
+ modes: Mode[];
59
+ } | {
60
+ ok: false;
61
+ reason: string;
62
+ };
63
+ /** Read and decode the shipped document, or say why it could not be read. */
64
+ export declare function readModes(path?: string): ModesRead;
65
+ /**
66
+ * Where the rendered document came from. `shippedDocument` is true only when
67
+ * the reader was given no `--file` and therefore read the kernel's OWN
68
+ * `assurance-modes.yaml` from the package root.
69
+ *
70
+ * It is a REQUIRED parameter rather than an option with a default, because the
71
+ * execution-status line below is a claim about a specific document and a
72
+ * default would let a caller make that claim by omission.
73
+ */
74
+ export interface RenderContext {
75
+ shippedDocument: boolean;
76
+ }
77
+ /**
78
+ * The execution status of one mode, DERIVED rather than looked up in a list of
79
+ * ids (CR-004 item 2, DR-0020).
80
+ *
81
+ * Two facts are available and both are checkable by the reader: whether this is
82
+ * the kernel's own document, and whether this mode IS the one blueprint section
83
+ * 8 names as the un-downgraded process, "The current proven process is the
84
+ * definition of `full`. Downgrades are declared, never improvised."
85
+ *
86
+ * WHY THE NAME AND NOT THE SKIP COUNT (CR-002, round 9). This function used
87
+ * `mode.skips.length === 0` as its proxy for "this is the un-downgraded mode".
88
+ * The proxy held only because `full` happened to be the only mode with an empty
89
+ * list, nothing enforced that, and `skips[]` is shipped DATA. Two measured
90
+ * consequences, both at exit 0 with every registry gate green: giving `full` one
91
+ * bogus `skips[]` entry made this function say that no phase of the tiphys
92
+ * project had ever been delivered under `full`, which is false about the mode
93
+ * this project has delivered every phase under; and giving `direct-pr` an empty
94
+ * `skips[]` made a mode nobody has ever entered claim to be the one the project
95
+ * follows, with `merge-authority: owner` printed beneath it, which is not the
96
+ * regime in force (DR-0015). Blueprint section 8 defines `full` BY NAME, so the
97
+ * name is the primary fact and the skip count is a consequence of it.
98
+ *
99
+ * THE COUNT IS STILL REPORTED, as a fact about the mode rather than as the
100
+ * ground of the claim. That distinction is the whole finding: a number may be
101
+ * shown without being believed.
102
+ *
103
+ * WHAT MAKES THE `full` SENTENCE TRUE IS DATA, AND IT IS GUARDED IN TWO PLACES
104
+ * THAT COVER DIFFERENT DOCUMENTS. Keying off the name moves the burden: the
105
+ * claim is only as good as the `full` in the document being served genuinely
106
+ * being un-downgraded.
107
+ *
108
+ * The VALIDATOR carries it for EVERY document (round 10, CRB9-02).
109
+ * `mode-no-undeclared-downgrade` rejects a `skips[]` entry that the same mode's
110
+ * pipeline runs, and also one that the reference does not run; on the reference
111
+ * those two exhaust the cases, so a `full` declaring any skipped stage at all is
112
+ * refused and this sentence is never printed over it. Until that second part
113
+ * existed, a `full` whose stage had MOVED from `pipeline` into `skips` was an
114
+ * HONEST downgrade that validated at exit 0, and `tiphys mode show --mode full`
115
+ * printed this sentence about fifteen lines above a `skips: deploy-verify` row.
116
+ *
117
+ * A REGISTERED TEST carries it for THIS repository's own document, asserting the
118
+ * shipped `full` declares no skipped stage. It is kept rather than superseded:
119
+ * a test guards the document, the check guards every other document, and the
120
+ * failure this pair exists to prevent is a property asserted where it is stated
121
+ * and not enforced where it is consumed.
122
+ *
123
+ * WHAT THIS DELIBERATELY DOES NOT SAY. It does not say that tiphys runs
124
+ * anything: nothing runs on tiphys before M4. The un-downgraded mode of the
125
+ * kernel's own document is the process the tiphys PROJECT follows for its own
126
+ * delivery; the downgraded ones have never been entered at all. And for a
127
+ * document that is not the kernel's own, the answer is that tiphys does not
128
+ * know, because it does not.
129
+ */
130
+ export declare function executionStatus(mode: Mode, context: RenderContext): string;
131
+ /**
132
+ * The standing limits of this release, printed on every invocation.
133
+ *
134
+ * IT SAYS ONLY WHAT THE SHIPPED SCHEMAS DO. The vocabularies really are closed
135
+ * enums, so "a document naming any other id is rejected" is the enum's own
136
+ * behaviour and not a claim about intent. The M4 sentence is attributed to
137
+ * DR-0020 rather than stated as a property of the code.
138
+ */
139
+ export declare const RELEASE_LIMITS: string;
140
+ /**
141
+ * Render one mode for a human or for a brief.
142
+ *
143
+ * THE SHAPE IS PART OF THE CONTRACT, because criterion 2 asserts over it: a
144
+ * section is a line ending in a colon at column zero, and its items are the
145
+ * lines indented by exactly two spaces beneath it. That makes "prints exactly
146
+ * the twelve stage ids in order" something a test can extract rather than
147
+ * something a reader has to eyeball.
148
+ */
149
+ export declare function renderMode(mode: Mode, context: RenderContext): string[];