@sentropic/h2a 0.98.4 → 0.98.6

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 (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/runtime/drive/consent.d.ts +1 -1
  8. package/dist/runtime/drive/consent.d.ts.map +1 -1
  9. package/dist/runtime/drive/consent.js +2 -2
  10. package/dist/runtime/drive/consent.js.map +1 -1
  11. package/dist/runtime/drive/index.d.ts +1 -1
  12. package/dist/runtime/drive/index.d.ts.map +1 -1
  13. package/dist/runtime/drive/index.js +3 -1
  14. package/dist/runtime/drive/index.js.map +1 -1
  15. package/dist/runtime/drumbeat/relaunchers.d.ts +1 -1
  16. package/dist/runtime/drumbeat/relaunchers.d.ts.map +1 -1
  17. package/dist/runtime/drumbeat/relaunchers.js +2 -1
  18. package/dist/runtime/drumbeat/relaunchers.js.map +1 -1
  19. package/dist/runtime/identity/binding-commit.d.ts +31 -0
  20. package/dist/runtime/identity/binding-commit.d.ts.map +1 -0
  21. package/dist/runtime/identity/binding-commit.js +211 -0
  22. package/dist/runtime/identity/binding-commit.js.map +1 -0
  23. package/dist/runtime/identity/bindings.d.ts +6 -30
  24. package/dist/runtime/identity/bindings.d.ts.map +1 -1
  25. package/dist/runtime/identity/bindings.js +85 -96
  26. package/dist/runtime/identity/bindings.js.map +1 -1
  27. package/dist/runtime/identity/durable.d.ts +7 -0
  28. package/dist/runtime/identity/durable.d.ts.map +1 -0
  29. package/dist/runtime/identity/durable.js +68 -0
  30. package/dist/runtime/identity/durable.js.map +1 -0
  31. package/dist/runtime/identity/live.d.ts +4 -1
  32. package/dist/runtime/identity/live.d.ts.map +1 -1
  33. package/dist/runtime/identity/live.js +59 -6
  34. package/dist/runtime/identity/live.js.map +1 -1
  35. package/dist/runtime/identity/possession.d.ts +34 -0
  36. package/dist/runtime/identity/possession.d.ts.map +1 -0
  37. package/dist/runtime/identity/possession.js +162 -0
  38. package/dist/runtime/identity/possession.js.map +1 -0
  39. package/dist/runtime/identity/worker.js +16 -5
  40. package/dist/runtime/identity/worker.js.map +1 -1
  41. package/dist/runtime/local-files/identity-fence.d.ts +164 -0
  42. package/dist/runtime/local-files/identity-fence.d.ts.map +1 -0
  43. package/dist/runtime/local-files/identity-fence.js +1336 -0
  44. package/dist/runtime/local-files/identity-fence.js.map +1 -0
  45. package/dist/runtime/local-files/index.d.ts +1 -0
  46. package/dist/runtime/local-files/index.d.ts.map +1 -1
  47. package/dist/runtime/local-files/index.js +1 -0
  48. package/dist/runtime/local-files/index.js.map +1 -1
  49. package/dist/runtime/local-files/legacy-identity-evidence.d.ts +26 -0
  50. package/dist/runtime/local-files/legacy-identity-evidence.d.ts.map +1 -0
  51. package/dist/runtime/local-files/legacy-identity-evidence.js +234 -0
  52. package/dist/runtime/local-files/legacy-identity-evidence.js.map +1 -0
  53. package/dist/runtime/local-files/locks.d.ts +3 -0
  54. package/dist/runtime/local-files/locks.d.ts.map +1 -1
  55. package/dist/runtime/local-files/locks.js +6 -5
  56. package/dist/runtime/local-files/locks.js.map +1 -1
  57. package/dist/runtime/local-files/store.d.ts +1 -1
  58. package/dist/runtime/local-files/store.d.ts.map +1 -1
  59. package/dist/runtime/local-files/store.js +12 -1
  60. package/dist/runtime/local-files/store.js.map +1 -1
  61. package/dist/runtime/local-files/succession-lock.d.ts +71 -6
  62. package/dist/runtime/local-files/succession-lock.d.ts.map +1 -1
  63. package/dist/runtime/local-files/succession-lock.js +226 -31
  64. package/dist/runtime/local-files/succession-lock.js.map +1 -1
  65. package/dist/runtime/mcp/identity-blocker-snapshot.d.ts +2 -0
  66. package/dist/runtime/mcp/identity-blocker-snapshot.d.ts.map +1 -0
  67. package/dist/runtime/mcp/identity-blocker-snapshot.js +98 -0
  68. package/dist/runtime/mcp/identity-blocker-snapshot.js.map +1 -0
  69. package/dist/runtime/mcp/identity-state.d.ts +20 -3
  70. package/dist/runtime/mcp/identity-state.d.ts.map +1 -1
  71. package/dist/runtime/mcp/identity-state.js +98 -13
  72. package/dist/runtime/mcp/identity-state.js.map +1 -1
  73. package/dist/runtime/mcp/server.d.ts.map +1 -1
  74. package/dist/runtime/mcp/server.js +12 -9
  75. package/dist/runtime/mcp/server.js.map +1 -1
  76. package/dist/runtime/remote/client.d.ts +1 -1
  77. package/dist/runtime/remote/client.d.ts.map +1 -1
  78. package/dist/runtime/remote/client.js +1 -1
  79. package/dist/runtime/remote/client.js.map +1 -1
  80. package/package.json +3 -3
@@ -0,0 +1,1336 @@
1
+ /** Identity binding fence, FINAL v6 (A+B). Strict v2/v4 discrimination,
2
+ * qualified death proofs, bounded legacy NSpid evidence, shared succession,
3
+ * durable journal/slot repair before retirement, and monotone diagnostics.
4
+ * Upgrade retains its own protocol bindings. No operator call or signal is
5
+ * used by automatic recovery; every unsupported blocker waits for release.
6
+ */
7
+ import { createHash, createHmac, randomBytes } from "node:crypto";
8
+ import { constants, closeSync, fsyncSync, openSync, readFileSync, readSync, statfsSync, statSync, fstatSync, lstatSync, unlinkSync, writeSync } from "node:fs";
9
+ import { basename, dirname, join } from "node:path";
10
+ import { performance } from "node:perf_hooks";
11
+ import { repairBindingSlots, prepareBindingSlots } from "../identity/binding-commit.js";
12
+ import { ensureDurableDirectory, syncDirectory, redactIdentityStorageError } from "../identity/durable.js";
13
+ import { captureLegacyLock, collectLegacyEvidence, legacyCaptureHeld, legacyCapacityExceeded } from './legacy-identity-evidence.js';
14
+ import { LockCancelledError, LockTimeoutError, logicalLockId, notifyObserver } from "./locks.js";
15
+ import { classifyLiveness, errnoOf, parseLegacyLockHolder, parseLockRec, procStartInfo, publishLockRecord, readBootId, readHostId, readPidNs, readTimeNs, succPathFor, succeedDeadToken } from "./succession-lock.js";
16
+ /** The one identity fence protocol (also carried by every v2 record). */
17
+ export const IDENTITY_BINDING_FENCE_PROTOCOL = "identity-binding-fence-v1";
18
+ /** New lock records are 0600 (R2). */
19
+ const LOCK_RECORD_MODE = 0o600;
20
+ /** Filesystems eligible for automatic recovery (R1); compared as f_type >>> 0. */
21
+ const QUALIFIED_FS_TYPES = new Set([
22
+ 0xef53, // ext2/3/4
23
+ 0x58465342, // XFS
24
+ 0x9123683e, // Btrfs
25
+ 0xf2f52010 // F2FS
26
+ ]);
27
+ /** `link(2)` failure codes that authorize the exclusive-create fallback (R2). */
28
+ const LINK_FALLBACK_CODES = new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS"]);
29
+ /** Read errors that are genuine storage failures (the wait aborts; R3). */
30
+ const STORAGE_READ_CODES = new Set(["EACCES", "EPERM", "EROFS", "EIO", "ENOSPC", "ENOTDIR", "EISDIR"]);
31
+ const MACHINE_ID_RE = /^[0-9a-f]{32}$/;
32
+ const PLATFORM_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
33
+ const BOOT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
34
+ const PID_NS_RE = /^pid:\[\d+\]$/;
35
+ const TIME_NS_RE = /^time:\[\d+\]$/;
36
+ const PROC_START_RE = /^proc:[0-9]+$/;
37
+ const GEN_RE = /^[A-Za-z0-9_-]{12,128}$/;
38
+ const DEFAULT_TIMEOUT_MS = 5000;
39
+ const DEFAULT_POLL_MS = 50;
40
+ /** Suggested bounded retry delay for `interval` refusals (R7). */
41
+ const RETRY_AFTER_MS = 1000;
42
+ // One worker cancellation signal identifies one external attempt across retries.
43
+ const LEGACY_PASS_QUOTAS = new WeakMap();
44
+ const V2_KEYS = new Set([
45
+ "recordVersion", "protocol", "platform", "publication", "hostKind", "host",
46
+ "boot", "ns", "timeNs", "pid", "start", "token", "fenceEpoch", "at",
47
+ "target", "operator"
48
+ ]);
49
+ function recordPlatform() {
50
+ const p = process.platform;
51
+ return p === "linux" || p === "darwin" || p === "win32" ? p : "other";
52
+ }
53
+ /**
54
+ * This process's fresh identity context (R2: renewed at every decision; the
55
+ * machine-id falls back to /var/lib/dbus/machine-id when /etc/machine-id is
56
+ * absent). Never memoized, unlike the upgrade path's `me()`.
57
+ */
58
+ export function identitySelf() {
59
+ const host = readHostId({ machineIdPaths: ["/etc/machine-id", "/var/lib/dbus/machine-id"] });
60
+ return {
61
+ host: host.host,
62
+ hostKind: host.hostKind,
63
+ boot: readBootId(),
64
+ ns: readPidNs(),
65
+ timeNs: readTimeNs(),
66
+ pid: process.pid,
67
+ start: procStartInfo(process.pid)?.start ?? null
68
+ };
69
+ }
70
+ /** Fresh 128-bit token; `token === fenceEpoch` (R2). */
71
+ function newIdentityToken() {
72
+ return randomBytes(16).toString("hex");
73
+ }
74
+ function makeIdentityRecord(self, token, target, publication = "link") {
75
+ const platform = recordPlatform();
76
+ // Outside Linux no anchor is given an artificial Linux format (R2): boot,
77
+ // namespace, time namespace and start stay null there.
78
+ return {
79
+ recordVersion: 2,
80
+ protocol: IDENTITY_BINDING_FENCE_PROTOCOL,
81
+ platform,
82
+ publication,
83
+ hostKind: platform === 'linux' ? self.hostKind : 'weak',
84
+ host: platform === 'linux' && self.hostKind === "machine-id" ? self.host : null,
85
+ boot: platform === "linux" ? self.boot : null,
86
+ ns: platform === "linux" ? self.ns : null,
87
+ timeNs: platform === "linux" ? self.timeNs : null,
88
+ pid: self.pid,
89
+ start: platform === "linux" ? self.start : null,
90
+ token,
91
+ fenceEpoch: token,
92
+ at: Date.now(),
93
+ ...(target !== undefined ? { target } : {})
94
+ };
95
+ }
96
+ function parseIdentityRecord(raw) {
97
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
98
+ return undefined;
99
+ const o = raw;
100
+ if (!Object.keys(o).every((key) => V2_KEYS.has(key)))
101
+ return undefined;
102
+ if (o.recordVersion !== 2 || o.protocol !== IDENTITY_BINDING_FENCE_PROTOCOL)
103
+ return undefined;
104
+ const platform = o.platform;
105
+ if (platform !== "linux" && platform !== "darwin" && platform !== "win32" && platform !== "other")
106
+ return undefined;
107
+ const publication = o.publication;
108
+ if (publication !== "link" && publication !== "o-excl")
109
+ return undefined;
110
+ const hostKind = o.hostKind;
111
+ if (hostKind !== "machine-id" && hostKind !== "weak")
112
+ return undefined;
113
+ if (hostKind === "machine-id") {
114
+ if (typeof o.host !== "string")
115
+ return undefined;
116
+ if (platform === "linux" ? !MACHINE_ID_RE.test(o.host) : !PLATFORM_UUID_RE.test(o.host))
117
+ return undefined;
118
+ if (platform === "linux" && /^0+$/.test(o.host))
119
+ return undefined;
120
+ }
121
+ else if (o.host !== null)
122
+ return undefined;
123
+ if (o.boot !== null && (typeof o.boot !== "string" || !BOOT_ID_RE.test(o.boot)))
124
+ return undefined;
125
+ if (o.ns !== null && (typeof o.ns !== "string" || !PID_NS_RE.test(o.ns)))
126
+ return undefined;
127
+ if (o.timeNs !== null && (typeof o.timeNs !== "string" || !TIME_NS_RE.test(o.timeNs)))
128
+ return undefined;
129
+ if (typeof o.pid !== "number" || !Number.isSafeInteger(o.pid) || o.pid <= 0)
130
+ return undefined;
131
+ if (o.start !== null && (typeof o.start !== "string" || !PROC_START_RE.test(o.start)))
132
+ return undefined;
133
+ if (typeof o.token !== "string" || !/^[0-9a-f]{32}$/.test(o.token))
134
+ return undefined;
135
+ if (o.fenceEpoch !== o.token)
136
+ return undefined;
137
+ if (typeof o.at !== "number" || !Number.isFinite(o.at))
138
+ return undefined;
139
+ if (o.target !== undefined && (typeof o.target !== "string" || !GEN_RE.test(o.target)))
140
+ return undefined;
141
+ if (o.operator !== undefined && (o.operator !== true || o.target === undefined))
142
+ return undefined;
143
+ return {
144
+ recordVersion: 2,
145
+ protocol: IDENTITY_BINDING_FENCE_PROTOCOL,
146
+ platform,
147
+ publication,
148
+ hostKind,
149
+ host: o.host,
150
+ boot: o.boot,
151
+ ns: o.ns,
152
+ timeNs: o.timeNs,
153
+ pid: o.pid,
154
+ start: o.start,
155
+ token: o.token,
156
+ fenceEpoch: o.token,
157
+ at: o.at,
158
+ ...(o.target !== undefined ? { target: o.target } : {}),
159
+ ...(o.operator === true ? { operator: true } : {})
160
+ };
161
+ }
162
+ const CULL_KEYS = new Set(["protocol", "fenceEpoch", "pid", "acquiredAt"]);
163
+ /**
164
+ * Reader for `identity/.lock` and its SUCC chain. Distinguishes (R6): the v2
165
+ * record, the 0.98.5 succession format (adapter — publication link is
166
+ * recognized, anchors are used as-is, never completed), the legacy binding
167
+ * record, the historical cull fence, an explicit unknown version/protocol
168
+ * (never downgraded), corruption, absence and I/O errors.
169
+ */
170
+ export function readIdentityLock(path) {
171
+ let bytes;
172
+ let fd;
173
+ try {
174
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
175
+ if (!fstatSync(fd).isFile())
176
+ return 'corrupt';
177
+ bytes = readFileSync(fd);
178
+ }
179
+ catch (e) {
180
+ const code = errnoOf(e);
181
+ if (code === "ENOENT")
182
+ return "absent";
183
+ if (code === 'ELOOP')
184
+ return 'corrupt';
185
+ return { kind: "io-error", code };
186
+ }
187
+ finally {
188
+ if (fd !== undefined)
189
+ closeSync(fd);
190
+ }
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(bytes.toString("utf8"));
194
+ }
195
+ catch {
196
+ return "corrupt";
197
+ }
198
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
199
+ return "corrupt";
200
+ const o = parsed;
201
+ const keys = Object.keys(o);
202
+ if ("recordVersion" in o) {
203
+ if (o.recordVersion === 2 && o.protocol === IDENTITY_BINDING_FENCE_PROTOCOL) {
204
+ const rec = parseIdentityRecord(parsed);
205
+ return rec ? { kind: "v2", rec, bytes } : "corrupt";
206
+ }
207
+ return "unknown";
208
+ }
209
+ const legacyKeys = new Set(["pid", "hostname", "startedAt", "protocol", "fenceEpoch"]);
210
+ if ('protocol' in o && o.protocol !== IDENTITY_BINDING_FENCE_PROTOCOL)
211
+ return 'unknown';
212
+ if (keys.includes("pid") && keys.includes("hostname") && keys.includes("startedAt")
213
+ && keys.every((key) => legacyKeys.has(key))) {
214
+ try {
215
+ const eligible = keys.length === 5 && Number.isInteger(o.pid) && o.pid >= 1 && o.pid <= 4194304
216
+ && typeof o.hostname === 'string' && o.hostname.length > 0
217
+ && typeof o.startedAt === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(o.startedAt)
218
+ && Number.isFinite(Date.parse(o.startedAt)) && new Date(o.startedAt).toISOString() === o.startedAt
219
+ && typeof o.fenceEpoch === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(o.fenceEpoch)
220
+ && bytes.equals(Buffer.from(JSON.stringify({ pid: o.pid, hostname: o.hostname, startedAt: o.startedAt, protocol: o.protocol, fenceEpoch: o.fenceEpoch })));
221
+ if (keys.length === 5 && !eligible)
222
+ return 'corrupt';
223
+ return { kind: "legacy", holder: parseLegacyLockHolder(parsed, bytes), bytes, eligible };
224
+ }
225
+ catch {
226
+ return "corrupt";
227
+ }
228
+ }
229
+ if (keys.length === CULL_KEYS.size && keys.every((key) => CULL_KEYS.has(key))) {
230
+ return { kind: "cull", bytes };
231
+ }
232
+ if ('protocol' in o)
233
+ return 'unknown';
234
+ const required = ['host', 'boot', 'ns', 'timeNs', 'pid', 'start', 'token', 'at'];
235
+ const allowed = new Set([...required, 'hostKind', 'target', 'operator']);
236
+ if (!required.every(key => key in o) || !keys.every(key => allowed.has(key)))
237
+ return 'corrupt';
238
+ try {
239
+ return { kind: "adapter", rec: parseLockRec(parsed), bytes };
240
+ }
241
+ catch {
242
+ return "corrupt";
243
+ }
244
+ }
245
+ /** Chain-link view for the shared election engine; unknown formats fail closed. */
246
+ function identityChainView(path) {
247
+ const read = readIdentityLock(path);
248
+ if (read === "absent")
249
+ return "absent";
250
+ if (read === "unknown" || read === "corrupt" || read.kind === "io-error"
251
+ || read.kind === "legacy" || read.kind === "cull") {
252
+ return "corrupt";
253
+ }
254
+ if (read.kind === "adapter")
255
+ return read.rec;
256
+ return { ...identityLockView(read.rec), platform: read.rec.platform, publication: read.rec.publication };
257
+ }
258
+ /** LockRec-shaped view of a v2 record for the classifier (host may be null: weak). */
259
+ function identityLockView(rec) {
260
+ return {
261
+ host: rec.host ?? "",
262
+ hostKind: rec.hostKind,
263
+ boot: rec.boot,
264
+ ns: rec.ns,
265
+ timeNs: rec.timeNs,
266
+ pid: rec.pid,
267
+ start: rec.start,
268
+ token: rec.token,
269
+ ...(rec.target !== undefined ? { target: rec.target } : {}),
270
+ ...(rec.operator === true ? { operator: true } : {}),
271
+ at: rec.at
272
+ };
273
+ }
274
+ function identityVerdict(read, self) {
275
+ if (read === "corrupt" || read === "unknown" || read.kind === "legacy" || read.kind === "cull") {
276
+ return "undecidable";
277
+ }
278
+ if (read.kind === 'v2' && read.rec.platform !== 'linux')
279
+ return 'undecidable';
280
+ const rec = read.kind === "v2" ? identityLockView(read.rec) : read.rec;
281
+ return classifyLiveness(rec, self, { protocol: "identity" }).verdict;
282
+ }
283
+ /**
284
+ * R1 filesystem guard: automatic recovery is authorized only when the REAL
285
+ * identity directory sits on an explicitly whitelisted local filesystem.
286
+ * tmpfs, NFS, SMB, FUSE, OverlayFS, virtiofs, 9p, ZFS and unknown types or a
287
+ * statfs error all refuse it (wait for normal release instead).
288
+ */
289
+ export function identityFsQualified(dir) {
290
+ try {
291
+ return QUALIFIED_FS_TYPES.has(statfsSync(dir).type >>> 0);
292
+ }
293
+ catch {
294
+ return false;
295
+ }
296
+ }
297
+ function fsyncDirectory(dir) {
298
+ // Windows: no directory open/fsync is required or possible (R2 matrix).
299
+ if (process.platform === "win32")
300
+ return;
301
+ let fd;
302
+ try {
303
+ fd = openSync(dir, "r");
304
+ fsyncSync(fd);
305
+ }
306
+ catch (e) {
307
+ const code = errnoOf(e);
308
+ // Darwin: a documented incapacity is tolerated, never a masked failure.
309
+ if (process.platform === "darwin" && (code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP"))
310
+ return;
311
+ throw e;
312
+ }
313
+ finally {
314
+ if (fd !== undefined) {
315
+ try {
316
+ closeSync(fd);
317
+ }
318
+ catch {
319
+ // best-effort
320
+ }
321
+ }
322
+ }
323
+ }
324
+ function ensureIdentityDir(dir) { ensureDurableDirectory(dir); }
325
+ export function publishIdentityRecord(path, rec, atomicOnly = false) {
326
+ const pub = publishLockRecord(path, rec, LOCK_RECORD_MODE);
327
+ if (pub.status === "ok") {
328
+ fsyncDirectory(dirname(path));
329
+ return { ...pub, publication: "link" };
330
+ }
331
+ if (pub.status !== "error" || !LINK_FALLBACK_CODES.has(pub.code))
332
+ return pub;
333
+ if (atomicOnly)
334
+ return pub;
335
+ const fallback = { ...rec, publication: "o-excl" };
336
+ const data = JSON.stringify(fallback);
337
+ let fd;
338
+ try {
339
+ fd = openSync(path, "wx", LOCK_RECORD_MODE);
340
+ let truncated = false;
341
+ try {
342
+ const written = writeSync(fd, data);
343
+ truncated = written !== Buffer.byteLength(data);
344
+ if (!truncated)
345
+ fsyncSync(fd);
346
+ }
347
+ finally {
348
+ try {
349
+ closeSync(fd);
350
+ }
351
+ catch {
352
+ // best-effort
353
+ }
354
+ fd = undefined;
355
+ }
356
+ if (truncated) {
357
+ try {
358
+ unlinkSync(path);
359
+ }
360
+ catch {
361
+ // best-effort
362
+ }
363
+ return { status: "error", code: "ENOSPC" };
364
+ }
365
+ fsyncDirectory(dirname(path));
366
+ return { status: "ok", publication: "o-excl" };
367
+ }
368
+ catch (e) {
369
+ const code = errnoOf(e);
370
+ if (fd !== undefined) {
371
+ try {
372
+ closeSync(fd);
373
+ }
374
+ catch {
375
+ // best-effort
376
+ }
377
+ }
378
+ if (code === "EEXIST")
379
+ return { status: "exists" };
380
+ return { status: "error", code };
381
+ }
382
+ }
383
+ // ---------------------------------------------------------------------------
384
+ // Diagnostics (R7): structured, bounded, expurgated. No raw machine-id and no
385
+ // absolute path ever reach a session surface.
386
+ // ---------------------------------------------------------------------------
387
+ let DIAGNOSTIC_KEY;
388
+ /** Ephemeral per-process fingerprint key (R7; a standalone CLI uses its own). */
389
+ function diagnosticFingerprint(machineId, key) {
390
+ DIAGNOSTIC_KEY ??= randomBytes(32);
391
+ return createHmac("sha256", key ?? DIAGNOSTIC_KEY)
392
+ .update(`h2a-identity-diagnostic-v1\0${machineId}`)
393
+ .digest("hex")
394
+ .slice(0, 16);
395
+ }
396
+ const LEGACY_NOTE = "The same legacy lock was observed throughout this attempt. If its owner is active, "
397
+ + "normal release will allow acquisition. If its owner has stopped, this format lacks "
398
+ + "the provenance required for automatic recovery; retrying without a relevant change "
399
+ + "cannot establish it.";
400
+ /**
401
+ * Why an undecidable v2/adapter holder cannot be recovered. This mirrors the
402
+ * classifier's gates (the verdict itself is never decided here) to name the
403
+ * first missing/malformed/mismatched anchor precisely.
404
+ */
405
+ function identityUndecidableReason(rec, self) {
406
+ if (rec.hostKind !== "machine-id" || self.hostKind !== "machine-id") {
407
+ return { reasonCode: "undecidable-weak-host", reason: "no strong machine identity (machine-id) is known on both sides" };
408
+ }
409
+ if (typeof rec.host !== "string" || !MACHINE_ID_RE.test(rec.host) || !MACHINE_ID_RE.test(self.host)) {
410
+ return { reasonCode: "undecidable-machine", reason: "the machine identity is absent or malformed on one side" };
411
+ }
412
+ if (rec.host !== self.host) {
413
+ return { reasonCode: "foreign-machine", reason: "the holder record belongs to a different machine" };
414
+ }
415
+ if (rec.ns === null || self.ns === null || !PID_NS_RE.test(rec.ns) || !PID_NS_RE.test(self.ns)) {
416
+ return { reasonCode: "undecidable-namespace", reason: "the pid namespace is absent or malformed on one side" };
417
+ }
418
+ if (rec.ns !== self.ns) {
419
+ return { reasonCode: "foreign-namespace", reason: "the holder record was written in a different pid namespace" };
420
+ }
421
+ if (rec.boot === null || self.boot === null || !BOOT_ID_RE.test(rec.boot) || !BOOT_ID_RE.test(self.boot)) {
422
+ return { reasonCode: "undecidable-boot", reason: "the boot identity is absent or malformed on one side" };
423
+ }
424
+ // Same frame, live probe inconclusive: distinguish the start/zombie cases.
425
+ if (rec.start === null) {
426
+ return { reasonCode: "undecidable-start", reason: "the recorded process start time is unknown, so a live PID cannot be attributed" };
427
+ }
428
+ return { reasonCode: "undecidable-probe", reason: "the recorded process could not be proven alive or dead from this frame" };
429
+ }
430
+ function blockerFromRead(read, self, blockerType, diagnosticKey) {
431
+ const unknown = {
432
+ blockerType,
433
+ pid: "unknown",
434
+ machineClass: "unknown",
435
+ bootClass: "unknown",
436
+ ns: "unknown",
437
+ start: "unknown",
438
+ generation: "unknown",
439
+ target: "unknown",
440
+ reasonCode: "corrupt-lock-record",
441
+ reason: "the holder record is empty, unreadable or corrupted",
442
+ retryCondition: "blocker-change"
443
+ };
444
+ if (read === "corrupt")
445
+ return unknown;
446
+ if (read === "unknown") {
447
+ return { ...unknown, reasonCode: "unknown-record-version", reason: "the holder record carries an unknown version or protocol and is never downgraded" };
448
+ }
449
+ if (read.kind === "cull") {
450
+ return { ...unknown, reasonCode: "cull-legacy-record", reason: "a historical cull fence has no recovery provenance and no break command" };
451
+ }
452
+ if (read.kind === "legacy") {
453
+ return {
454
+ ...unknown,
455
+ pid: read.holder.pid,
456
+ generation: read.holder.token,
457
+ reasonCode: "legacy-generation-unsupported",
458
+ reason: "legacy record is outside the supported automatic recovery class",
459
+ retryCondition: "blocker-change"
460
+ };
461
+ }
462
+ const isV2 = read.kind === "v2";
463
+ const v2 = isV2 ? read.rec : undefined;
464
+ const view = isV2 ? identityLockView(v2) : read.rec;
465
+ const verdict = isV2 && v2?.platform !== 'linux' ? 'undecidable' : classifyLiveness(view, self, { protocol: "identity" }).verdict;
466
+ const machineClass = view.hostKind === "machine-id" && self.hostKind === "machine-id" && typeof view.host === "string" && MACHINE_ID_RE.test(view.host)
467
+ ? view.host === self.host ? "local" : "other"
468
+ : "unknown";
469
+ const bootClass = view.boot !== null && self.boot !== null && BOOT_ID_RE.test(view.boot) && BOOT_ID_RE.test(self.boot)
470
+ ? view.boot === self.boot ? "current" : "other"
471
+ : "unknown";
472
+ const base = {
473
+ blockerType,
474
+ pid: view.pid,
475
+ machineClass,
476
+ ...(machineClass !== "unknown" && typeof view.host === "string"
477
+ ? { machineFingerprint: diagnosticFingerprint(view.host, diagnosticKey) }
478
+ : {}),
479
+ bootClass,
480
+ ns: view.ns ?? "unknown",
481
+ start: view.start ?? "unknown",
482
+ generation: view.token,
483
+ target: view.target ?? "unknown"
484
+ };
485
+ if (isV2 && v2?.platform !== 'linux')
486
+ return { ...base, reasonCode: 'platform-unsupported', reason: 'automatic identity recovery requires a Linux record and reader', retryCondition: 'blocker-change' };
487
+ if (verdict === "live") {
488
+ return {
489
+ ...base,
490
+ reasonCode: "holder-live",
491
+ reason: "the recorded holder process is alive; its normal release will allow acquisition",
492
+ retryCondition: "interval"
493
+ };
494
+ }
495
+ if (verdict === "dead") {
496
+ if (isV2 && v2 && v2.publication === "o-excl") {
497
+ return {
498
+ ...base,
499
+ reasonCode: "publication-o-excl",
500
+ reason: "the holder record was published without atomic link publication and is never recovered automatically",
501
+ retryCondition: "blocker-change"
502
+ };
503
+ }
504
+ if (process.platform !== "linux" || (isV2 && v2 && v2.platform !== "linux")) {
505
+ return {
506
+ ...base,
507
+ reasonCode: "platform-unsupported",
508
+ reason: "automatic recovery is authorized on Linux only",
509
+ retryCondition: "blocker-change"
510
+ };
511
+ }
512
+ return {
513
+ ...base,
514
+ reasonCode: "fs-not-qualified",
515
+ reason: "the identity directory's filesystem does not qualify for automatic recovery",
516
+ retryCondition: "blocker-change"
517
+ };
518
+ }
519
+ const { reasonCode, reason } = identityUndecidableReason(view, self);
520
+ const foreign = reasonCode.startsWith("foreign-");
521
+ return {
522
+ ...base,
523
+ reasonCode,
524
+ reason,
525
+ retryCondition: foreign ? "blocker-change" : "interval"
526
+ };
527
+ }
528
+ /** R7 common literal; absent values render as "unknown". */
529
+ export function diagnosticMessage(d) {
530
+ const unknown = "unknown";
531
+ let text = `Identity unavailable after ${d.elapsedMs} ms; budget ${d.budgetMs} ms. `
532
+ + `Lock: ${d.lock}. `
533
+ + `Last observed blocker: ${d.blockerType}, pid=${d.pid === "unknown" ? unknown : d.pid}, `
534
+ + `machine=${d.machineClass}${d.machineFingerprint ? `/${d.machineFingerprint}` : ""}, `
535
+ + `boot=${d.bootClass}, namespace=${d.ns}, start=${d.start}, `
536
+ + `generation=${d.generation}, target=${d.target}. `
537
+ + `Automatic recovery is not authorized: ${d.reasonCode} — ${d.reason}.`;
538
+ if (d.retryCondition === "interval" && d.retryAfterMs !== undefined) {
539
+ text += ` Retry is possible after about ${d.retryAfterMs} ms.`;
540
+ }
541
+ if (d.legacyNote !== undefined)
542
+ text += ` ${d.legacyNote}`;
543
+ return text;
544
+ }
545
+ export class IdentityLockTimeoutError extends LockTimeoutError {
546
+ diagnostic;
547
+ constructor(lockPath, diagnostic) {
548
+ super(diagnostic.lock, undefined, diagnostic.budgetMs);
549
+ this.name = "LockTimeoutError";
550
+ this.diagnostic = diagnostic;
551
+ this.message = diagnosticMessage(diagnostic);
552
+ }
553
+ }
554
+ function blockerKey(read, lockPath) {
555
+ if (read === "corrupt" || read === "unknown") {
556
+ const st = statOf(lockPath);
557
+ return `${read}:${sha256Bytes(readBytesOf(lockPath))}:${st ? `${st.dev}/${st.ino}` : 'meta-unobserved'}`;
558
+ }
559
+ if (read.kind === "legacy" || read.kind === "cull") {
560
+ const st = statOf(lockPath);
561
+ return `${read.kind}:${sha256Bytes(read.bytes)}:${st ? `${st.dev}/${st.ino}/${st.uid}/${st.mode}/${st.nlink}/${st.mtimeMs}/${st.ctimeMs}` : "meta-unobserved"}`;
562
+ }
563
+ return `${read.kind}:${read.rec.token}`;
564
+ }
565
+ function readBytesOf(path) {
566
+ let fd;
567
+ try {
568
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
569
+ return fstatSync(fd).isFile() ? readFileSync(fd) : Buffer.alloc(0);
570
+ }
571
+ catch {
572
+ return Buffer.alloc(0);
573
+ }
574
+ finally {
575
+ if (fd !== undefined)
576
+ closeSync(fd);
577
+ }
578
+ }
579
+ function statOf(path) {
580
+ try {
581
+ const st = lstatSync(path);
582
+ return st;
583
+ }
584
+ catch {
585
+ return undefined;
586
+ }
587
+ }
588
+ function sha256Bytes(bytes) {
589
+ return createHash("sha256").update(bytes).digest("hex");
590
+ }
591
+ /** Read the chain up to the SUCC effectively blocking (R4 readFirst). */
592
+ function scanSuccession(lockPath, self, first, legacyDead = false) {
593
+ if (first === "corrupt" || first === "unknown" || first.kind === "legacy" && !legacyDead || first.kind === "cull") {
594
+ return { lock: first };
595
+ }
596
+ if (!legacyDead && identityVerdict(first, self) !== "dead")
597
+ return { lock: first };
598
+ // The LOCK holder is provably dead: walk the SUCC chain one link at a time.
599
+ const generation = typeof first === 'object' && first.kind === 'legacy' ? first.holder.token : first.rec.token;
600
+ let t = generation;
601
+ for (let depth = 0; depth < 8; depth++) {
602
+ const path = succPathFor(lockPath, t);
603
+ const link = readIdentityLock(path);
604
+ if (link === "absent")
605
+ return { lock: first };
606
+ if (link === "corrupt" || link === "unknown" || link.kind === "io-error"
607
+ || link.kind === "legacy" || link.kind === "cull") {
608
+ return { lock: first, blockingLink: link, blockingPath: path };
609
+ }
610
+ if (link.rec.target !== generation || identityVerdict(link, self) !== "dead" || !recoveryAuthorized(link, dirname(lockPath)))
611
+ return { lock: first, blockingLink: link, blockingPath: path };
612
+ t = link.rec.token;
613
+ }
614
+ return { lock: first, blockingLink: "unknown", exhausted: true };
615
+ }
616
+ function identityElectionIo(self, lockPath, ctx) {
617
+ let electedPath;
618
+ let electedToken;
619
+ let target;
620
+ const held = () => {
621
+ if (!lockPath || !electedPath || !electedToken || !target)
622
+ return false;
623
+ const current = identitySelf();
624
+ if (current.host !== self.host || current.hostKind !== self.hostKind || current.boot !== self.boot || current.ns !== self.ns || current.timeNs !== self.timeNs)
625
+ return false;
626
+ const original = identityChainView(lockPath);
627
+ const elected = identityChainView(electedPath);
628
+ return original !== 'absent' && original !== 'corrupt' && original.token === target
629
+ && elected !== 'absent' && elected !== 'corrupt' && elected.token === electedToken && elected.target === target
630
+ && readIdentityLock(succPathFor(lockPath, electedToken)) === 'absent';
631
+ };
632
+ return {
633
+ ...(lockPath ? { beforeElection: (g) => {
634
+ if (ctx && performance.now() >= ctx.deadline)
635
+ return false;
636
+ const read = readIdentityLock(lockPath);
637
+ return typeof read === 'object' && (read.kind === 'v2' || read.kind === 'adapter') && read.rec.token === g
638
+ && recoveryAuthorized(read, dirname(lockPath)) && identityVerdict(read, identitySelf()) === 'dead';
639
+ } } : {}),
640
+ makeRec: (s, token, target) => makeIdentityRecord(s, token, target),
641
+ readRecord: identityChainView,
642
+ classify: (r, s) => {
643
+ const metadata = r;
644
+ if (metadata.publication === 'o-excl' || metadata.platform && metadata.platform !== 'linux')
645
+ return 'undecidable';
646
+ return classifyLiveness(r, identitySelf(), { protocol: "identity" }).verdict;
647
+ },
648
+ publish: (path, rec) => {
649
+ const pub = publishIdentityRecord(path, rec, Boolean(rec.target));
650
+ if (rec.target && pub.status === 'ok' && pub.publication !== 'link')
651
+ return { status: 'error', code: 'ENOTSUP' };
652
+ return pub;
653
+ },
654
+ onElected: (path, token, g) => { electedPath = path; electedToken = token; target = g; },
655
+ ...(lockPath ? { beforeRetire: () => {
656
+ if (!held() || ctx && performance.now() >= ctx.deadline) {
657
+ if (electedPath && electedToken && target)
658
+ abandonOwnSuccessor(lockPath, electedPath, electedToken, target);
659
+ return false;
660
+ }
661
+ repairIdentityJournal(dirname(lockPath), held, ctx.slotReceipts);
662
+ return held();
663
+ }, afterRetire: dir => { syncDirectory(dir); if (ctx)
664
+ ctx.recovered = true; } } : {}),
665
+ lease: (_prefix, path, token) => makeIdentityLease(path, token, 'link', true)
666
+ };
667
+ }
668
+ function abandonOwnSuccessor(lockPath, path, token, target) {
669
+ const read = readIdentityLock(path);
670
+ if (typeof read !== 'object' || read.kind !== 'v2' || read.rec.token !== token || read.rec.target !== target || read.rec.pid !== process.pid)
671
+ return;
672
+ if (readIdentityLock(succPathFor(lockPath, token)) !== 'absent')
673
+ return;
674
+ unlinkSync(path);
675
+ syncDirectory(dirname(path));
676
+ }
677
+ function recoveryAuthorized(first, dir) {
678
+ if (process.platform !== "linux")
679
+ return false;
680
+ if (first.kind === "v2" && (first.rec.platform !== "linux" || first.rec.publication !== "link"))
681
+ return false;
682
+ // The 0.98.5 succession format publishes by synchronized tmp + link (R2
683
+ // adapter): recognized as link, anchors used as-is.
684
+ return identityFsQualified(dir);
685
+ }
686
+ function makeIdentityLease(lockPath, token, publication, recovered) {
687
+ let done = false;
688
+ let dirty = false;
689
+ const stillHeld = () => { const read = identityChainView(lockPath); return !done && read !== 'absent' && read !== 'corrupt' && read.token === token; };
690
+ const release = () => {
691
+ process.off('exit', exit);
692
+ if (done || dirty)
693
+ return;
694
+ if (stillHeld()) {
695
+ unlinkSync(lockPath);
696
+ syncDirectory(dirname(lockPath));
697
+ }
698
+ done = true;
699
+ };
700
+ const exit = () => { try {
701
+ release();
702
+ }
703
+ catch { /* preserve barrier on storage failure */ } };
704
+ process.on('exit', exit);
705
+ return { acquired: true, token, stillHeld, release, publication, recovered,
706
+ beginMutation: () => { if (!stillHeld())
707
+ throw new Error('identity fence lost'); dirty = true; }, endMutation: () => { dirty = false; } };
708
+ }
709
+ export function repairIdentityJournal(dir, held, slots) {
710
+ if (!held())
711
+ throw new Error('identity repair election lost');
712
+ neutralizeBindingsTail(join(dir, 'bindings.jsonl'), held);
713
+ attestBindingsTailDurable(join(dir, 'bindings.jsonl'));
714
+ if (!held())
715
+ throw new Error('identity repair election lost');
716
+ repairBindingSlots(dir, held, slots);
717
+ }
718
+ /** Identity operator uses the same election and repair barrier; upgrade stays separate. */
719
+ export function breakIdentityLockAsOperator(lockPath, options) {
720
+ const read = readIdentityLock(lockPath);
721
+ if (read === 'absent' || read === 'corrupt' || read === 'unknown' || read.kind === 'io-error' || read.kind === 'cull')
722
+ return { broken: false, reason: read === 'absent' ? 'absent' : 'corrupt', legacyRecords: 0 };
723
+ const legacyRecords = read.kind === 'legacy' ? 1 : 0;
724
+ const token = read.kind === 'legacy' ? read.holder.token : read.rec.token;
725
+ const result = { token, pid: read.kind === 'legacy' ? read.holder.pid : read.rec.pid, legacyRecords };
726
+ if (token !== options.expectToken)
727
+ return { ...result, broken: false, reason: 'token-mismatch' };
728
+ const verdict = read.kind === 'legacy' ? 'undecidable' : identityVerdict(read, identitySelf());
729
+ if (verdict === 'live')
730
+ return { ...result, broken: false, reason: 'live' };
731
+ if (verdict === 'undecidable' && !options.assertDead)
732
+ return { ...result, broken: false, reason: 'assert-dead-required' };
733
+ const before = statSync(lockPath);
734
+ const same = () => { try {
735
+ const st = statSync(lockPath);
736
+ return st.dev === before.dev && st.ino === before.ino && readFileSync(lockPath).equals(read.bytes);
737
+ }
738
+ catch {
739
+ return false;
740
+ } };
741
+ let electedPath = '';
742
+ let electedToken = '';
743
+ const held = () => { const rec = identityChainView(electedPath); return same() && rec !== 'absent' && rec !== 'corrupt' && rec.token === electedToken && rec.target === token && readIdentityLock(succPathFor(lockPath, electedToken)) === 'absent'; };
744
+ const io = identityElectionIo(identitySelf());
745
+ const chainRead = io.readRecord;
746
+ io.readRecord = path => path === lockPath && read.kind === 'legacy' && same()
747
+ ? { host: '', hostKind: 'weak', boot: null, ns: null, timeNs: null, pid: read.holder.pid, start: null, token, at: 0 } : chainRead(path);
748
+ io.makeRec = (self, t, g) => ({ ...makeIdentityRecord(self, t, g), operator: true });
749
+ io.onElected = (path, t) => { electedPath = path; electedToken = t; };
750
+ const slots = prepareBindingSlots(dirname(lockPath));
751
+ io.beforeRetire = () => { if (!held()) {
752
+ abandonOwnSuccessor(lockPath, electedPath, electedToken, token);
753
+ return false;
754
+ } repairIdentityJournal(dirname(lockPath), held, slots); return held(); };
755
+ io.afterRetire = syncDirectory;
756
+ io.retireOnly = true;
757
+ const next = succeedDeadToken(dirname(lockPath), lockPath, token, identitySelf(), io);
758
+ if (next === 'retry')
759
+ return { ...result, broken: false, reason: 'token-mismatch' };
760
+ return { ...result, broken: next.acquired, ...(!next.acquired ? { reason: next.reason } : {}) };
761
+ }
762
+ function unreadableBlocker(code) {
763
+ return {
764
+ key: `io-error:${code}`,
765
+ info: {
766
+ blockerType: "unknown",
767
+ pid: "unknown",
768
+ machineClass: "unknown",
769
+ bootClass: "unknown",
770
+ ns: "unknown",
771
+ start: "unknown",
772
+ generation: "unknown",
773
+ target: "unknown",
774
+ reasonCode: "holder-unreadable",
775
+ reason: "the holder record could not be read",
776
+ retryCondition: "interval"
777
+ }
778
+ };
779
+ }
780
+ /**
781
+ * One acquisition attempt round. Returns a lease, the blocker observation to
782
+ * wait on, or a retry signal. Never throws for contention; storage errors
783
+ * propagate (errno preserved for the worker's cause classification).
784
+ */
785
+ function acquireIdentityRound(lockPath, dir, self, ctx) {
786
+ const read = readIdentityLock(lockPath);
787
+ if (typeof read === "object" && read.kind === "io-error") {
788
+ if (STORAGE_READ_CODES.has(read.code)) {
789
+ const err = new Error(`identity fence read failed: ${read.code}`);
790
+ err.code = read.code;
791
+ throw err;
792
+ }
793
+ // Transient read failure: treat as a blocker we cannot name yet.
794
+ return { blocker: unreadableBlocker(read.code) };
795
+ }
796
+ if (read === "absent") {
797
+ ctx.slotReceipts = prepareBindingSlots(dir);
798
+ fenceTimedOut(ctx);
799
+ const tok = newIdentityToken();
800
+ const rec = makeIdentityRecord(self, tok);
801
+ const pub = publishIdentityRecord(lockPath, rec);
802
+ if (pub.status === "ok") {
803
+ return { lease: makeIdentityLease(lockPath, tok, pub.publication ?? "link", ctx.recovered === true) };
804
+ }
805
+ if (pub.status === "exists" || pub.status === "retry")
806
+ return { retry: true };
807
+ const err = new Error(`identity fence publish failed: ${pub.code}`);
808
+ err.code = pub.code;
809
+ throw err;
810
+ }
811
+ if (typeof read === 'object' && read.kind === 'legacy' && read.eligible) {
812
+ if (!ctx.legacyAttempt) {
813
+ const captured = captureLegacyLock(lockPath, read.bytes, read.holder, self);
814
+ ctx.legacyAttempt = { generation: read.holder.token };
815
+ if ('fd' in captured) {
816
+ ctx.legacyAttempt.capture = captured;
817
+ ctx.legacyAttempt.result = identityFsQualified(dir) && process.platform === 'linux' ? legacyPass(ctx, captured)
818
+ : { dead: false, reasonCode: 'fs-not-qualified', reason: 'identity filesystem or platform is not recovery qualified', structural: true };
819
+ }
820
+ else
821
+ ctx.legacyAttempt.result = captured;
822
+ }
823
+ const attempt = ctx.legacyAttempt;
824
+ let evidence = attempt.result;
825
+ if (evidence.limit === 'processes' && attempt.capture) {
826
+ const exceeded = legacyCapacityExceeded(attempt.capture, ctx.deadline, identitySelf);
827
+ if (exceeded !== true)
828
+ ctx.state.stable = false;
829
+ evidence = { ...evidence, structural: exceeded === true && ctx.state.stable };
830
+ }
831
+ if (attempt.generation !== read.holder.token || attempt.capture && !legacyCaptureHeld(attempt.capture, identitySelf())) {
832
+ evidence = { dead: false, reasonCode: 'legacy-observation-changed', reason: 'legacy file or current frame changed' };
833
+ }
834
+ if (evidence.dead && attempt.capture && !attempt.confirmed) {
835
+ const scan = scanSuccession(lockPath, self, read, true);
836
+ if (scan.blockingLink === undefined) {
837
+ ctx.slotReceipts = prepareBindingSlots(dir);
838
+ fenceTimedOut(ctx);
839
+ const capture = attempt.capture;
840
+ const g = read.holder.token;
841
+ const io = identityElectionIo(self);
842
+ io.beforeElection = () => legacyCaptureHeld(capture, identitySelf()) && performance.now() < ctx.deadline;
843
+ const chainRead = io.readRecord;
844
+ io.readRecord = path => path === lockPath && legacyCaptureHeld(capture, identitySelf())
845
+ ? { host: '', hostKind: 'weak', boot: null, ns: null, timeNs: null, pid: read.holder.pid, start: null, token: g, at: 0 } : chainRead(path);
846
+ let electionPath = '';
847
+ let electionToken = '';
848
+ const held = () => { const link = chainRead(electionPath); return legacyCaptureHeld(capture, identitySelf()) && link !== 'absent' && link !== 'corrupt' && link.token === electionToken && link.target === g && readIdentityLock(succPathFor(lockPath, electionToken)) === 'absent'; };
849
+ io.onElected = (path, token) => { electionPath = path; electionToken = token; };
850
+ io.beforeRetire = () => {
851
+ attempt.confirmed = true;
852
+ const confirmation = legacyPass(ctx, capture);
853
+ if (!confirmation.dead || !held()) {
854
+ attempt.result = confirmation.dead ? { dead: false, reasonCode: 'legacy-observation-changed', reason: 'election changed before repair' } : confirmation;
855
+ abandonOwnSuccessor(lockPath, electionPath, electionToken, g);
856
+ return false;
857
+ }
858
+ try {
859
+ fenceTimedOut(ctx);
860
+ }
861
+ catch (e) {
862
+ abandonOwnSuccessor(lockPath, electionPath, electionToken, g);
863
+ throw e;
864
+ }
865
+ repairIdentityJournal(dir, held, ctx.slotReceipts);
866
+ return held();
867
+ };
868
+ io.afterRetire = dir => { syncDirectory(dir); ctx.recovered = true; };
869
+ const next = succeedDeadToken(dir, lockPath, g, self, io);
870
+ if (next === 'retry')
871
+ return { retry: true };
872
+ if (next.acquired)
873
+ return { lease: next };
874
+ if (next.reason?.startsWith('error:')) {
875
+ const code = next.reason.slice(6);
876
+ if (LINK_FALLBACK_CODES.has(code))
877
+ attempt.result = { dead: false, reasonCode: 'fs-no-atomic-publish', reason: 'succession requires atomic link publication', structural: true };
878
+ else
879
+ throw Object.assign(new Error('identity succession storage failure'), { code });
880
+ }
881
+ evidence = attempt.result;
882
+ }
883
+ else if (scan.exhausted) {
884
+ return { blocker: { key: `${read.holder.token}:chain-exhausted`, info: { ...blockerFromRead(read, self, 'SUCC', ctx.options.diagnosticKey), reasonCode: 'succ-chain-exhausted', reason: 'succession chain depth limit (8) reached', retryCondition: 'blocker-change' } } };
885
+ }
886
+ else if (scan.blockingLink === 'unknown' || scan.blockingLink === 'corrupt') {
887
+ return { blocker: { key: `${read.holder.token}:${blockerKey(scan.blockingLink, scan.blockingPath)}`, info: blockerFromRead(scan.blockingLink, self, 'SUCC', ctx.options.diagnosticKey) } };
888
+ }
889
+ else if (scan.blockingLink !== 'absent'
890
+ && typeof scan.blockingLink === 'object' && scan.blockingLink.kind !== 'io-error') {
891
+ return { blocker: { key: `${read.holder.token}:${blockerKey(scan.blockingLink, scan.blockingPath)}`, info: blockerFromRead(scan.blockingLink, self, 'SUCC', ctx.options.diagnosticKey) } };
892
+ }
893
+ else
894
+ evidence = { dead: false, reasonCode: 'succession-contested', reason: 'legacy succession is blocked' };
895
+ }
896
+ const base = blockerFromRead(read, self, 'LOCK', ctx.options.diagnosticKey);
897
+ return { blocker: { key: `${blockerKey(read, lockPath)}:${evidence.reasonCode}`, info: { ...base, reasonCode: evidence.reasonCode, reason: evidence.reason,
898
+ retryCondition: evidence.structural ? 'blocker-change' : 'interval' } } };
899
+ }
900
+ // A blocker exists: classify fresh, then decide recovery or wait (R3/R4).
901
+ const scan = scanSuccession(lockPath, self, read);
902
+ if (scan.blockingLink === undefined) {
903
+ // The LOCK itself is the effective blocker. Dead + authorized ⇒ recovery.
904
+ if (typeof read === "object" && (read.kind === "v2" || read.kind === "adapter")
905
+ && identityVerdict(read, self) === "dead" && recoveryAuthorized(read, dir)) {
906
+ const g = read.rec.token;
907
+ ctx.slotReceipts = prepareBindingSlots(dir);
908
+ fenceTimedOut(ctx);
909
+ const next = succeedDeadToken(dir, lockPath, g, self, identityElectionIo(self, lockPath, ctx));
910
+ if (next === "retry")
911
+ return { retry: true };
912
+ if (next.acquired) {
913
+ return { lease: next };
914
+ }
915
+ if (next.reason !== undefined && next.reason.startsWith("error:")) {
916
+ const code = next.reason.slice("error:".length);
917
+ if (LINK_FALLBACK_CODES.has(code))
918
+ return { blocker: { key: blockerKey(read, lockPath), info: { ...blockerFromRead(read, self, 'LOCK', ctx.options.diagnosticKey), reasonCode: 'fs-no-atomic-publish', reason: 'succession requires atomic link publication', retryCondition: 'blocker-change' } } };
919
+ const err = new Error(`identity fence recovery failed: ${code}`);
920
+ err.code = code;
921
+ throw err;
922
+ }
923
+ // busy: a rival live successor won the election; dead-undecidable: the
924
+ // chain is not walkable. Both wait for a relevant change (R4).
925
+ return {
926
+ blocker: {
927
+ key: blockerKey(read, lockPath),
928
+ info: {
929
+ ...blockerFromRead(read, self, "LOCK", ctx.options.diagnosticKey),
930
+ reasonCode: "succession-contested",
931
+ reason: "another live successor won the election for the dead holder",
932
+ retryCondition: "interval"
933
+ }
934
+ }
935
+ };
936
+ }
937
+ return {
938
+ blocker: {
939
+ key: blockerKey(read, lockPath),
940
+ info: blockerFromRead(read, self, "LOCK", ctx.options.diagnosticKey)
941
+ }
942
+ };
943
+ }
944
+ if (scan.exhausted) {
945
+ // Chain exhausted at depth 8 (R4): bounded wait, no blind purge.
946
+ return {
947
+ blocker: {
948
+ key: "chain-exhausted",
949
+ info: {
950
+ ...blockerFromRead(read, self, "LOCK", ctx.options.diagnosticKey),
951
+ blockerType: "SUCC",
952
+ reasonCode: "succ-chain-exhausted",
953
+ reason: "the succession chain reached its depth limit; no blind purge is performed",
954
+ retryCondition: "blocker-change"
955
+ }
956
+ }
957
+ };
958
+ }
959
+ if (scan.blockingLink === "absent") {
960
+ // Settled between the scan and here: re-evaluate from scratch.
961
+ return { retry: true };
962
+ }
963
+ const link = scan.blockingLink;
964
+ if (link === "corrupt" || link === 'unknown' || (typeof link === "object" && link.kind !== "io-error")) {
965
+ return {
966
+ blocker: {
967
+ key: `SUCC@${blockerKey(read, lockPath)}:${blockerKey(link, scan.blockingPath)}`,
968
+ info: blockerFromRead(link, self, "SUCC", ctx.options.diagnosticKey)
969
+ }
970
+ };
971
+ }
972
+ const code = typeof link === "object" && link.kind === "io-error" ? link.code : "EIO";
973
+ return { blocker: { key: `SUCC@link-io:${code}`, info: unreadableBlocker(code).info } };
974
+ }
975
+ function defaultRelativeLockPath(lockPath) {
976
+ return `${basename(dirname(lockPath))}/${basename(lockPath)}`;
977
+ }
978
+ function observeBlocker(state, blocker) {
979
+ blocker = { ...blocker, key: `${blocker.key}:${blocker.info.reasonCode}` };
980
+ if (state.firstKey === undefined) {
981
+ state.firstKey = blocker.key;
982
+ state.firstInfo = blocker.info;
983
+ }
984
+ if (state.lastKey !== undefined && state.lastKey !== blocker.key) {
985
+ state.stable = false;
986
+ }
987
+ state.lastKey = blocker.key;
988
+ state.lastInfo = blocker.info;
989
+ }
990
+ function timeoutDiagnostic(lockPath, state, startedAt, budgetMs, relative) {
991
+ const info = state.lastInfo ?? state.firstInfo;
992
+ const stable = state.stable && state.lastKey !== undefined && !state.lastKey.includes('meta-unobserved') && info?.reasonCode !== 'holder-unreadable';
993
+ const isStableLegacy = stable && info?.reasonCode === "legacy-generation-unsupported";
994
+ const isB = info?.blockerType === 'LOCK' && info.generation.startsWith('legacy-') && !isStableLegacy;
995
+ const diagnostic = {
996
+ elapsedMs: Math.round(performance.now() - startedAt),
997
+ budgetMs,
998
+ lock: relative,
999
+ stable,
1000
+ ...(info
1001
+ ? { ...info, retryCondition: stable ? info.retryCondition : 'interval' }
1002
+ : {
1003
+ blockerType: "unknown",
1004
+ pid: "unknown",
1005
+ machineClass: "unknown",
1006
+ bootClass: "unknown",
1007
+ ns: "unknown",
1008
+ start: "unknown",
1009
+ generation: "unknown",
1010
+ target: "unknown",
1011
+ reasonCode: "no-observation",
1012
+ reason: "no holder record could be observed during this attempt",
1013
+ retryCondition: "interval"
1014
+ }),
1015
+ ...(info && info.retryCondition === "interval" ? { retryAfterMs: RETRY_AFTER_MS } : {}),
1016
+ ...(isStableLegacy
1017
+ ? {
1018
+ legacyNote: LEGACY_NOTE,
1019
+ command: `h2a identity unlock --root <validated-store-root> --token ${info.generation} --assert-dead`
1020
+ }
1021
+ : {}),
1022
+ ...(isB ? { legacyNote: `Legacy identity lock retained: independent recovery evidence is incomplete (${info.reasonCode}). Normal release may still allow acquisition.` } : {})
1023
+ };
1024
+ return new IdentityLockTimeoutError(lockPath, diagnostic);
1025
+ }
1026
+ function newLoopContext(lockPath, options) {
1027
+ const dir = dirname(lockPath);
1028
+ ensureIdentityDir(dir);
1029
+ const observe = options.observe;
1030
+ const quota = options.signal ? LEGACY_PASS_QUOTAS.get(options.signal) ?? { passes: 0 } : { passes: 0 };
1031
+ if (options.signal)
1032
+ LEGACY_PASS_QUOTAS.set(options.signal, quota);
1033
+ return {
1034
+ quota,
1035
+ options,
1036
+ lockPath,
1037
+ dir,
1038
+ relative: options.relativeLockPath ?? defaultRelativeLockPath(lockPath),
1039
+ lock: observe ? logicalLockId(lockPath) : "",
1040
+ ...(observe ? { observe } : {}),
1041
+ startedAt: performance.now(),
1042
+ deadline: performance.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
1043
+ state: { stable: true },
1044
+ attempts: 0
1045
+ };
1046
+ }
1047
+ function legacyPass(ctx, capture) {
1048
+ if (ctx.quota.passes >= 2)
1049
+ return { dead: false, reasonCode: 'legacy-inventory-incomplete', reason: 'legacy inventory attempt quota exhausted' };
1050
+ ctx.quota.passes++;
1051
+ return collectLegacyEvidence(capture, ctx.deadline, identitySelf);
1052
+ }
1053
+ /** Throw the bounded R7 diagnostic when the budget is spent; else return. */
1054
+ function fenceTimedOut(ctx) {
1055
+ if (performance.now() < ctx.deadline)
1056
+ return;
1057
+ notifyObserver(ctx.observe, {
1058
+ lock: ctx.lock,
1059
+ event: "timeout",
1060
+ waitMs: performance.now() - ctx.startedAt,
1061
+ attempts: ctx.attempts
1062
+ });
1063
+ throw timeoutDiagnostic(ctx.lockPath, ctx.state, ctx.startedAt, ctx.deadline - ctx.startedAt, ctx.relative);
1064
+ }
1065
+ /** Synchronous identity fence (CLI / sync binding writer). */
1066
+ export function withIdentityFenceSync(lockPath, fn, options = {}) {
1067
+ try {
1068
+ return withIdentityFenceSyncInternal(lockPath, fn, options);
1069
+ }
1070
+ catch (error) {
1071
+ throw redactIdentityStorageError(error);
1072
+ }
1073
+ }
1074
+ function withIdentityFenceSyncInternal(lockPath, fn, options) {
1075
+ const ctx = newLoopContext(lockPath, options);
1076
+ try {
1077
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
1078
+ notifyObserver(ctx.observe, { lock: ctx.lock, event: "wait" });
1079
+ let lease;
1080
+ for (;;) {
1081
+ fenceTimedOut(ctx);
1082
+ const outcome = acquireIdentityRoundOnce(ctx);
1083
+ if (outcome !== undefined) {
1084
+ lease = outcome;
1085
+ break;
1086
+ }
1087
+ fenceTimedOut(ctx);
1088
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(pollMs, Math.max(0, ctx.deadline - performance.now())));
1089
+ }
1090
+ const acquiredAt = performance.now();
1091
+ try {
1092
+ lease.beginMutation();
1093
+ repairIdentityJournal(ctx.dir, lease.stillHeld, ctx.slotReceipts);
1094
+ lease.endMutation();
1095
+ fenceTimedOut(ctx);
1096
+ const result = fn(lease);
1097
+ fenceTimedOut(ctx);
1098
+ return result;
1099
+ }
1100
+ finally {
1101
+ lease.release();
1102
+ notifyObserver(ctx.observe, {
1103
+ lock: ctx.lock,
1104
+ event: "released",
1105
+ holdMs: performance.now() - acquiredAt
1106
+ });
1107
+ }
1108
+ }
1109
+ finally {
1110
+ if (ctx.legacyAttempt?.capture)
1111
+ closeSync(ctx.legacyAttempt.capture.fd);
1112
+ }
1113
+ }
1114
+ /** Asynchronous identity fence (the identity worker's binding transaction). */
1115
+ export async function withIdentityFence(lockPath, fn, options = {}) {
1116
+ try {
1117
+ return await withIdentityFenceInternal(lockPath, fn, options);
1118
+ }
1119
+ catch (error) {
1120
+ throw redactIdentityStorageError(error);
1121
+ }
1122
+ }
1123
+ async function withIdentityFenceInternal(lockPath, fn, options) {
1124
+ const ctx = newLoopContext(lockPath, options);
1125
+ try {
1126
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
1127
+ const signal = options.signal;
1128
+ notifyObserver(ctx.observe, { lock: ctx.lock, event: "wait" });
1129
+ let lease;
1130
+ for (;;) {
1131
+ fenceTimedOut(ctx);
1132
+ if (signal?.aborted) {
1133
+ notifyObserver(ctx.observe, {
1134
+ lock: ctx.lock,
1135
+ event: "timeout",
1136
+ waitMs: performance.now() - ctx.startedAt,
1137
+ attempts: ctx.attempts
1138
+ });
1139
+ throw new LockCancelledError(lockPath, String(signal.reason ?? "aborted"));
1140
+ }
1141
+ const outcome = acquireIdentityRoundOnce(ctx);
1142
+ if (outcome !== undefined) {
1143
+ lease = outcome;
1144
+ break;
1145
+ }
1146
+ fenceTimedOut(ctx);
1147
+ // Cooperative cancellation between poll attempts only; a transaction
1148
+ // already entered under the fence finishes (released in the finally).
1149
+ if (signal !== undefined) {
1150
+ if (signal.aborted)
1151
+ continue;
1152
+ await new Promise((resolve) => {
1153
+ const timer = setTimeout(resolve, Math.min(pollMs, Math.max(0, ctx.deadline - performance.now())));
1154
+ signal.addEventListener("abort", () => {
1155
+ clearTimeout(timer);
1156
+ resolve();
1157
+ }, { once: true });
1158
+ });
1159
+ }
1160
+ else {
1161
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, ctx.deadline - performance.now()))));
1162
+ }
1163
+ }
1164
+ const acquiredAt = performance.now();
1165
+ try {
1166
+ lease.beginMutation();
1167
+ repairIdentityJournal(ctx.dir, lease.stillHeld, ctx.slotReceipts);
1168
+ lease.endMutation();
1169
+ fenceTimedOut(ctx);
1170
+ const result = await fn(lease);
1171
+ fenceTimedOut(ctx);
1172
+ return result;
1173
+ }
1174
+ finally {
1175
+ lease.release();
1176
+ notifyObserver(ctx.observe, {
1177
+ lock: ctx.lock,
1178
+ event: "released",
1179
+ holdMs: performance.now() - acquiredAt
1180
+ });
1181
+ }
1182
+ }
1183
+ finally {
1184
+ if (ctx.legacyAttempt?.capture)
1185
+ closeSync(ctx.legacyAttempt.capture.fd);
1186
+ }
1187
+ }
1188
+ /**
1189
+ * One acquisition round inside a loop context: the lease on success, undefined
1190
+ * when the round must wait (blocker observed) or immediately re-run (retry).
1191
+ */
1192
+ function acquireIdentityRoundOnce(ctx) {
1193
+ ctx.attempts += 1;
1194
+ const self = identitySelf();
1195
+ const outcome = acquireIdentityRound(ctx.lockPath, ctx.dir, self, ctx);
1196
+ if (outcome.lease !== undefined) {
1197
+ notifyObserver(ctx.observe, {
1198
+ lock: ctx.lock,
1199
+ event: "acquired",
1200
+ waitMs: performance.now() - ctx.startedAt,
1201
+ attempts: ctx.attempts
1202
+ });
1203
+ return outcome.lease;
1204
+ }
1205
+ if (outcome.blocker !== undefined) {
1206
+ observeBlocker(ctx.state, outcome.blocker);
1207
+ ctx.options.onBlocker?.(outcome.blocker.info, outcome.blocker.key);
1208
+ }
1209
+ else
1210
+ ctx.state.stable = false;
1211
+ return undefined;
1212
+ }
1213
+ const TAIL_CHUNK = 64 * 1024;
1214
+ function readRange(fd, start, end) {
1215
+ const buffer = Buffer.alloc(end - start);
1216
+ let read = 0;
1217
+ while (read < buffer.length) {
1218
+ const n = readSync(fd, buffer, read, buffer.length - read, start + read);
1219
+ if (n === 0)
1220
+ throw new Error("binding journal changed during read");
1221
+ read += n;
1222
+ }
1223
+ return buffer;
1224
+ }
1225
+ /**
1226
+ * Terminate an unterminated final suffix of the journal with exactly one
1227
+ * appended `\n` (R5). No byte before the suffix is ever rewritten or removed;
1228
+ * the operation is idempotent. `offset`/`length` are in bytes, `sha256` covers
1229
+ * the ORIGINAL suffix without the added separator; no content is exposed.
1230
+ */
1231
+ export function neutralizeBindingsTail(file, held = () => true) {
1232
+ let fd;
1233
+ try {
1234
+ fd = openSync(file, "r+");
1235
+ }
1236
+ catch (e) {
1237
+ if (errnoOf(e) === "ENOENT")
1238
+ return undefined;
1239
+ throw e;
1240
+ }
1241
+ try {
1242
+ const before = fstatSync(fd);
1243
+ if (before.size === 0)
1244
+ return undefined;
1245
+ // Locate the last line feed (bounded reverse scan).
1246
+ let lastNl = -1;
1247
+ for (let probe = before.size; probe > 0 && lastNl === -1;) {
1248
+ const start = Math.max(0, probe - TAIL_CHUNK);
1249
+ const chunk = readRange(fd, start, probe);
1250
+ const idx = chunk.lastIndexOf(10);
1251
+ if (idx !== -1)
1252
+ lastNl = start + idx;
1253
+ probe = start;
1254
+ }
1255
+ const suffixStart = lastNl === -1 ? 0 : lastNl + 1;
1256
+ const st1 = fstatSync(fd);
1257
+ if (st1.dev !== before.dev || st1.ino !== before.ino || st1.size !== before.size) {
1258
+ // Possession/inode changed under us: re-read fresh, apply nothing stale.
1259
+ return neutralizeBindingsTail(file, held);
1260
+ }
1261
+ if (suffixStart >= st1.size)
1262
+ return undefined; // already terminated: no-op
1263
+ const suffix = readRange(fd, suffixStart, st1.size);
1264
+ const st2 = fstatSync(fd);
1265
+ if (st2.dev !== st1.dev || st2.ino !== st1.ino || st2.size !== st1.size) {
1266
+ return neutralizeBindingsTail(file, held);
1267
+ }
1268
+ // Append exactly one separator, byte-count checked, then fsync file + dir.
1269
+ const appendFd = openSync(file, "a");
1270
+ try {
1271
+ const pathStat = statSync(file);
1272
+ const appendStat = fstatSync(appendFd);
1273
+ if (!held() || pathStat.dev !== before.dev || pathStat.ino !== before.ino || appendStat.ino !== before.ino || pathStat.size !== before.size)
1274
+ throw new Error('binding journal changed before neutralization');
1275
+ const written = writeSync(appendFd, "\n");
1276
+ if (written !== 1) {
1277
+ const err = new Error("binding journal separator short write");
1278
+ err.code = "ENOSPC";
1279
+ throw err;
1280
+ }
1281
+ fsyncSync(appendFd);
1282
+ }
1283
+ finally {
1284
+ try {
1285
+ closeSync(appendFd);
1286
+ }
1287
+ catch {
1288
+ // best-effort
1289
+ }
1290
+ }
1291
+ fsyncDirectory(dirname(file));
1292
+ return {
1293
+ offset: suffixStart,
1294
+ length: st2.size - suffixStart,
1295
+ sha256: sha256Bytes(suffix)
1296
+ };
1297
+ }
1298
+ finally {
1299
+ if (fd !== undefined) {
1300
+ try {
1301
+ closeSync(fd);
1302
+ }
1303
+ catch {
1304
+ // best-effort
1305
+ }
1306
+ }
1307
+ }
1308
+ }
1309
+ /**
1310
+ * Attest the journal's durability after a recovery (R5 idempotence): a visible
1311
+ * separator whose previous fsync is unattested is fsynced now, content-free.
1312
+ */
1313
+ export function attestBindingsTailDurable(file) {
1314
+ let fd;
1315
+ try {
1316
+ fd = openSync(file, "r+");
1317
+ fsyncSync(fd);
1318
+ }
1319
+ catch (e) {
1320
+ if (errnoOf(e) === "ENOENT")
1321
+ return;
1322
+ throw e;
1323
+ }
1324
+ finally {
1325
+ if (fd !== undefined) {
1326
+ try {
1327
+ closeSync(fd);
1328
+ }
1329
+ catch {
1330
+ // best-effort
1331
+ }
1332
+ }
1333
+ }
1334
+ fsyncDirectory(dirname(file));
1335
+ }
1336
+ //# sourceMappingURL=identity-fence.js.map