immune-brain 2.8.2

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 (101) hide show
  1. package/README.md +239 -0
  2. package/README.zh-CN.md +239 -0
  3. package/package.json +84 -0
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
  6. package/plugins/immune-brain/.pi-extension/package.json +11 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
  10. package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
  11. package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
  12. package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
  13. package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
  14. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
  15. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
  16. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
  17. package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
  18. package/plugins/immune-brain/bin/imm-kernel +4 -0
  19. package/plugins/immune-brain/bin/imm-plan +4 -0
  20. package/plugins/immune-brain/bin/imm-pr-diag +230 -0
  21. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
  22. package/plugins/immune-brain/bin/imm-retired +4 -0
  23. package/plugins/immune-brain/bin/imm-tracker +4 -0
  24. package/plugins/immune-brain/dist/BASELINE.md +138 -0
  25. package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
  26. package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
  27. package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
  28. package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
  29. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
  30. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
  31. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
  32. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
  33. package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
  34. package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
  35. package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
  36. package/plugins/immune-brain/dist/imm-loop.md +158 -0
  37. package/plugins/immune-brain/dist/imm-planner.md +387 -0
  38. package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
  39. package/plugins/immune-brain/dist/registry.yaml +49 -0
  40. package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
  41. package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
  42. package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
  43. package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
  44. package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
  45. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
  46. package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
  47. package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
  48. package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
  49. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
  50. package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
  51. package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
  52. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
  53. package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
  54. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
  55. package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
  56. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
  57. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
  58. package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
  59. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
  60. package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
  61. package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
  62. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
  63. package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
  64. package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
  65. package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
  66. package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
  67. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
  68. package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
  69. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
  70. package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
  71. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
  72. package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
  73. package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
  74. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
  75. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
  76. package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
  77. package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
  78. package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
  79. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
  80. package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
  81. package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
  82. package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
  83. package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
  84. package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
  85. package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
  86. package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
  87. package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
  88. package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
  89. package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
  90. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
  91. package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
  92. package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
  93. package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
  94. package/plugins/immune-brain/skills/.ignore +1 -0
  95. package/plugins/immune-brain/skills/BASELINE.md +138 -0
  96. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
  97. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
  98. package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
  99. package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
  100. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
  101. package/plugins/immune-brain/skills/registry.yaml +49 -0
@@ -0,0 +1,1780 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ fstatSync,
6
+ fsyncSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ renameSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
17
+ import {
18
+ parseBackendClaim,
19
+ readBackendClaim,
20
+ parseTaskTombstone,
21
+ readTaskTombstone,
22
+ serializeTaskTombstone,
23
+ type BackendClaim,
24
+ type TaskTombstone,
25
+ } from "./backend_claim";
26
+ import {
27
+ auditTaskRecordPath,
28
+ auditTerminalProofPath,
29
+ JOURNAL_RELATIVE,
30
+ stateClaimPath,
31
+ stateStoreLockPath,
32
+ stateTaskRecordPath,
33
+ stateTransactionPath,
34
+ stateWorkspacePath,
35
+ } from "./storage_paths";
36
+ import { parseTaskRecordV2, parseTaskRecordV3 } from "./validation";
37
+ import type {
38
+ TaskLifecycle,
39
+ TaskPhase,
40
+ TaskRecordV2,
41
+ TaskRecordV3,
42
+ StoredTaskMutationV3,
43
+ V3AuthorityObservation,
44
+ } from "./types";
45
+
46
+ export const MISSING_REVISION = "missing";
47
+
48
+ export class KernelStoreConflictError extends Error {
49
+ readonly code = "kernel_store_conflict";
50
+
51
+ constructor(message: string) {
52
+ super(message);
53
+ this.name = "KernelStoreConflictError";
54
+ }
55
+ }
56
+
57
+ export class KernelStoreSecurityError extends Error {
58
+ readonly code = "kernel_store_security_error";
59
+
60
+ constructor(message: string) {
61
+ super(message);
62
+ this.name = "KernelStoreSecurityError";
63
+ }
64
+ }
65
+
66
+ export interface WorkspaceState {
67
+ contract: "assurance_kernel/workspace/v1";
68
+ current_working: string | null;
69
+ }
70
+
71
+ export type JournalReasonCode =
72
+ | "command_ok"
73
+ | "authority_commit_observed"
74
+ | "invalid_command"
75
+ | "dry_run_required"
76
+ | "source_missing"
77
+ | "source_invalid"
78
+ | "source_read_failed"
79
+ | "shadow_divergence"
80
+ | "migration_ambiguous"
81
+ | "readiness_query_nonqualifying";
82
+
83
+ export interface JournalEntry {
84
+ contract: "assurance_kernel/journal/v1";
85
+ timestamp: string;
86
+ task_id: string | null;
87
+ command: string;
88
+ entry_phase: TaskPhase | null;
89
+ result: "ok" | "rejected" | "escalated";
90
+ reason_code: JournalReasonCode;
91
+ recovery_hint: string | null;
92
+ planner_reentry: boolean;
93
+ user_intervention: boolean;
94
+ observation?: V3AuthorityObservation;
95
+ }
96
+
97
+ function revisionFor(content: string): string {
98
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
99
+ }
100
+
101
+ function canonicalRoot(root: string): string {
102
+ try {
103
+ return realpathSync(root);
104
+ } catch {
105
+ throw new KernelStoreSecurityError("project root is unavailable");
106
+ }
107
+ }
108
+
109
+ function withinRoot(root: string, candidate: string): boolean {
110
+ const rel = relative(root, candidate);
111
+ return (
112
+ rel === "" ||
113
+ (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`))
114
+ );
115
+ }
116
+
117
+ function safeCandidate(root: string, relativePath: string): {
118
+ root: string;
119
+ path: string;
120
+ } {
121
+ if (
122
+ !relativePath ||
123
+ relativePath.includes("\0") ||
124
+ isAbsolute(relativePath) ||
125
+ relativePath.includes("\\")
126
+ )
127
+ throw new KernelStoreSecurityError("project-relative path is invalid");
128
+ const canonical = canonicalRoot(root);
129
+ const candidate = resolve(canonical, relativePath);
130
+ if (!withinRoot(canonical, candidate))
131
+ throw new KernelStoreSecurityError("path escapes the project root");
132
+ return { root: canonical, path: candidate };
133
+ }
134
+
135
+ function pathStatOrNull(path: string): ReturnType<typeof lstatSync> | null {
136
+ try {
137
+ return lstatSync(path);
138
+ } catch (error) {
139
+ const code = (error as NodeJS.ErrnoException).code;
140
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ function assertNoSymlinkSegments(root: string, candidate: string): void {
146
+ const rel = relative(root, candidate);
147
+ let current = root;
148
+ for (const segment of rel.split(sep).filter(Boolean)) {
149
+ current = resolve(current, segment);
150
+ const stat = pathStatOrNull(current);
151
+ if (!stat) continue;
152
+ if (stat.isSymbolicLink())
153
+ throw new KernelStoreSecurityError(
154
+ `symlink storage segment is forbidden: ${relative(root, current)}`,
155
+ );
156
+ }
157
+ }
158
+
159
+ function capturePathIdentities(
160
+ root: string,
161
+ candidate: string,
162
+ ): Array<{ path: string; dev: number; ino: number }> {
163
+ const paths = [root];
164
+ let current = root;
165
+ for (const segment of relative(root, candidate).split(sep).filter(Boolean)) {
166
+ current = resolve(current, segment);
167
+ paths.push(current);
168
+ }
169
+ return paths.map((path) => {
170
+ const stat = lstatSync(path);
171
+ if (stat.isSymbolicLink())
172
+ throw new KernelStoreSecurityError(
173
+ `symlink storage segment is forbidden: ${relative(root, path)}`,
174
+ );
175
+ return { path, dev: stat.dev, ino: stat.ino };
176
+ });
177
+ }
178
+
179
+ function assertPathIdentitiesUnchanged(
180
+ before: Array<{ path: string; dev: number; ino: number }>,
181
+ ): void {
182
+ for (const identity of before) {
183
+ const after = lstatSync(identity.path);
184
+ if (
185
+ after.isSymbolicLink() ||
186
+ after.dev !== identity.dev ||
187
+ after.ino !== identity.ino
188
+ )
189
+ throw new KernelStoreSecurityError(
190
+ `path identity changed during access: ${identity.path}`,
191
+ );
192
+ }
193
+ }
194
+
195
+ function ensureSecureDirectory(root: string, relativePath: string): string {
196
+ const target = safeCandidate(root, relativePath);
197
+ const rel = relative(target.root, target.path);
198
+ let current = target.root;
199
+ for (const segment of rel.split(sep).filter(Boolean)) {
200
+ current = resolve(current, segment);
201
+ const stat = pathStatOrNull(current);
202
+ if (stat) {
203
+ if (stat.isSymbolicLink())
204
+ throw new KernelStoreSecurityError(
205
+ `symlink storage segment is forbidden: ${relative(target.root, current)}`,
206
+ );
207
+ if (!stat.isDirectory())
208
+ throw new KernelStoreSecurityError(
209
+ `storage segment is not a directory: ${relative(target.root, current)}`,
210
+ );
211
+ continue;
212
+ }
213
+ mkdirSync(current);
214
+ }
215
+ return target.path;
216
+ }
217
+
218
+ export function readSecureProjectFile(
219
+ root: string,
220
+ relativePath: string,
221
+ ): string {
222
+ const candidate = safeCandidate(root, relativePath);
223
+ assertNoSymlinkSegments(candidate.root, candidate.path);
224
+ const before = pathStatOrNull(candidate.path);
225
+ if (!before) throw new Error(`source_missing: ${relativePath}`);
226
+ const identities = capturePathIdentities(candidate.root, candidate.path);
227
+ if (!before.isFile())
228
+ throw new KernelStoreSecurityError(`source is not a regular file: ${relativePath}`);
229
+ const noFollow = constants.O_NOFOLLOW ?? 0;
230
+ let fd: number | null = null;
231
+ try {
232
+ fd = openSync(candidate.path, constants.O_RDONLY | noFollow);
233
+ const opened = fstatSync(fd);
234
+ if (opened.dev !== before.dev || opened.ino !== before.ino)
235
+ throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
236
+ const content = readFileSync(fd, "utf8");
237
+ const after = lstatSync(candidate.path);
238
+ if (after.dev !== opened.dev || after.ino !== opened.ino)
239
+ throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
240
+ assertPathIdentitiesUnchanged(identities);
241
+ return content;
242
+ } finally {
243
+ if (fd !== null) closeSync(fd);
244
+ }
245
+ }
246
+
247
+ function currentRevision(root: string, relativePath: string): string {
248
+ const candidate = safeCandidate(root, relativePath);
249
+ const stat = pathStatOrNull(candidate.path);
250
+ if (!stat) return MISSING_REVISION;
251
+ if (stat.isSymbolicLink())
252
+ throw new KernelStoreSecurityError(`symlink storage target is forbidden: ${relativePath}`);
253
+ return revisionFor(readSecureProjectFile(root, relativePath));
254
+ }
255
+
256
+ function processIsAlive(pid: number): boolean {
257
+ try {
258
+ process.kill(pid, 0);
259
+ return true;
260
+ } catch (error) {
261
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
262
+ }
263
+ }
264
+
265
+ function clearStaleLock(lockPath: string): boolean {
266
+ const before = pathStatOrNull(lockPath);
267
+ if (!before) return true;
268
+ if (before.isSymbolicLink() || !before.isFile())
269
+ throw new KernelStoreSecurityError(
270
+ "kernel store lock is not a regular file",
271
+ );
272
+ let stale = false;
273
+ let fd: number | null = null;
274
+ try {
275
+ fd = openSync(lockPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
276
+ const raw = JSON.parse(readFileSync(fd, "utf8")) as Record<string, unknown>;
277
+ stale =
278
+ Number.isInteger(raw.pid) &&
279
+ Number(raw.pid) > 0 &&
280
+ !processIsAlive(Number(raw.pid));
281
+ } catch {
282
+ stale = Date.now() - Number(before.mtimeMs) > 30_000;
283
+ } finally {
284
+ if (fd !== null) closeSync(fd);
285
+ }
286
+ if (!stale) return false;
287
+ const after = lstatSync(lockPath);
288
+ if (
289
+ after.isSymbolicLink() ||
290
+ after.dev !== before.dev ||
291
+ after.ino !== before.ino
292
+ )
293
+ throw new KernelStoreSecurityError(
294
+ "kernel store lock identity changed during recovery",
295
+ );
296
+ rmSync(lockPath);
297
+ return true;
298
+ }
299
+
300
+ function withExclusiveLock<T>(lockPath: string, operation: () => T): T {
301
+ const noFollow = constants.O_NOFOLLOW ?? 0;
302
+ let fd: number | null = null;
303
+ for (let attempt = 0; attempt < 2; attempt += 1) {
304
+ try {
305
+ fd = openSync(
306
+ lockPath,
307
+ constants.O_WRONLY |
308
+ constants.O_CREAT |
309
+ constants.O_EXCL |
310
+ noFollow,
311
+ 0o600,
312
+ );
313
+ break;
314
+ } catch (error) {
315
+ if (
316
+ attempt === 0 &&
317
+ (error as NodeJS.ErrnoException).code === "EEXIST" &&
318
+ clearStaleLock(lockPath)
319
+ )
320
+ continue;
321
+ throw new KernelStoreConflictError(
322
+ `kernel store lock is busy: ${error instanceof Error ? error.message : error}`,
323
+ );
324
+ }
325
+ }
326
+ if (fd === null)
327
+ throw new KernelStoreConflictError("kernel store lock could not be acquired");
328
+ const identity = fstatSync(fd);
329
+ try {
330
+ writeFileSync(
331
+ fd,
332
+ `${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`,
333
+ "utf8",
334
+ );
335
+ fsyncSync(fd);
336
+ return operation();
337
+ } finally {
338
+ closeSync(fd);
339
+ const current = pathStatOrNull(lockPath);
340
+ if (
341
+ current &&
342
+ !current.isSymbolicLink() &&
343
+ current.dev === identity.dev &&
344
+ current.ino === identity.ino
345
+ )
346
+ rmSync(lockPath);
347
+ }
348
+ }
349
+
350
+ function fsyncDirectory(path: string): void {
351
+ const fd = openSync(path, constants.O_RDONLY);
352
+ try {
353
+ fsyncSync(fd);
354
+ } finally {
355
+ closeSync(fd);
356
+ }
357
+ }
358
+
359
+ function atomicCasWrite(
360
+ root: string,
361
+ relativePath: string,
362
+ content: string,
363
+ expectedRevision: string,
364
+ ): string {
365
+ const candidate = safeCandidate(root, relativePath);
366
+ const parentRelative = relative(candidate.root, dirname(candidate.path));
367
+ ensureSecureDirectory(root, parentRelative);
368
+ assertNoSymlinkSegments(candidate.root, candidate.path);
369
+ return withExclusiveLock(`${candidate.path}.lock`, () => {
370
+ const actualRevision = currentRevision(root, relativePath);
371
+ if (actualRevision !== expectedRevision)
372
+ throw new KernelStoreConflictError(
373
+ `CAS mismatch for ${relativePath}: expected ${expectedRevision}, got ${actualRevision}`,
374
+ );
375
+ const tempPath = `${candidate.path}.${randomUUID()}.tmp`;
376
+ let fd: number | null = null;
377
+ try {
378
+ fd = openSync(
379
+ tempPath,
380
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
381
+ 0o600,
382
+ );
383
+ writeFileSync(fd, content, "utf8");
384
+ fsyncSync(fd);
385
+ closeSync(fd);
386
+ fd = null;
387
+ assertNoSymlinkSegments(candidate.root, candidate.path);
388
+ renameSync(tempPath, candidate.path);
389
+ fsyncDirectory(dirname(candidate.path));
390
+ } finally {
391
+ if (fd !== null) closeSync(fd);
392
+ rmSync(tempPath, { force: true });
393
+ }
394
+ return revisionFor(content);
395
+ });
396
+ }
397
+
398
+ function validateTaskId(taskId: string): void {
399
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
400
+ throw new KernelStoreSecurityError("task_id is not a safe file identity");
401
+ }
402
+
403
+ const JOURNAL_LOCK_NAME = ".journal.lock";
404
+ const JOURNAL_READ_LIMIT = 64 * 1024 * 1024;
405
+ const TRANSACTION_PATH = ".imm/tasks/.workspace-transaction.json";
406
+ const V1_TRANSACTION_RETIRED = "workspace_transaction/v1 is retired after v4 storage retirement; use TaskRecord v3 + workspace_transaction/v2";
407
+
408
+ let afterTaskTransactionWriteForTest: (() => void) | null = null;
409
+
410
+ /** Test-only seam for a failure after the first file in a two-file transaction. */
411
+ export function setAfterTaskTransactionWriteForTest(
412
+ hook: (() => void) | null,
413
+ ): void {
414
+ afterTaskTransactionWriteForTest = hook;
415
+ }
416
+
417
+ function runAfterTaskTransactionWriteHook(): void {
418
+ const hook = afterTaskTransactionWriteForTest;
419
+ afterTaskTransactionWriteForTest = null;
420
+ hook?.();
421
+ }
422
+
423
+ let terminalSettlementStepHookForTest: ((stepIndex: number) => void) | null = null;
424
+
425
+ /** Test-only seam: fail deterministically after each terminal settlement step. */
426
+ export function setTerminalSettlementStepHookForTest(
427
+ hook: ((stepIndex: number) => void) | null,
428
+ ): void {
429
+ terminalSettlementStepHookForTest = hook;
430
+ }
431
+
432
+ function runTerminalSettlementStepHook(stepIndex: number): void {
433
+ const hook = terminalSettlementStepHookForTest;
434
+ if (!hook) return;
435
+ hook(stepIndex);
436
+ // A hook that did not interrupt keeps observing later steps; a thrown
437
+ // interruption propagates before this line and is consumed by the caller
438
+ // clearing the hook.
439
+ }
440
+
441
+
442
+ function parseWorkspaceContent(content: string): WorkspaceState {
443
+ const raw = JSON.parse(content) as Record<string, unknown>;
444
+ const unknown = Object.keys(raw).filter(
445
+ (key) => !["contract", "current_working"].includes(key),
446
+ );
447
+ if (unknown.length > 0)
448
+ throw new KernelStoreSecurityError(
449
+ `workspace has unknown field: ${unknown[0]}`,
450
+ );
451
+ if (raw.contract !== "assurance_kernel/workspace/v1")
452
+ throw new KernelStoreSecurityError("workspace contract is invalid");
453
+ if (
454
+ raw.current_working !== null &&
455
+ (typeof raw.current_working !== "string" || !raw.current_working.trim())
456
+ )
457
+ throw new KernelStoreSecurityError("workspace current_working is invalid");
458
+ if (typeof raw.current_working === "string") validateTaskId(raw.current_working);
459
+ return raw as unknown as WorkspaceState;
460
+ }
461
+
462
+ function serializeWorkspace(state: WorkspaceState): string {
463
+ return `${JSON.stringify(state, null, 2)}\n`;
464
+ }
465
+
466
+ export { serializeWorkspace };
467
+
468
+ export function readWorkspaceStateRaw(root: string): {
469
+ revision: string;
470
+ state: WorkspaceState;
471
+ } {
472
+ const relativePath = stateWorkspacePath();
473
+ if (currentRevision(root, relativePath) === MISSING_REVISION)
474
+ return {
475
+ revision: MISSING_REVISION,
476
+ state: {
477
+ contract: "assurance_kernel/workspace/v1",
478
+ current_working: null,
479
+ },
480
+ };
481
+ const content = readSecureProjectFile(root, relativePath);
482
+ return { revision: revisionFor(content), state: parseWorkspaceContent(content) };
483
+ }
484
+
485
+ function convergeFile(
486
+ root: string,
487
+ relativePath: string,
488
+ expectedRevision: string,
489
+ nextContent: string,
490
+ ): string {
491
+ const nextRevision = revisionFor(nextContent);
492
+ const actualRevision = currentRevision(root, relativePath);
493
+ if (actualRevision === nextRevision) return nextRevision;
494
+ if (actualRevision !== expectedRevision)
495
+ throw new KernelStoreConflictError(
496
+ `transaction conflict for ${relativePath}: expected ${expectedRevision} or ${nextRevision}, got ${actualRevision}`,
497
+ );
498
+ return atomicCasWrite(
499
+ root,
500
+ relativePath,
501
+ nextContent,
502
+ expectedRevision,
503
+ );
504
+ }
505
+
506
+ function appendJournalLineLocked(root: string, entry: JournalEntry): void {
507
+ const directory = ensureSecureDirectory(root, dirname(JOURNAL_RELATIVE));
508
+ const path = resolve(directory, basename(JOURNAL_RELATIVE));
509
+ assertNoSymlinkSegments(canonicalRoot(root), path);
510
+ const noFollow = constants.O_NOFOLLOW ?? 0;
511
+ const fd = openSync(
512
+ path,
513
+ constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | noFollow,
514
+ 0o600,
515
+ );
516
+ try {
517
+ writeFileSync(fd, `${JSON.stringify(entry)}\n`, "utf8");
518
+ fsyncSync(fd);
519
+ } finally {
520
+ closeSync(fd);
521
+ }
522
+ }
523
+
524
+ function withJournalLock<T>(root: string, operation: () => T): T {
525
+ const directory = ensureSecureDirectory(root, dirname(JOURNAL_RELATIVE));
526
+ return withExclusiveLock(resolve(directory, JOURNAL_LOCK_NAME), operation);
527
+ }
528
+
529
+ export function appendJournalEntry(root: string, entry: JournalEntry): void {
530
+ withJournalLock(root, () => appendJournalLineLocked(root, entry));
531
+ }
532
+
533
+ export function appendObservationJournalEntry(
534
+ root: string,
535
+ entry: JournalEntry & { observation: V3AuthorityObservation },
536
+ ): "appended" | "duplicate" {
537
+ return withJournalLock(root, () => {
538
+ let existing = "";
539
+ try {
540
+ existing = readSecureProjectFile(root, JOURNAL_RELATIVE);
541
+ } catch (error) {
542
+ if (!(error instanceof Error) || !error.message.startsWith("source_missing:"))
543
+ throw error;
544
+ }
545
+ if (Buffer.byteLength(existing, "utf8") > JOURNAL_READ_LIMIT)
546
+ throw new KernelStoreSecurityError("kernel journal exceeds the observation read limit");
547
+ for (const [index, line] of existing.split("\n").entries()) {
548
+ if (!line.trim()) continue;
549
+ let parsed: Record<string, unknown>;
550
+ try {
551
+ parsed = JSON.parse(line) as Record<string, unknown>;
552
+ } catch {
553
+ throw new KernelStoreSecurityError(
554
+ `kernel journal line ${index + 1} is not valid JSON`,
555
+ );
556
+ }
557
+ const observation = parsed.observation as
558
+ | Record<string, unknown>
559
+ | undefined;
560
+ if (observation?.commit_id !== entry.observation.commit_id) continue;
561
+ if (observation.observation_id === entry.observation.observation_id)
562
+ return "duplicate";
563
+ throw new KernelStoreConflictError(
564
+ `observation commit identity conflict: ${entry.observation.commit_id}`,
565
+ );
566
+ }
567
+ appendJournalLineLocked(root, entry);
568
+ return "appended";
569
+ });
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // R2C2 dedicated TaskRecord v3 transaction path.
574
+ // Uses the existing exclusive store lock and rejects the retired v1 marker.
575
+ // ---------------------------------------------------------------------------
576
+
577
+ const TRANSACTION_PATH_V2 = stateTransactionPath("workspace-transaction-v2.json");
578
+ const ENROLLMENT_MARKER_PATH = stateTransactionPath("enrollment-marker.json");
579
+ const DRAIN_MARKER_PATH = stateTransactionPath("drain-transaction.json");
580
+ const TERMINAL_MARKER_PATH = stateTransactionPath("terminal-transaction.json");
581
+ const AUTHORITY_REPAIR_MARKER_PATH = stateTransactionPath("authority-repair-transaction.json");
582
+
583
+ export interface ArtifactRelocationV1 {
584
+ from_path: string;
585
+ to_path: string;
586
+ content_hash: string;
587
+ }
588
+
589
+ interface WorkspaceTransactionV2 {
590
+ contract: "assurance_kernel/workspace_transaction/v2";
591
+ task_id: string;
592
+ expected_record_hash: string;
593
+ next_record_content: string;
594
+ expected_workspace_hash: string;
595
+ next_workspace_content: string;
596
+ artifact_relocations?: ArtifactRelocationV1[];
597
+ }
598
+
599
+ export type { WorkspaceTransactionV2 };
600
+
601
+ export function revisionForContent(content: string): string {
602
+ return revisionFor(content);
603
+ }
604
+
605
+ function parseWorkspaceTransactionV2(raw: Record<string, unknown>): WorkspaceTransactionV2 {
606
+ const allowed = [
607
+ "contract",
608
+ "task_id",
609
+ "expected_record_hash",
610
+ "next_record_content",
611
+ "expected_workspace_hash",
612
+ "next_workspace_content",
613
+ "artifact_relocations",
614
+ ];
615
+ const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
616
+ if (unknown.length > 0)
617
+ throw new KernelStoreSecurityError(
618
+ `workspace transaction v2 has unknown field: ${unknown[0]}`,
619
+ );
620
+ if (raw.contract !== "assurance_kernel/workspace_transaction/v2")
621
+ throw new KernelStoreSecurityError(
622
+ "workspace transaction v2 contract is invalid",
623
+ );
624
+ for (const field of allowed.slice(1, 6)) {
625
+ if (typeof raw[field] !== "string" || !String(raw[field]).trim())
626
+ throw new KernelStoreSecurityError(
627
+ `workspace transaction v2 ${field} is invalid`,
628
+ );
629
+ }
630
+ const relocationRaw = raw.artifact_relocations;
631
+ if (relocationRaw !== undefined && !Array.isArray(relocationRaw))
632
+ throw new KernelStoreSecurityError("workspace transaction v2 artifact_relocations is invalid");
633
+ const artifactRelocations = (relocationRaw ?? []).map((item, index) => {
634
+ if (!item || typeof item !== "object" || Array.isArray(item))
635
+ throw new KernelStoreSecurityError(`artifact relocation ${index} is invalid`);
636
+ const value = item as Record<string, unknown>;
637
+ const unknownFields = Object.keys(value).filter((key) => !["from_path", "to_path", "content_hash"].includes(key));
638
+ if (unknownFields.length > 0)
639
+ throw new KernelStoreSecurityError(`artifact relocation has unknown field: ${unknownFields[0]}`);
640
+ for (const field of ["from_path", "to_path", "content_hash"])
641
+ if (typeof value[field] !== "string" || !String(value[field]).trim())
642
+ throw new KernelStoreSecurityError(`artifact relocation ${field} is invalid`);
643
+ const relocation = value as unknown as ArtifactRelocationV1;
644
+ assertArtifactRelocation(relocation);
645
+ return relocation;
646
+ });
647
+ const transaction = {
648
+ ...(raw as unknown as WorkspaceTransactionV2),
649
+ ...(artifactRelocations.length > 0 ? { artifact_relocations: artifactRelocations } : {}),
650
+ };
651
+ validateTaskId(transaction.task_id);
652
+ const record = parseTaskRecordV3(
653
+ JSON.parse(transaction.next_record_content),
654
+ );
655
+ if (record.task_id !== transaction.task_id)
656
+ throw new KernelStoreSecurityError(
657
+ "workspace transaction v2 task identity is inconsistent",
658
+ );
659
+ parseWorkspaceContent(transaction.next_workspace_content);
660
+ return transaction;
661
+ }
662
+
663
+ function readPendingTransactionV2(root: string): WorkspaceTransactionV2 | null {
664
+ if (currentRevision(root, TRANSACTION_PATH_V2) === MISSING_REVISION) return null;
665
+ const raw = JSON.parse(
666
+ readSecureProjectFile(root, TRANSACTION_PATH_V2),
667
+ ) as Record<string, unknown>;
668
+ return parseWorkspaceTransactionV2(raw);
669
+ }
670
+
671
+ function removeTransactionMarkerV2(root: string): void {
672
+ const candidate = safeCandidate(root, TRANSACTION_PATH_V2);
673
+ assertNoSymlinkSegments(candidate.root, candidate.path);
674
+ const stat = pathStatOrNull(candidate.path);
675
+ if (!stat) return;
676
+ if (!stat.isFile())
677
+ throw new KernelStoreSecurityError(
678
+ "workspace transaction v2 marker is not a regular file",
679
+ );
680
+ rmSync(candidate.path);
681
+ fsyncDirectory(dirname(candidate.path));
682
+ }
683
+
684
+ function archiveArtifactPath(path: string): string | null {
685
+ const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
686
+ return matched ? `docs/${matched[1]}/archive/${matched[2]}` : null;
687
+ }
688
+
689
+ function assertArtifactRelocation(relocation: ArtifactRelocationV1): void {
690
+ if (!/^sha256:[a-f0-9]{64}$/.test(relocation.content_hash))
691
+ throw new KernelStoreSecurityError("artifact relocation content_hash is invalid");
692
+ if (
693
+ archiveArtifactPath(relocation.from_path) !== relocation.to_path
694
+ && archiveArtifactPath(relocation.to_path) !== relocation.from_path
695
+ )
696
+ throw new KernelStoreSecurityError("artifact relocation paths must be one active/archive pair");
697
+ }
698
+
699
+ function convergeArtifactRelocation(root: string, relocation: ArtifactRelocationV1): void {
700
+ assertArtifactRelocation(relocation);
701
+ const fromRevision = currentRevision(root, relocation.from_path);
702
+ const toRevision = currentRevision(root, relocation.to_path);
703
+ if (fromRevision === MISSING_REVISION && toRevision === relocation.content_hash) return;
704
+ if (fromRevision !== relocation.content_hash || toRevision !== MISSING_REVISION)
705
+ throw new KernelStoreConflictError(
706
+ `artifact relocation conflict for ${relocation.from_path} -> ${relocation.to_path}`,
707
+ );
708
+ const from = safeCandidate(root, relocation.from_path);
709
+ const to = safeCandidate(root, relocation.to_path);
710
+ ensureSecureDirectory(root, relative(to.root, dirname(to.path)));
711
+ assertNoSymlinkSegments(from.root, from.path);
712
+ assertNoSymlinkSegments(to.root, to.path);
713
+ renameSync(from.path, to.path);
714
+ fsyncDirectory(dirname(from.path));
715
+ if (dirname(from.path) !== dirname(to.path)) fsyncDirectory(dirname(to.path));
716
+ }
717
+
718
+ function completeTransactionV2Locked(
719
+ root: string,
720
+ transaction: WorkspaceTransactionV2,
721
+ invokeTestHook: boolean,
722
+ ): StoredTaskMutationV3 {
723
+ for (const relocation of transaction.artifact_relocations ?? [])
724
+ convergeArtifactRelocation(root, relocation);
725
+ const taskPath = stateTaskRecordPath(transaction.task_id);
726
+ const taskRevision = convergeFile(
727
+ root,
728
+ taskPath,
729
+ transaction.expected_record_hash,
730
+ transaction.next_record_content,
731
+ );
732
+ if (invokeTestHook) runAfterTaskTransactionWriteHook();
733
+ const workspaceRevision = convergeFile(
734
+ root,
735
+ stateWorkspacePath(),
736
+ transaction.expected_workspace_hash,
737
+ transaction.next_workspace_content,
738
+ );
739
+ const record = parseTaskRecordV3(
740
+ JSON.parse(transaction.next_record_content),
741
+ );
742
+ const workspace = parseWorkspaceContent(transaction.next_workspace_content);
743
+ removeTransactionMarkerV2(root);
744
+ return {
745
+ revision: taskRevision,
746
+ record,
747
+ workspace: { revision: workspaceRevision, state: workspace },
748
+ };
749
+ }
750
+
751
+ function recoverPendingTransactionV2Locked(root: string): void {
752
+ const transaction = readPendingTransactionV2(root);
753
+ if (transaction) completeTransactionV2Locked(root, transaction, false);
754
+ }
755
+
756
+ /**
757
+ * v4-only recovery gate: if a retired v1 transaction marker exists, every
758
+ * store recovery (including v2) fails closed with the stable diagnostic.
759
+ * This guarantees no v4 operation can silently complete or discard a legacy
760
+ * v1 transaction; the operator must resolve it with the prior runtime first.
761
+ */
762
+ function assertNoRetiredV1Marker(root: string): void {
763
+ if (currentRevision(root, TRANSACTION_PATH) !== MISSING_REVISION)
764
+ throw new KernelStoreSecurityError(V1_TRANSACTION_RETIRED);
765
+ }
766
+
767
+ /** Reject any retired v1 marker before considering v2 recovery. */
768
+ function recoverAnyPendingTransactionLocked(root: string): void {
769
+ const hasV1 = currentRevision(root, TRANSACTION_PATH) !== MISSING_REVISION;
770
+ const hasV2 = currentRevision(root, TRANSACTION_PATH_V2) !== MISSING_REVISION;
771
+ const hasEnrollment =
772
+ currentRevision(root, ENROLLMENT_MARKER_PATH) !== MISSING_REVISION;
773
+ const hasDrain = currentRevision(root, DRAIN_MARKER_PATH) !== MISSING_REVISION;
774
+ const hasTerminal = currentRevision(root, TERMINAL_MARKER_PATH) !== MISSING_REVISION;
775
+ const hasAuthorityRepair =
776
+ currentRevision(root, AUTHORITY_REPAIR_MARKER_PATH) !== MISSING_REVISION;
777
+ if (hasV1)
778
+ throw new KernelStoreSecurityError(V1_TRANSACTION_RETIRED);
779
+ const markers = [hasV2, hasEnrollment, hasDrain, hasTerminal, hasAuthorityRepair].filter(Boolean).length;
780
+ if (markers > 1)
781
+ throw new KernelStoreSecurityError(
782
+ "simultaneous workspace transaction markers are forbidden",
783
+ );
784
+ if (hasV2) recoverPendingTransactionV2Locked(root);
785
+ if (hasEnrollment) recoverPendingEnrollmentLocked(root);
786
+ if (hasDrain) recoverPendingDrainLocked(root);
787
+ if (hasTerminal) recoverPendingTerminalLocked(root, false);
788
+ if (hasAuthorityRepair) recoverPendingAuthorityRepairLocked(root);
789
+ }
790
+
791
+ export function readTaskRecordRaw(
792
+ root: string,
793
+ taskId: string,
794
+ ): { revision: string; record: TaskRecordV3 | null } {
795
+ validateTaskId(taskId);
796
+ const relativePath = stateTaskRecordPath(taskId);
797
+ if (currentRevision(root, relativePath) === MISSING_REVISION)
798
+ return { revision: MISSING_REVISION, record: null };
799
+ const content = readSecureProjectFile(root, relativePath);
800
+ const raw = JSON.parse(content) as { contract?: unknown };
801
+ if (raw.contract === "assurance_kernel/task_record/v2")
802
+ throw new KernelStoreSecurityError(
803
+ "TaskRecord v2 is not supported in the state layout; v2 records belong to the historical audit layout",
804
+ );
805
+ const record = parseTaskRecordV3(raw);
806
+ if (record.task_id !== taskId)
807
+ throw new KernelStoreSecurityError("task record v3 identity is inconsistent");
808
+ return { revision: revisionFor(content), record };
809
+ }
810
+
811
+ /**
812
+ * Read the immutable terminal audit pair for one task:
813
+ * `.imm/audit/<task-id>/task-record.json` plus `terminal-proof.json`.
814
+ * Both files must exist, be identity-consistent, and the proof's
815
+ * `final_record_hash` must equal the record bytes' revision. Only ENOENT on
816
+ * the whole task directory means absent; a partial pair fails closed.
817
+ * The audit record may be TaskRecord v3 or historical terminal v2.
818
+ */
819
+ export function readAuditTaskPair(
820
+ root: string,
821
+ taskId: string,
822
+ ): {
823
+ recordRevision: string;
824
+ record: TaskRecordV3 | TaskRecordV2;
825
+ proof: TaskTombstone;
826
+ } | null {
827
+ validateTaskId(taskId);
828
+ const recordPath = auditTaskRecordPath(taskId);
829
+ const proofPath = auditTerminalProofPath(taskId);
830
+ const recordRevision = currentRevision(root, recordPath);
831
+ const proofRevision = currentRevision(root, proofPath);
832
+ if (recordRevision === MISSING_REVISION && proofRevision === MISSING_REVISION)
833
+ return null;
834
+ if (recordRevision === MISSING_REVISION || proofRevision === MISSING_REVISION)
835
+ throw new KernelStoreSecurityError("terminal audit pair is incomplete");
836
+ const recordContent = readSecureProjectFile(root, recordPath);
837
+ const proof = parseTaskTombstone(
838
+ JSON.parse(readSecureProjectFile(root, proofPath)) as Record<string, unknown>,
839
+ );
840
+ if (proof.task_id !== taskId)
841
+ throw new KernelStoreSecurityError(
842
+ "terminal audit proof identity is inconsistent",
843
+ );
844
+ if (proof.final_record_hash !== recordRevision)
845
+ throw new KernelStoreSecurityError(
846
+ "terminal audit proof does not match its task record",
847
+ );
848
+ const raw = JSON.parse(recordContent) as { contract?: unknown };
849
+ let record: TaskRecordV3 | TaskRecordV2;
850
+ if (raw.contract === "assurance_kernel/task_record/v2") {
851
+ const legacy = parseTaskRecordV2(raw);
852
+ if (
853
+ legacy.task_id !== taskId ||
854
+ (legacy.phase !== "done" && legacy.phase !== "stopped")
855
+ )
856
+ throw new KernelStoreSecurityError(
857
+ "historical audit TaskRecord v2 must be terminal and identity-consistent",
858
+ );
859
+ record = legacy;
860
+ } else {
861
+ const current = parseTaskRecordV3(raw);
862
+ if (
863
+ current.task_id !== taskId ||
864
+ (current.lifecycle !== "done" && current.lifecycle !== "stopped")
865
+ )
866
+ throw new KernelStoreSecurityError(
867
+ "audit TaskRecord v3 must be terminal and identity-consistent",
868
+ );
869
+ record = current;
870
+ }
871
+ return { recordRevision, record, proof };
872
+ }
873
+
874
+ export function readTaskRecord(
875
+ root: string,
876
+ taskId: string,
877
+ ): { revision: string; record: TaskRecordV3 | null } {
878
+ return withKernelStoreLock(root, () => readTaskRecordRaw(root, taskId));
879
+ }
880
+
881
+ /** Commit a TaskRecord v3 reducer result through the recoverable transaction. */
882
+ export function commitTaskRecordLocked(
883
+ root: string,
884
+ taskId: string,
885
+ expectedRecordHash: string,
886
+ nextRecord: TaskRecordV3,
887
+ expectedWorkspaceHash: string,
888
+ nextWorkspace: WorkspaceState,
889
+ artifactRelocations: ArtifactRelocationV1[] = [],
890
+ ): StoredTaskMutationV3 {
891
+ const transaction: WorkspaceTransactionV2 = {
892
+ contract: "assurance_kernel/workspace_transaction/v2",
893
+ task_id: taskId,
894
+ expected_record_hash: expectedRecordHash,
895
+ next_record_content: `${JSON.stringify(nextRecord, null, 2)}\n`,
896
+ expected_workspace_hash: expectedWorkspaceHash,
897
+ next_workspace_content: serializeWorkspace(nextWorkspace),
898
+ ...(artifactRelocations.length > 0 ? { artifact_relocations: artifactRelocations } : {}),
899
+ };
900
+ atomicCasWrite(
901
+ root,
902
+ TRANSACTION_PATH_V2,
903
+ `${JSON.stringify(transaction, null, 2)}\n`,
904
+ MISSING_REVISION,
905
+ );
906
+ try {
907
+ return completeTransactionV2Locked(root, transaction, true);
908
+ } catch (error) {
909
+ try {
910
+ return completeTransactionV2Locked(root, transaction, false);
911
+ } catch (recoveryError) {
912
+ throw new KernelStoreConflictError(
913
+ `kernel v2 transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}; recovery: ${recoveryError instanceof Error ? recoveryError.message : recoveryError}`,
914
+ );
915
+ }
916
+ }
917
+ }
918
+
919
+ export function withKernelStoreLock<T>(root: string, operation: () => T): T {
920
+ const locksDirectory = ensureSecureDirectory(root, dirname(stateStoreLockPath()));
921
+ return withExclusiveLock(resolve(locksDirectory, basename(stateStoreLockPath())), () => {
922
+ assertNoRetiredV1Marker(root);
923
+ recoverAnyPendingTransactionLocked(root);
924
+ return operation();
925
+ });
926
+ }
927
+
928
+ export type KernelAuthorityState =
929
+ | "unowned"
930
+ | "active_owner"
931
+ | "terminal_owner"
932
+ | "repairable_stale_claim"
933
+ | "authority_conflict";
934
+
935
+ export interface KernelAuthorityProjection {
936
+ contract: "assurance_kernel/authority_projection/v1";
937
+ requested_task_id: string;
938
+ state: KernelAuthorityState;
939
+ owner_task_id: string | null;
940
+ owner_lifecycle: TaskLifecycle | null;
941
+ claim_lifecycle_status: BackendClaim["lifecycle_status"] | null;
942
+ diagnostic: string | null;
943
+ revision: string;
944
+ }
945
+
946
+ /**
947
+ * Recover durable Kernel transactions, then classify all ownership facts under
948
+ * one lock. This is the shared fact boundary for host adapters; it never
949
+ * invents terminality or removes a claim.
950
+ */
951
+ function projectKernelAuthorityLocked(
952
+ root: string,
953
+ taskId: string,
954
+ ): KernelAuthorityProjection {
955
+ try {
956
+ const claim = readBackendClaim(root);
957
+ const ownerTaskId = claim?.task_id ?? null;
958
+ const inspectedTaskId = ownerTaskId ?? taskId;
959
+ const stateRecord = readTaskRecordRaw(root, inspectedTaskId);
960
+ const auditPair = readAuditTaskPair(root, inspectedTaskId);
961
+ const workspace = readWorkspaceStateRaw(root).state;
962
+ const record = stateRecord.record;
963
+ const auditRecord = auditPair?.record;
964
+ const authorityRecord = record
965
+ ? {
966
+ task_id: record.task_id,
967
+ lifecycle: record.lifecycle,
968
+ intent_revision: record.intent_snapshot.revision,
969
+ intent_content_hash: record.intent_ref.content_hash,
970
+ }
971
+ : auditRecord
972
+ ? "phase" in auditRecord
973
+ ? {
974
+ task_id: auditRecord.task_id,
975
+ lifecycle: auditRecord.phase as "done" | "stopped",
976
+ intent_revision: auditRecord.intent_revision,
977
+ intent_content_hash: auditRecord.intent_ref.content_hash,
978
+ }
979
+ : {
980
+ task_id: auditRecord.task_id,
981
+ lifecycle: auditRecord.lifecycle,
982
+ intent_revision: auditRecord.intent_snapshot.revision,
983
+ intent_content_hash: auditRecord.intent_ref.content_hash,
984
+ }
985
+ : null;
986
+ const terminal = auditPair !== null;
987
+ // review-1: a state record must never coexist with a terminal audit
988
+ // pair; only the settlement marker may hold them transiently, and a
989
+ // present marker routes to recovery/conflict before this projection.
990
+ const duplicateStateAndAudit = Boolean(stateRecord.record && auditPair);
991
+ const matchingTerminalProof = Boolean(auditPair && workspace.current_working === null);
992
+ const matchingClaimIdentity = Boolean(
993
+ claim &&
994
+ authorityRecord &&
995
+ claim.task_id === authorityRecord.task_id &&
996
+ claim.intent_revision === authorityRecord.intent_revision &&
997
+ claim.intent_content_hash === authorityRecord.intent_content_hash,
998
+ );
999
+ const sameOwner = claim ? workspace.current_working === claim.task_id : workspace.current_working === null;
1000
+ const revision = revisionForContent(JSON.stringify({ claim, stateRecord, auditPair, workspace }));
1001
+
1002
+ if (duplicateStateAndAudit)
1003
+ return {
1004
+ contract: "assurance_kernel/authority_projection/v1",
1005
+ requested_task_id: taskId,
1006
+ state: "authority_conflict",
1007
+ owner_task_id: claim?.task_id ?? null,
1008
+ owner_lifecycle: authorityRecord?.lifecycle ?? null,
1009
+ claim_lifecycle_status: claim?.lifecycle_status ?? null,
1010
+ diagnostic: `simultaneous state record and terminal audit pair for ${inspectedTaskId}; resolve or recover before authority interpretation`,
1011
+ revision,
1012
+ };
1013
+ if (claim && !sameOwner && !matchingTerminalProof)
1014
+ return {
1015
+ contract: "assurance_kernel/authority_projection/v1",
1016
+ requested_task_id: taskId,
1017
+ state: "authority_conflict",
1018
+ owner_task_id: claim.task_id,
1019
+ owner_lifecycle: authorityRecord?.lifecycle ?? null,
1020
+ claim_lifecycle_status: claim.lifecycle_status,
1021
+ diagnostic: `workspace owner ${workspace.current_working ?? "null"} contradicts claim ${claim.task_id}`,
1022
+ revision,
1023
+ };
1024
+ if (claim && !authorityRecord)
1025
+ return {
1026
+ contract: "assurance_kernel/authority_projection/v1",
1027
+ requested_task_id: taskId,
1028
+ state: "authority_conflict",
1029
+ owner_task_id: claim.task_id,
1030
+ owner_lifecycle: null,
1031
+ claim_lifecycle_status: claim.lifecycle_status,
1032
+ diagnostic: `claim ${claim.task_id} has no TaskRecord`,
1033
+ revision,
1034
+ };
1035
+ if (claim && matchingTerminalProof && matchingClaimIdentity)
1036
+ return {
1037
+ contract: "assurance_kernel/authority_projection/v1",
1038
+ requested_task_id: taskId,
1039
+ state: "repairable_stale_claim",
1040
+ owner_task_id: claim.task_id,
1041
+ owner_lifecycle: authorityRecord?.lifecycle ?? null,
1042
+ claim_lifecycle_status: claim.lifecycle_status,
1043
+ diagnostic: null,
1044
+ revision,
1045
+ };
1046
+ if (claim && terminal)
1047
+ return {
1048
+ contract: "assurance_kernel/authority_projection/v1",
1049
+ requested_task_id: taskId,
1050
+ state: "authority_conflict",
1051
+ owner_task_id: claim.task_id,
1052
+ owner_lifecycle: authorityRecord?.lifecycle ?? null,
1053
+ claim_lifecycle_status: claim.lifecycle_status,
1054
+ diagnostic: `claim ${claim.task_id} has contradictory terminal ownership evidence`,
1055
+ revision,
1056
+ };
1057
+ if (claim)
1058
+ return {
1059
+ contract: "assurance_kernel/authority_projection/v1",
1060
+ requested_task_id: taskId,
1061
+ state: "active_owner",
1062
+ owner_task_id: claim.task_id,
1063
+ owner_lifecycle: authorityRecord?.lifecycle ?? null,
1064
+ claim_lifecycle_status: claim.lifecycle_status,
1065
+ diagnostic: null,
1066
+ revision,
1067
+ };
1068
+ if (matchingTerminalProof)
1069
+ return {
1070
+ contract: "assurance_kernel/authority_projection/v1",
1071
+ requested_task_id: taskId,
1072
+ state: "terminal_owner",
1073
+ owner_task_id: inspectedTaskId,
1074
+ owner_lifecycle: authorityRecord?.lifecycle ?? null,
1075
+ claim_lifecycle_status: null,
1076
+ diagnostic: null,
1077
+ revision,
1078
+ };
1079
+ if (authorityRecord || workspace.current_working !== null)
1080
+ return {
1081
+ contract: "assurance_kernel/authority_projection/v1",
1082
+ requested_task_id: taskId,
1083
+ state: "authority_conflict",
1084
+ owner_task_id: workspace.current_working,
1085
+ owner_lifecycle: authorityRecord?.lifecycle ?? null,
1086
+ claim_lifecycle_status: null,
1087
+ diagnostic: "nonterminal owner state exists without a backend claim",
1088
+ revision,
1089
+ };
1090
+ return {
1091
+ contract: "assurance_kernel/authority_projection/v1",
1092
+ requested_task_id: taskId,
1093
+ state: "unowned",
1094
+ owner_task_id: null,
1095
+ owner_lifecycle: null,
1096
+ claim_lifecycle_status: null,
1097
+ diagnostic: null,
1098
+ revision,
1099
+ };
1100
+
1101
+ } catch (error) {
1102
+ return {
1103
+ contract: "assurance_kernel/authority_projection/v1",
1104
+ requested_task_id: taskId,
1105
+ state: "authority_conflict",
1106
+ owner_task_id: null,
1107
+ owner_lifecycle: null,
1108
+ claim_lifecycle_status: null,
1109
+ diagnostic: error instanceof Error ? error.message : String(error),
1110
+ revision: "",
1111
+ };
1112
+ }
1113
+ }
1114
+
1115
+ export function reconcileKernelAuthority(
1116
+ root: string,
1117
+ taskId: string,
1118
+ ): KernelAuthorityProjection {
1119
+ validateTaskId(taskId);
1120
+ return withKernelStoreLock(root, () => projectKernelAuthorityLocked(root, taskId));
1121
+ }
1122
+
1123
+ interface AuthorityRepairMarker {
1124
+ contract: "assurance_kernel/authority_repair_transaction/v1";
1125
+ task_id: string;
1126
+ expected_projection_revision: string;
1127
+ expected_claim_content: string;
1128
+ at: string;
1129
+ }
1130
+
1131
+ function readPendingAuthorityRepairMarker(root: string): AuthorityRepairMarker | null {
1132
+ if (currentRevision(root, AUTHORITY_REPAIR_MARKER_PATH) === MISSING_REVISION) return null;
1133
+ const raw = JSON.parse(
1134
+ readSecureProjectFile(root, AUTHORITY_REPAIR_MARKER_PATH),
1135
+ ) as Record<string, unknown>;
1136
+ const allowed = [
1137
+ "contract",
1138
+ "task_id",
1139
+ "expected_projection_revision",
1140
+ "expected_claim_content",
1141
+ "at",
1142
+ ];
1143
+ const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1144
+ if (unknown.length > 0)
1145
+ throw new KernelStoreSecurityError(
1146
+ `authority repair marker has unknown field: ${unknown[0]}`,
1147
+ );
1148
+ if (raw.contract !== "assurance_kernel/authority_repair_transaction/v1")
1149
+ throw new KernelStoreSecurityError("authority repair marker contract is invalid");
1150
+ for (const field of ["task_id", "expected_projection_revision", "expected_claim_content", "at"]) {
1151
+ if (typeof raw[field] !== "string" || !String(raw[field]).trim())
1152
+ throw new KernelStoreSecurityError(`authority repair marker ${field} is invalid`);
1153
+ }
1154
+ const marker = raw as unknown as AuthorityRepairMarker;
1155
+ validateTaskId(marker.task_id);
1156
+ const claim = parseBackendClaim(JSON.parse(marker.expected_claim_content));
1157
+ if (claim.task_id !== marker.task_id)
1158
+ throw new KernelStoreSecurityError("authority repair claim identity is inconsistent");
1159
+ return marker;
1160
+ }
1161
+
1162
+ function removeAuthorityRepairMarker(root: string): void {
1163
+ const candidate = safeCandidate(root, AUTHORITY_REPAIR_MARKER_PATH);
1164
+ assertNoSymlinkSegments(candidate.root, candidate.path);
1165
+ const stat = pathStatOrNull(candidate.path);
1166
+ if (!stat) return;
1167
+ if (!stat.isFile())
1168
+ throw new KernelStoreSecurityError("authority repair marker is not a regular file");
1169
+ rmSync(candidate.path);
1170
+ fsyncDirectory(dirname(candidate.path));
1171
+ }
1172
+
1173
+ function recoverPendingAuthorityRepairLocked(root: string): void {
1174
+ const marker = readPendingAuthorityRepairMarker(root);
1175
+ if (!marker) return;
1176
+ const projection = projectKernelAuthorityLocked(root, marker.task_id);
1177
+ const claimRevision = currentRevision(root, CLAIM_RELATIVE_PATH);
1178
+ if (claimRevision === MISSING_REVISION) {
1179
+ if (
1180
+ projection.state !== "terminal_owner" ||
1181
+ projection.owner_task_id !== marker.task_id
1182
+ )
1183
+ throw new KernelStoreConflictError(
1184
+ "authority repair committed claim removal but terminal proof changed",
1185
+ );
1186
+ removeAuthorityRepairMarker(root);
1187
+ return;
1188
+ }
1189
+ if (
1190
+ projection.state !== "repairable_stale_claim" ||
1191
+ projection.owner_task_id !== marker.task_id ||
1192
+ projection.revision !== marker.expected_projection_revision ||
1193
+ claimRevision !== revisionFor(marker.expected_claim_content)
1194
+ )
1195
+ throw new KernelStoreConflictError(
1196
+ "authority repair facts changed after confirmation",
1197
+ );
1198
+ const claimCandidate = safeCandidate(root, CLAIM_RELATIVE_PATH);
1199
+ assertNoSymlinkSegments(claimCandidate.root, claimCandidate.path);
1200
+ rmSync(claimCandidate.path);
1201
+ fsyncDirectory(dirname(claimCandidate.path));
1202
+ removeAuthorityRepairMarker(root);
1203
+ }
1204
+
1205
+ /** Remove one exactly proven stale terminal claim through a replayable marker. */
1206
+ export function repairKernelAuthority(
1207
+ root: string,
1208
+ taskId: string,
1209
+ expectedProjectionRevision: string,
1210
+ at = new Date().toISOString(),
1211
+ ): KernelAuthorityProjection {
1212
+ validateTaskId(taskId);
1213
+ return withKernelStoreLock(root, () => {
1214
+ const projection = projectKernelAuthorityLocked(root, taskId);
1215
+ if (
1216
+ projection.state !== "repairable_stale_claim" ||
1217
+ projection.owner_task_id !== taskId ||
1218
+ projection.revision !== expectedProjectionRevision
1219
+ )
1220
+ throw new KernelStoreConflictError("authority repair requires exact stale terminal proof");
1221
+ const marker: AuthorityRepairMarker = {
1222
+ contract: "assurance_kernel/authority_repair_transaction/v1",
1223
+ task_id: taskId,
1224
+ expected_projection_revision: expectedProjectionRevision,
1225
+ expected_claim_content: readSecureProjectFile(root, CLAIM_RELATIVE_PATH),
1226
+ at,
1227
+ };
1228
+ atomicCasWrite(
1229
+ root,
1230
+ AUTHORITY_REPAIR_MARKER_PATH,
1231
+ `${JSON.stringify(marker, null, 2)}\n`,
1232
+ MISSING_REVISION,
1233
+ );
1234
+ try {
1235
+ recoverPendingAuthorityRepairLocked(root);
1236
+ } catch (error) {
1237
+ throw new KernelStoreConflictError(
1238
+ `authority repair failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1239
+ );
1240
+ }
1241
+ return projectKernelAuthorityLocked(root, taskId);
1242
+ });
1243
+ }
1244
+
1245
+ // P2B0 enrollment marker. The enrollment transaction embeds the v2
1246
+ // task/workspace transaction plus the backend claim; recovery completes the
1247
+ // v2 convergence then re-writes the claim if it was not yet durable.
1248
+ // ---------------------------------------------------------------------------
1249
+
1250
+ interface EnrollmentMarker {
1251
+ contract: "assurance_kernel/enrollment_transaction/v1";
1252
+ task_id: string;
1253
+ transaction: WorkspaceTransactionV2;
1254
+ claim: Record<string, unknown>;
1255
+ }
1256
+
1257
+ function readPendingEnrollmentMarker(root: string): EnrollmentMarker | null {
1258
+ if (currentRevision(root, ENROLLMENT_MARKER_PATH) === MISSING_REVISION) return null;
1259
+ const raw = JSON.parse(
1260
+ readSecureProjectFile(root, ENROLLMENT_MARKER_PATH),
1261
+ ) as Record<string, unknown>;
1262
+ const allowed = ["contract", "task_id", "transaction", "claim"];
1263
+ const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1264
+ if (unknown.length > 0)
1265
+ throw new KernelStoreSecurityError(
1266
+ `enrollment marker has unknown field: ${unknown[0]}`,
1267
+ );
1268
+ if (raw.contract !== "assurance_kernel/enrollment_transaction/v1")
1269
+ throw new KernelStoreSecurityError(
1270
+ "enrollment marker contract is invalid",
1271
+ );
1272
+ if (typeof raw.task_id !== "string" || !raw.task_id.trim())
1273
+ throw new KernelStoreSecurityError(
1274
+ "enrollment marker task_id is invalid",
1275
+ );
1276
+ const transaction = parseWorkspaceTransactionV2(
1277
+ raw.transaction as Record<string, unknown>,
1278
+ );
1279
+ if (transaction.task_id !== raw.task_id)
1280
+ throw new KernelStoreSecurityError(
1281
+ "enrollment marker task identity is inconsistent",
1282
+ );
1283
+ return {
1284
+ contract: "assurance_kernel/enrollment_transaction/v1",
1285
+ task_id: raw.task_id,
1286
+ transaction,
1287
+ claim: raw.claim as Record<string, unknown>,
1288
+ };
1289
+ }
1290
+
1291
+ function removeEnrollmentMarker(root: string): void {
1292
+ const candidate = safeCandidate(root, ENROLLMENT_MARKER_PATH);
1293
+ assertNoSymlinkSegments(candidate.root, candidate.path);
1294
+ const stat = pathStatOrNull(candidate.path);
1295
+ if (!stat) return;
1296
+ if (!stat.isFile())
1297
+ throw new KernelStoreSecurityError(
1298
+ "enrollment marker is not a regular file",
1299
+ );
1300
+ rmSync(candidate.path);
1301
+ fsyncDirectory(dirname(candidate.path));
1302
+ }
1303
+
1304
+ /** Recover a pending enrollment marker: complete the embedded v2 transaction, then re-write the claim. */
1305
+ function recoverPendingEnrollmentLocked(root: string): void {
1306
+ const marker = readPendingEnrollmentMarker(root);
1307
+ if (!marker) return;
1308
+ const transaction = marker.transaction;
1309
+ convergeFile(
1310
+ root,
1311
+ stateTaskRecordPath(transaction.task_id),
1312
+ transaction.expected_record_hash,
1313
+ transaction.next_record_content,
1314
+ );
1315
+ convergeFile(
1316
+ root,
1317
+ stateWorkspacePath(),
1318
+ transaction.expected_workspace_hash,
1319
+ transaction.next_workspace_content,
1320
+ );
1321
+ // re-write the backend claim (last step of the enrollment transaction)
1322
+ atomicCasWrite(
1323
+ root,
1324
+ stateClaimPath(),
1325
+ `${JSON.stringify(marker.claim, null, 2)}\n`,
1326
+ MISSING_REVISION,
1327
+ );
1328
+ removeEnrollmentMarker(root);
1329
+ }
1330
+
1331
+ /**
1332
+ * Write an enrollment marker (embedded v2 transaction + claim) and complete it
1333
+ * atomically under the store lock. Exported for the enrollment core.
1334
+ */
1335
+ export function commitEnrollmentLocked(
1336
+ root: string,
1337
+ taskId: string,
1338
+ transaction: WorkspaceTransactionV2,
1339
+ claim: Record<string, unknown>,
1340
+ ): { record: TaskRecordV2; workspace: WorkspaceState } {
1341
+ const marker: EnrollmentMarker = {
1342
+ contract: "assurance_kernel/enrollment_transaction/v1",
1343
+ task_id: taskId,
1344
+ transaction,
1345
+ claim,
1346
+ };
1347
+ atomicCasWrite(
1348
+ root,
1349
+ ENROLLMENT_MARKER_PATH,
1350
+ `${JSON.stringify(marker, null, 2)}\n`,
1351
+ MISSING_REVISION,
1352
+ );
1353
+ try {
1354
+ recoverPendingEnrollmentLocked(root);
1355
+ } catch (error) {
1356
+ throw new KernelStoreConflictError(
1357
+ `enrollment transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1358
+ );
1359
+ }
1360
+ return {
1361
+ record: parseTaskRecordV3(JSON.parse(transaction.next_record_content)),
1362
+ workspace: parseWorkspaceContent(transaction.next_workspace_content),
1363
+ };
1364
+ }
1365
+
1366
+ // ---------------------------------------------------------------------------
1367
+ // P2B2 drain and terminal ownership transactions. The workspace backend claim
1368
+ // has exactly one write path: these recoverable markers owned by this module.
1369
+ // `backend_claim.ts` exports no writer; enrollment uses the marker above.
1370
+ // ---------------------------------------------------------------------------
1371
+
1372
+ interface DrainMarker {
1373
+ contract: "assurance_kernel/drain_transaction/v1";
1374
+ task_id: string;
1375
+ expected_claim_content: string;
1376
+ next_claim_content: string;
1377
+ at: string;
1378
+ }
1379
+
1380
+ interface TerminalMarker {
1381
+ contract: "assurance_kernel/terminal_transaction/v2";
1382
+ task_id: string;
1383
+ expected_state_record_hash: string;
1384
+ audit_record_content: string;
1385
+ proof_content: string;
1386
+ expected_workspace_hash: string;
1387
+ next_workspace_content: string;
1388
+ artifact_relocations?: ArtifactRelocationV1[];
1389
+ expected_claim_sha256: string;
1390
+ at: string;
1391
+ }
1392
+
1393
+ function parseArtifactRelocationsV1(raw: unknown): ArtifactRelocationV1[] {
1394
+ if (raw === undefined) return [];
1395
+ if (!Array.isArray(raw))
1396
+ throw new KernelStoreSecurityError("artifact_relocations is invalid");
1397
+ return raw.map((item, index) => {
1398
+ if (!item || typeof item !== "object" || Array.isArray(item))
1399
+ throw new KernelStoreSecurityError(`artifact relocation ${index} is invalid`);
1400
+ const value = item as Record<string, unknown>;
1401
+ const unknownFields = Object.keys(value).filter((key) => !["from_path", "to_path", "content_hash"].includes(key));
1402
+ if (unknownFields.length > 0)
1403
+ throw new KernelStoreSecurityError(`artifact relocation has unknown field: ${unknownFields[0]}`);
1404
+ for (const field of ["from_path", "to_path", "content_hash"])
1405
+ if (typeof value[field] !== "string" || !String(value[field]).trim())
1406
+ throw new KernelStoreSecurityError(`artifact relocation ${field} is invalid`);
1407
+ const relocation = value as unknown as ArtifactRelocationV1;
1408
+ assertArtifactRelocation(relocation);
1409
+ return relocation;
1410
+ });
1411
+ }
1412
+
1413
+ // The workspace-active claim lives at the ignored state path (storage_paths).
1414
+ const CLAIM_RELATIVE_PATH = stateClaimPath();
1415
+
1416
+ function parseDrainMarker(raw: Record<string, unknown>): DrainMarker {
1417
+ const allowed = [
1418
+ "contract",
1419
+ "task_id",
1420
+ "expected_claim_content",
1421
+ "next_claim_content",
1422
+ "at",
1423
+ ];
1424
+ const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1425
+ if (unknown.length > 0)
1426
+ throw new KernelStoreSecurityError(
1427
+ `drain marker has unknown field: ${unknown[0]}`,
1428
+ );
1429
+ if (raw.contract !== "assurance_kernel/drain_transaction/v1")
1430
+ throw new KernelStoreSecurityError("drain marker contract is invalid");
1431
+ if (typeof raw.task_id !== "string" || !raw.task_id.trim())
1432
+ throw new KernelStoreSecurityError("drain marker task_id is invalid");
1433
+ for (const field of ["expected_claim_content", "next_claim_content", "at"]) {
1434
+ if (typeof raw[field] !== "string" || !String(raw[field]).trim())
1435
+ throw new KernelStoreSecurityError(`drain marker ${field} is invalid`);
1436
+ }
1437
+ const marker = raw as unknown as DrainMarker;
1438
+ validateTaskId(marker.task_id);
1439
+ const expected = parseBackendClaim(JSON.parse(marker.expected_claim_content));
1440
+ const next = parseBackendClaim(JSON.parse(marker.next_claim_content));
1441
+ if (expected.task_id !== marker.task_id || next.task_id !== marker.task_id)
1442
+ throw new KernelStoreSecurityError("drain marker claim identity is inconsistent");
1443
+ if (expected.lifecycle_status !== "active" || next.lifecycle_status !== "draining")
1444
+ throw new KernelStoreSecurityError(
1445
+ "drain marker must transition active -> draining",
1446
+ );
1447
+ return marker;
1448
+ }
1449
+
1450
+ function readPendingDrainMarker(root: string): DrainMarker | null {
1451
+ if (currentRevision(root, DRAIN_MARKER_PATH) === MISSING_REVISION) return null;
1452
+ const raw = JSON.parse(
1453
+ readSecureProjectFile(root, DRAIN_MARKER_PATH),
1454
+ ) as Record<string, unknown>;
1455
+ return parseDrainMarker(raw);
1456
+ }
1457
+
1458
+ function removeDrainMarker(root: string): void {
1459
+ const candidate = safeCandidate(root, DRAIN_MARKER_PATH);
1460
+ assertNoSymlinkSegments(candidate.root, candidate.path);
1461
+ const stat = pathStatOrNull(candidate.path);
1462
+ if (!stat) return;
1463
+ if (!stat.isFile())
1464
+ throw new KernelStoreSecurityError(
1465
+ "drain marker is not a regular file",
1466
+ );
1467
+ rmSync(candidate.path);
1468
+ fsyncDirectory(dirname(candidate.path));
1469
+ }
1470
+
1471
+ /**
1472
+ * Converge the workspace claim to draining. Exact committed replay (claim
1473
+ * already equals next content) is idempotent; a conflicting claim fails
1474
+ * closed and leaves the marker recoverable.
1475
+ */
1476
+ function recoverPendingDrainLocked(root: string): void {
1477
+ const marker = readPendingDrainMarker(root);
1478
+ if (!marker) return;
1479
+ convergeFile(
1480
+ root,
1481
+ CLAIM_RELATIVE_PATH,
1482
+ revisionFor(marker.expected_claim_content),
1483
+ marker.next_claim_content,
1484
+ );
1485
+ removeDrainMarker(root);
1486
+ }
1487
+
1488
+ /**
1489
+ * Commit the recoverable active -> draining claim transition under the store
1490
+ * lock. Returns the committed draining claim. Caller must already hold the
1491
+ * lock, have validated task/record/workspace ownership, and have consumed the
1492
+ * user capability exactly once before the marker write.
1493
+ */
1494
+ export function commitDrainLocked(
1495
+ root: string,
1496
+ taskId: string,
1497
+ expectedClaimContent: string,
1498
+ nextClaimContent: string,
1499
+ at: string,
1500
+ ): BackendClaim {
1501
+ validateTaskId(taskId);
1502
+ const expected = parseBackendClaim(JSON.parse(expectedClaimContent));
1503
+ const next = parseBackendClaim(JSON.parse(nextClaimContent));
1504
+ if (expected.task_id !== taskId || next.task_id !== taskId)
1505
+ throw new KernelStoreSecurityError("drain claim identity is inconsistent");
1506
+ if (expected.lifecycle_status !== "active" || next.lifecycle_status !== "draining")
1507
+ throw new KernelStoreSecurityError(
1508
+ "drain transaction must transition active -> draining",
1509
+ );
1510
+ const marker: DrainMarker = {
1511
+ contract: "assurance_kernel/drain_transaction/v1",
1512
+ task_id: taskId,
1513
+ expected_claim_content: expectedClaimContent,
1514
+ next_claim_content: nextClaimContent,
1515
+ at,
1516
+ };
1517
+ atomicCasWrite(
1518
+ root,
1519
+ DRAIN_MARKER_PATH,
1520
+ `${JSON.stringify(marker, null, 2)}\n`,
1521
+ MISSING_REVISION,
1522
+ );
1523
+ try {
1524
+ recoverPendingDrainLocked(root);
1525
+ } catch (error) {
1526
+ throw new KernelStoreConflictError(
1527
+ `drain transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1528
+ );
1529
+ }
1530
+ return next;
1531
+ }
1532
+
1533
+ function parseTerminalMarker(raw: Record<string, unknown>): TerminalMarker {
1534
+ const allowed = [
1535
+ "contract",
1536
+ "task_id",
1537
+ "expected_state_record_hash",
1538
+ "audit_record_content",
1539
+ "proof_content",
1540
+ "expected_workspace_hash",
1541
+ "next_workspace_content",
1542
+ "artifact_relocations",
1543
+ "expected_claim_sha256",
1544
+ "at",
1545
+ ];
1546
+ const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1547
+ if (unknown.length > 0)
1548
+ throw new KernelStoreSecurityError(
1549
+ `terminal marker has unknown field: ${unknown[0]}`,
1550
+ );
1551
+ if (raw.contract !== "assurance_kernel/terminal_transaction/v2")
1552
+ throw new KernelStoreSecurityError("terminal marker contract is invalid");
1553
+ if (typeof raw.task_id !== "string" || !raw.task_id.trim())
1554
+ throw new KernelStoreSecurityError("terminal marker task_id is invalid");
1555
+ validateTaskId(raw.task_id);
1556
+ for (const field of ["expected_state_record_hash", "audit_record_content", "proof_content", "expected_workspace_hash", "next_workspace_content", "at"]) {
1557
+ if (typeof raw[field] !== "string" || !String(raw[field]).trim())
1558
+ throw new KernelStoreSecurityError(`terminal marker ${field} is invalid`);
1559
+ }
1560
+ const expectedClaim = raw.expected_claim_sha256;
1561
+ if (typeof expectedClaim !== "string" || !/^sha256:[a-f0-9]{64}$/.test(expectedClaim))
1562
+ throw new KernelStoreSecurityError("terminal marker expected_claim_sha256 is required");
1563
+ const marker = {
1564
+ contract: "assurance_kernel/terminal_transaction/v2" as const,
1565
+ task_id: raw.task_id,
1566
+ expected_state_record_hash: raw.expected_state_record_hash as string,
1567
+ audit_record_content: raw.audit_record_content as string,
1568
+ proof_content: raw.proof_content as string,
1569
+ expected_workspace_hash: raw.expected_workspace_hash as string,
1570
+ next_workspace_content: raw.next_workspace_content as string,
1571
+ artifact_relocations: parseArtifactRelocationsV1(raw.artifact_relocations),
1572
+ expected_claim_sha256: expectedClaim,
1573
+ at: raw.at as string,
1574
+ };
1575
+ const record = parseTaskRecordV3(JSON.parse(marker.audit_record_content));
1576
+ if (record.task_id !== marker.task_id)
1577
+ throw new KernelStoreSecurityError("terminal marker task identity is inconsistent");
1578
+ if (record.lifecycle !== "done" && record.lifecycle !== "stopped")
1579
+ throw new KernelStoreSecurityError("terminal marker record must be terminal");
1580
+ const proof = parseTaskTombstone(JSON.parse(marker.proof_content) as Record<string, unknown>);
1581
+ if (proof.task_id !== marker.task_id)
1582
+ throw new KernelStoreSecurityError("terminal marker proof identity is inconsistent");
1583
+ if (proof.final_record_hash !== revisionFor(marker.audit_record_content))
1584
+ throw new KernelStoreSecurityError("terminal marker proof does not match the terminal record bytes");
1585
+ if (proof.terminal_lifecycle !== record.lifecycle)
1586
+ throw new KernelStoreSecurityError("terminal marker proof lifecycle contradicts the terminal record");
1587
+ const workspaceState = parseWorkspaceContent(marker.next_workspace_content);
1588
+ if (workspaceState.current_working !== null)
1589
+ throw new KernelStoreSecurityError("terminal marker requires a cleared workspace owner");
1590
+ return marker;
1591
+ }
1592
+
1593
+ function readPendingTerminalMarker(root: string): TerminalMarker | null {
1594
+ if (currentRevision(root, TERMINAL_MARKER_PATH) === MISSING_REVISION) return null;
1595
+ const raw = JSON.parse(
1596
+ readSecureProjectFile(root, TERMINAL_MARKER_PATH),
1597
+ ) as Record<string, unknown>;
1598
+ return parseTerminalMarker(raw);
1599
+ }
1600
+
1601
+ function removeTerminalMarker(root: string): void {
1602
+ const candidate = safeCandidate(root, TERMINAL_MARKER_PATH);
1603
+ assertNoSymlinkSegments(candidate.root, candidate.path);
1604
+ const stat = pathStatOrNull(candidate.path);
1605
+ if (!stat) return;
1606
+ if (!stat.isFile())
1607
+ throw new KernelStoreSecurityError(
1608
+ "terminal marker is not a regular file",
1609
+ );
1610
+ rmSync(candidate.path);
1611
+ fsyncDirectory(dirname(candidate.path));
1612
+ }
1613
+
1614
+ /**
1615
+ * Converge terminal state: TaskRecord, workspace owner, active-claim removal,
1616
+ * and task tombstone creation. Each step is idempotent on its committed
1617
+ * result and fails closed on contradictory partial bytes.
1618
+ */
1619
+ function convergeStateRecordRemoval(root: string, taskId: string, expectedHash: string): void {
1620
+ const relativePath = stateTaskRecordPath(taskId);
1621
+ const actual = currentRevision(root, relativePath);
1622
+ if (actual === MISSING_REVISION) return; // already removed
1623
+ if (actual !== expectedHash)
1624
+ throw new KernelStoreConflictError(
1625
+ `state record changed during terminal settlement: expected ${expectedHash}, got ${actual}`,
1626
+ );
1627
+ const candidate = safeCandidate(root, relativePath);
1628
+ assertNoSymlinkSegments(candidate.root, candidate.path);
1629
+ const stat = pathStatOrNull(candidate.path);
1630
+ if (!stat || !stat.isFile())
1631
+ throw new KernelStoreSecurityError("state task record is not a regular file");
1632
+ rmSync(candidate.path);
1633
+ fsyncDirectory(dirname(candidate.path));
1634
+ }
1635
+
1636
+ /**
1637
+ * Converge terminal state: create the immutable audit record/proof pair,
1638
+ * clear the workspace owner, remove the active claim and the state record,
1639
+ * and remove the marker. Each step is idempotent on its committed result and
1640
+ * fails closed on contradictory partial bytes. The state record is removed
1641
+ * only after the complete audit pair is durable.
1642
+ */
1643
+ function recoverPendingTerminalLocked(root: string, invokeStepHook = false): void {
1644
+ const marker = readPendingTerminalMarker(root);
1645
+ if (!marker) return;
1646
+ for (const relocation of marker.artifact_relocations ?? [])
1647
+ convergeArtifactRelocation(root, relocation);
1648
+ if (invokeStepHook) runTerminalSettlementStepHook(0);
1649
+ // Audit record: create-once; identical committed bytes are idempotent.
1650
+ convergeFile(
1651
+ root,
1652
+ auditTaskRecordPath(marker.task_id),
1653
+ MISSING_REVISION,
1654
+ marker.audit_record_content,
1655
+ );
1656
+ // Terminal proof: create-once after the record is durable.
1657
+ convergeFile(
1658
+ root,
1659
+ auditTerminalProofPath(marker.task_id),
1660
+ MISSING_REVISION,
1661
+ marker.proof_content,
1662
+ );
1663
+ if (invokeStepHook) runTerminalSettlementStepHook(1);
1664
+ convergeFile(
1665
+ root,
1666
+ stateWorkspacePath(),
1667
+ marker.expected_workspace_hash,
1668
+ marker.next_workspace_content,
1669
+ );
1670
+ if (invokeStepHook) runTerminalSettlementStepHook(2);
1671
+ // Active-claim removal: absence is the committed outcome. A present
1672
+ // claim is removed only after its bytes match the marker's frozen
1673
+ // expected hash; a foreign or changed claim fails closed and keeps the
1674
+ // marker recoverable (review-1).
1675
+ const claimCandidate = safeCandidate(root, stateClaimPath());
1676
+ assertNoSymlinkSegments(claimCandidate.root, claimCandidate.path);
1677
+ const claimStat = pathStatOrNull(claimCandidate.path);
1678
+ if (claimStat) {
1679
+ // review-2: a present claim at replay must byte-match the frozen
1680
+ // claim hash; absence is the already-converged step, a foreign or
1681
+ // changed claim fails closed instead of being destroyed.
1682
+ if (!claimStat.isFile())
1683
+ throw new KernelStoreSecurityError(
1684
+ "backend claim is not a regular file",
1685
+ );
1686
+ const claimBytes = readSecureProjectFile(root, stateClaimPath());
1687
+ if (revisionFor(claimBytes) !== marker.expected_claim_sha256)
1688
+ throw new KernelStoreConflictError(
1689
+ `backend claim changed during terminal settlement: expected ${marker.expected_claim_sha256}, got ${revisionFor(claimBytes)}`,
1690
+ );
1691
+ rmSync(claimCandidate.path);
1692
+ fsyncDirectory(dirname(claimCandidate.path));
1693
+ }
1694
+ if (invokeStepHook) runTerminalSettlementStepHook(3);
1695
+ // Active state record removal: only after the audit pair is verified.
1696
+ convergeStateRecordRemoval(root, marker.task_id, marker.expected_state_record_hash);
1697
+ if (invokeStepHook) runTerminalSettlementStepHook(4);
1698
+ terminalSettlementStepHookForTest = null;
1699
+ removeTerminalMarker(root);
1700
+ }
1701
+ /**
1702
+ * Commit the recoverable terminal ownership transfer under the store lock:
1703
+ * terminal TaskRecord, cleared workspace owner, removed active claim, created
1704
+ * task tombstone. Caller must already hold the lock and have validated the
1705
+ * exact before/after identities.
1706
+ */
1707
+ export function commitTerminalLocked(
1708
+ root: string,
1709
+ taskId: string,
1710
+ transaction: WorkspaceTransactionV2,
1711
+ tombstone: TaskTombstone,
1712
+ ): { record: TaskRecordV3; workspace: WorkspaceState } {
1713
+ validateTaskId(taskId);
1714
+ if (transaction.task_id !== taskId)
1715
+ throw new KernelStoreSecurityError(
1716
+ "terminal transaction task identity is inconsistent",
1717
+ );
1718
+ if (tombstone.task_id !== taskId)
1719
+ throw new KernelStoreSecurityError(
1720
+ "terminal tombstone task identity is inconsistent",
1721
+ );
1722
+ const nextWorkspaceState = parseWorkspaceContent(transaction.next_workspace_content);
1723
+ if (nextWorkspaceState.current_working !== null)
1724
+ throw new KernelStoreSecurityError(
1725
+ "terminal settlement requires a cleared workspace owner",
1726
+ );
1727
+ if (tombstone.final_record_hash !== revisionFor(transaction.next_record_content))
1728
+ throw new KernelStoreSecurityError(
1729
+ "terminal proof must match the terminal record bytes",
1730
+ );
1731
+ const terminalRecord = parseTaskRecordV3(
1732
+ JSON.parse(transaction.next_record_content) as Record<string, unknown>,
1733
+ );
1734
+ if (tombstone.terminal_lifecycle !== terminalRecord.lifecycle)
1735
+ throw new KernelStoreSecurityError(
1736
+ "terminal proof lifecycle contradicts the terminal TaskRecord",
1737
+ );
1738
+ const claimBytes = readSecureProjectFile(root, stateClaimPath());
1739
+ const claim = parseBackendClaim(JSON.parse(claimBytes) as Record<string, unknown>);
1740
+ if (claim.task_id !== taskId)
1741
+ throw new KernelStoreSecurityError(
1742
+ `terminal settlement claim belongs to ${claim.task_id}, not ${taskId}`,
1743
+ );
1744
+ if (claim.lifecycle_status !== "active" && claim.lifecycle_status !== "draining")
1745
+ throw new KernelStoreSecurityError(
1746
+ "terminal settlement claim must be active or draining",
1747
+ );
1748
+ const expectedClaimSha256 = revisionFor(claimBytes);
1749
+ const marker: TerminalMarker = {
1750
+ contract: "assurance_kernel/terminal_transaction/v2",
1751
+ task_id: taskId,
1752
+ expected_state_record_hash: transaction.expected_record_hash,
1753
+ audit_record_content: transaction.next_record_content,
1754
+ proof_content: serializeTaskTombstone(tombstone),
1755
+ expected_workspace_hash: transaction.expected_workspace_hash,
1756
+ next_workspace_content: transaction.next_workspace_content,
1757
+ ...(transaction.artifact_relocations
1758
+ ? { artifact_relocations: transaction.artifact_relocations }
1759
+ : {}),
1760
+ expected_claim_sha256: expectedClaimSha256,
1761
+ at: tombstone.terminalized_at,
1762
+ };
1763
+ atomicCasWrite(
1764
+ root,
1765
+ TERMINAL_MARKER_PATH,
1766
+ `${JSON.stringify(marker, null, 2)}\n`,
1767
+ MISSING_REVISION,
1768
+ );
1769
+ try {
1770
+ recoverPendingTerminalLocked(root, true);
1771
+ } catch (error) {
1772
+ throw new KernelStoreConflictError(
1773
+ `terminal transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1774
+ );
1775
+ }
1776
+ return {
1777
+ record: parseTaskRecordV3(JSON.parse(transaction.next_record_content)),
1778
+ workspace: parseWorkspaceContent(transaction.next_workspace_content),
1779
+ };
1780
+ }