immune-brain 3.6.8 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +11 -4
  2. package/README.zh-CN.md +10 -3
  3. package/package.json +3 -2
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  10. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  11. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
  12. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  13. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  14. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  15. package/plugins/immune-brain/dist/imm-planner.md +53 -32
  16. package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  20. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  21. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  22. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  23. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  24. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  25. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  26. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  27. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  28. package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
  29. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  30. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  31. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  32. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  33. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  34. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  35. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  36. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  37. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  38. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  39. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  40. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  41. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  42. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  43. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  44. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  45. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  46. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  47. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  48. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  49. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  51. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  52. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  53. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  54. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  55. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  56. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  57. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  58. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  59. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  60. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  62. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  63. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  66. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  67. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  68. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  69. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  70. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  71. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  72. package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
  73. package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
  74. package/plugins/immune-brain/skills/registry.yaml +9 -0
  75. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  76. package/plugins/immune-brain/bin/imm-retired +0 -4
  77. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  78. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  79. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  80. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  81. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  82. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -1,67 +1,122 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ /**
2
+ * Kernel authority store adapter.
3
+ *
4
+ * One worktree-local SQLite database (`.imm/state/kernel.sqlite`) is the single
5
+ * transaction boundary for runs, lifecycle, revisions, findings, attestations
6
+ * and operation outcomes. This module owns the authority decisions that used to
7
+ * be spread across file-level CAS writes and recoverable transaction markers:
8
+ *
9
+ * - the workspace owner is derived from the single active run,
10
+ * - concurrent writes are checked by monotonic integer revisions,
11
+ * - every replayable operation records its result in the same transaction, so a
12
+ * lost response reuses durable facts instead of writing again,
13
+ * - terminal settlement commits atomically and exports audit evidence
14
+ * afterwards, where an interrupted export stays retryable and can never
15
+ * reactivate a settled run.
16
+ *
17
+ * File-level helpers still exist for tracked evidence: documentation artifacts
18
+ * (freeze/rework relocation) and the immutable audit pair under `.imm/audit/`.
19
+ */
20
+ import { createHash } from "node:crypto";
2
21
  import {
3
- closeSync,
4
22
  constants,
23
+ closeSync,
24
+ existsSync,
5
25
  fstatSync,
6
26
  fsyncSync,
7
27
  lstatSync,
8
28
  mkdirSync,
9
29
  openSync,
10
30
  readFileSync,
31
+ readdirSync,
11
32
  realpathSync,
12
33
  renameSync,
13
34
  rmSync,
14
35
  writeFileSync,
15
36
  } from "node:fs";
16
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
37
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
38
+ import type { DatabaseSync } from "node:sqlite";
39
+
17
40
  import {
41
+ claimFromRunRow,
18
42
  parseBackendClaim,
19
- readBackendClaim,
20
43
  parseTaskTombstone,
21
- readTaskTombstone,
44
+ serializeBackendClaim,
22
45
  serializeTaskTombstone,
23
46
  type BackendClaim,
24
47
  type TaskTombstone,
25
48
  } from "./backend_claim";
26
49
  import {
27
- auditTaskRecordPath,
28
- auditTerminalProofPath,
29
- JOURNAL_RELATIVE,
30
- stateClaimPath,
31
- stateStoreLockPath,
32
- stateTaskRecordPath,
33
- stateTransactionPath,
34
- stateWorkspacePath,
50
+ auditEvidencePaths,
51
+ auditRunRecordPath,
52
+ auditRunTerminalProofPath,
53
+ FILE_STORE_CLAIM_RELATIVE,
54
+ FILE_STORE_TRANSACTIONS_RELATIVE,
55
+ FILE_STORE_WORKSPACE_RELATIVE,
56
+ stateDatabasePath,
35
57
  } from "./storage_paths";
36
- import { parseTaskRecord, parseTaskRecordV2 } from "./validation";
58
+ import { canonicalRecordHash } from "./reducer";
59
+ import { parseTaskRecord } from "./validation";
60
+ import { parseTaskRecordV2, parseTaskRecordV3, type TaskRecordV2 } from "./legacy_task_record";
61
+ import {
62
+ assertRunBinding,
63
+ drainOperationId,
64
+ enrollmentOperationId,
65
+ enrollmentRequestDigest,
66
+ mintRunId,
67
+ runIdentity,
68
+ terminalOperationId,
69
+ type RunIdentity,
70
+ } from "./run_identity";
71
+ import {
72
+ KernelStoreConflictError,
73
+ KernelStoreSecurityError,
74
+ activeRunId,
75
+ appendJournalRow,
76
+ findJournalObservation,
77
+ insertOperationRow,
78
+ insertRunRow,
79
+ listPendingAuditExports,
80
+ listPendingRelocations,
81
+ markAuditExported,
82
+ setPendingRelocations,
83
+ readOperationRow,
84
+ readRunRowById,
85
+ readRunRowByTask,
86
+ readWorkspaceRow,
87
+ setStoreFaultForTest,
88
+ updateRunClaim,
89
+ updateRunRecord,
90
+ updateRunTerminal,
91
+ withKernelRead,
92
+ withKernelTransaction,
93
+ writeWorkspaceRow,
94
+ type KernelRunRow,
95
+ } from "./sqlite_store";
37
96
  import type {
38
97
  TaskLifecycle,
39
98
  TaskPhase,
40
- TaskRecordV2,
41
99
  TaskRecord,
100
+ TaskRecordV3,
42
101
  StoredTaskMutationV3,
43
102
  V3AuthorityObservation,
44
103
  } from "./types";
45
104
 
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
- }
105
+ export {
106
+ KernelSchemaError,
107
+ KernelStoreConflictError,
108
+ KernelStoreSecurityError,
109
+ } from "./sqlite_store";
56
110
 
57
- export class KernelStoreSecurityError extends Error {
58
- readonly code = "kernel_store_security_error";
111
+ export const MISSING_REVISION = "missing";
59
112
 
60
- constructor(message: string) {
61
- super(message);
62
- this.name = "KernelStoreSecurityError";
63
- }
64
- }
113
+ /**
114
+ * The revision token of a workspace that has recorded no write yet. A worktree
115
+ * whose store has not been created reports this same token, so a preparation
116
+ * taken before the store exists still matches the store's own bootstrap
117
+ * revision instead of changing identity when the first writer creates it.
118
+ */
119
+ export const INITIAL_WORKSPACE_REVISION = recordRevision(0);
65
120
 
66
121
  export interface WorkspaceState {
67
122
  contract: "assurance_kernel/workspace/v1";
@@ -109,10 +164,37 @@ export interface JournalEntry {
109
164
  observation?: V3AuthorityObservation;
110
165
  }
111
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // Revision vocabulary: monotonic integers instead of serialized byte hashes.
169
+ // ---------------------------------------------------------------------------
170
+
112
171
  function revisionFor(content: string): string {
113
172
  return `sha256:${createHash("sha256").update(content).digest("hex")}`;
114
173
  }
115
174
 
175
+ export function revisionForContent(content: string): string {
176
+ return revisionFor(content);
177
+ }
178
+
179
+ /**
180
+ * The workspace CAS token. The contract requires a `sha256:` token, and the
181
+ * value must be revision-based: two workspaces with identical serialized
182
+ * content at different revisions are different states, so the token binds the
183
+ * store's monotonic revision instead of the bytes.
184
+ */
185
+ function recordRevision(revision: number): string {
186
+ return revisionFor(`assurance_kernel/workspace_revision/v1:${revision}`);
187
+ }
188
+
189
+ function nowIso(): string {
190
+ return new Date().toISOString();
191
+ }
192
+
193
+ function validateTaskId(taskId: string): void {
194
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
195
+ throw new KernelStoreSecurityError("task_id is not a safe file identity");
196
+ }
197
+
116
198
  function canonicalRoot(root: string): string {
117
199
  try {
118
200
  return realpathSync(root);
@@ -121,6 +203,174 @@ function canonicalRoot(root: string): string {
121
203
  }
122
204
  }
123
205
 
206
+ // ---------------------------------------------------------------------------
207
+ // Retired file-store guard: a worktree must never mix two authority stores.
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Retire the retired claim/owner files that provably duplicate this task's own
212
+ * committed run. Called only from an authority repair, whose proof is what
213
+ * authorizes the deletion; ordinary mutations never delete retired authority.
214
+ */
215
+ function retireSupersededRetiredFiles(root: string, db: DatabaseSync, taskId: string): void {
216
+ const canonical = canonicalRoot(root);
217
+ for (const path of [FILE_STORE_CLAIM_RELATIVE, FILE_STORE_WORKSPACE_RELATIVE]) {
218
+ const full = resolve(canonical, path);
219
+ if (!existsSync(full)) continue;
220
+ if (isRetiredFileProvablySuperseded(full, db, taskId)) rmSync(full, { force: true });
221
+ }
222
+ }
223
+
224
+ /**
225
+ * True only when the retired file's own identity matches the committed run for
226
+ * this task: the same task id, and — when the file carries them — the same
227
+ * enrollment event and intent hash. An unreadable or foreign file is never
228
+ * treated as superseded.
229
+ */
230
+ function isRetiredFileProvablySuperseded(
231
+ path: string,
232
+ db: DatabaseSync,
233
+ taskId: string,
234
+ ): boolean {
235
+ const run = readRunRowByTask(db, taskId);
236
+ if (!run) return false;
237
+ let raw: Record<string, unknown>;
238
+ try {
239
+ raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
240
+ } catch {
241
+ return false;
242
+ }
243
+ // Ownership, not freshness: a stale copy of this task's own claim is exactly
244
+ // what a repair removes, while another task's claim is never this task's to
245
+ // retire. The store holding this task's run is what makes the file a
246
+ // duplicate of committed authority — but only the *same execution* proves it:
247
+ // another enrollment of the same task (different event, revision or content
248
+ // hash) is a different authority decision and must survive.
249
+ if (raw.task_id !== taskId) return false;
250
+ if (raw.contract !== "assurance_kernel/backend_claim/v2") {
251
+ // The retired workspace owner names its owner directly.
252
+ return raw.current_working === taskId;
253
+ }
254
+ if (raw.backend !== "kernel") return false;
255
+ if (raw.enrollment_event_id !== run.enrollment_event_id) return false;
256
+ if (raw.intent_revision !== run.intent_revision) return false;
257
+ if (raw.intent_content_hash !== run.intent_content_hash) return false;
258
+ return true;
259
+ }
260
+
261
+ /**
262
+ * Refuse mutation while the retired `.imm/state/*.json` file store (or the
263
+ * pre-cutover `.imm/tasks` layout) still holds authority. The check is bounded
264
+ * to `existsSync` paths so it can run on every locked mutation.
265
+ */
266
+ function assertNoRetiredFileStore(
267
+ root: string,
268
+ db?: DatabaseSync,
269
+ taskId?: string | null,
270
+ ): void {
271
+ const canonical = canonicalRoot(root);
272
+ // Real authority in the retired store: must be imported, never ignored.
273
+ const retired: Array<[string, string]> = [
274
+ [".imm/tasks", "pre-cutover task store"],
275
+ [".imm/workspace.json", "pre-cutover workspace owner"],
276
+ [".imm/state/tasks", "task records"],
277
+ ];
278
+ for (const [path, label] of retired) {
279
+ if (existsSync(resolve(canonical, path)))
280
+ throw new KernelStoreSecurityError(
281
+ `retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`,
282
+ );
283
+ }
284
+ // A retired claim/owner file is inert only when its own bytes prove that this
285
+ // exact task and enrollment already own the SQLite run: it is then a
286
+ // duplicate of committed authority. Nothing is deleted here — removal
287
+ // belongs to the supported migration — and a file whose owner is anything
288
+ // else keeps its authority and fails the mutation closed.
289
+ const derived: Array<[string, string]> = [
290
+ [FILE_STORE_CLAIM_RELATIVE, "workspace claim"],
291
+ [FILE_STORE_WORKSPACE_RELATIVE, "workspace owner"],
292
+ ];
293
+ for (const [path, label] of derived) {
294
+ const full = resolve(canonical, path);
295
+ if (!existsSync(full)) continue;
296
+ if (db === undefined || typeof taskId !== "string" || taskId.length === 0)
297
+ throw new KernelStoreSecurityError(
298
+ `retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`,
299
+ );
300
+ if (!isRetiredFileProvablySuperseded(full, db, taskId))
301
+ throw new KernelStoreSecurityError(
302
+ `retired file-store authority is present (${label}: ${path}) and does not belong to this task; import it with the supported migration before mutating this worktree`,
303
+ );
304
+ }
305
+ if (existsSync(resolve(canonical, FILE_STORE_TRANSACTIONS_RELATIVE))) {
306
+ const entries = readdirNames(resolve(canonical, FILE_STORE_TRANSACTIONS_RELATIVE));
307
+ const pending = entries.filter((entry) => entry.endsWith(".json") && entry !== "storage-layout-migration.json");
308
+ if (pending.length > 0)
309
+ throw new KernelStoreSecurityError(
310
+ `retired file-store transaction marker is present (${pending[0]}); settle it with the runtime that wrote it before mutating this worktree`,
311
+ );
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Read-only file-store conflict inspection. Real authority in the retired store
317
+ * always conflicts; a derived claim/owner file conflicts only while the store
318
+ * has no run for the requested task (a leftover for a known task is inert and
319
+ * gets retired by the next mutation).
320
+ */
321
+ function retiredFileStoreConflict(
322
+ root: string,
323
+ db: DatabaseSync | null,
324
+ taskId: string | null,
325
+ ): string | null {
326
+ const canonical = canonicalRoot(root);
327
+ const authority: Array<[string, string]> = [
328
+ [".imm/tasks", "pre-cutover task store"],
329
+ [".imm/workspace.json", "pre-cutover workspace owner"],
330
+ [".imm/state/tasks", "task records"],
331
+ ];
332
+ for (const [path, label] of authority)
333
+ if (existsSync(resolve(canonical, path)))
334
+ return `retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`;
335
+ const storeHasTask = db !== null && taskId !== null && readRunRowByTask(db, taskId) !== null;
336
+ if (!storeHasTask) {
337
+ const derived: Array<[string, string]> = [
338
+ [FILE_STORE_CLAIM_RELATIVE, "workspace claim"],
339
+ [FILE_STORE_WORKSPACE_RELATIVE, "workspace owner"],
340
+ ];
341
+ for (const [path, label] of derived)
342
+ if (existsSync(resolve(canonical, path)))
343
+ return `retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`;
344
+ }
345
+ const transactions = resolve(canonical, FILE_STORE_TRANSACTIONS_RELATIVE);
346
+ if (existsSync(transactions)) {
347
+ const pending = readdirNames(transactions).filter(
348
+ (entry) => entry.endsWith(".json") && entry !== "storage-layout-migration.json",
349
+ );
350
+ if (pending.length > 0)
351
+ return `retired file-store transaction marker is present (${pending[0]}); settle it with the runtime that wrote it before mutating this worktree`;
352
+ }
353
+ return null;
354
+ }
355
+
356
+ function readdirNames(path: string): string[] {
357
+ try {
358
+ return readdirSync(path);
359
+ } catch (error) {
360
+ const code = (error as NodeJS.ErrnoException).code;
361
+ // A missing directory is an empty one; anything else (permissions, I/O) is
362
+ // an unverifiable listing and must never be read as "no retired authority".
363
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
364
+ throw new KernelStoreSecurityError(
365
+ `retired store directory ${path} could not be listed (${code ?? "unknown"}); resolve the filesystem condition before mutating`,
366
+ );
367
+ }
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Secure file helpers (tracked evidence + artifact relocation only).
372
+ // ---------------------------------------------------------------------------
373
+
124
374
  function withinRoot(root: string, candidate: string): boolean {
125
375
  const rel = relative(root, candidate);
126
376
  return (
@@ -281,9 +531,7 @@ function clearStaleLock(lockPath: string): boolean {
281
531
  const before = pathStatOrNull(lockPath);
282
532
  if (!before) return true;
283
533
  if (before.isSymbolicLink() || !before.isFile())
284
- throw new KernelStoreSecurityError(
285
- "kernel store lock is not a regular file",
286
- );
534
+ throw new KernelStoreSecurityError("kernel store lock is not a regular file");
287
535
  let stale = false;
288
536
  let fd: number | null = null;
289
537
  try {
@@ -300,11 +548,7 @@ function clearStaleLock(lockPath: string): boolean {
300
548
  }
301
549
  if (!stale) return false;
302
550
  const after = lstatSync(lockPath);
303
- if (
304
- after.isSymbolicLink() ||
305
- after.dev !== before.dev ||
306
- after.ino !== before.ino
307
- )
551
+ if (after.isSymbolicLink() || after.dev !== before.dev || after.ino !== before.ino)
308
552
  throw new KernelStoreSecurityError(
309
553
  "kernel store lock identity changed during recovery",
310
554
  );
@@ -312,6 +556,7 @@ function clearStaleLock(lockPath: string): boolean {
312
556
  return true;
313
557
  }
314
558
 
559
+ /** Bounded exclusive file lock for tracked-evidence writes. */
315
560
  function withExclusiveLock<T>(lockPath: string, operation: () => T): T {
316
561
  const noFollow = constants.O_NOFOLLOW ?? 0;
317
562
  let fd: number | null = null;
@@ -319,10 +564,7 @@ function withExclusiveLock<T>(lockPath: string, operation: () => T): T {
319
564
  try {
320
565
  fd = openSync(
321
566
  lockPath,
322
- constants.O_WRONLY |
323
- constants.O_CREAT |
324
- constants.O_EXCL |
325
- noFollow,
567
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
326
568
  0o600,
327
569
  );
328
570
  break;
@@ -344,7 +586,7 @@ function withExclusiveLock<T>(lockPath: string, operation: () => T): T {
344
586
  try {
345
587
  writeFileSync(
346
588
  fd,
347
- `${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`,
589
+ `${JSON.stringify({ pid: process.pid, started_at: nowIso() })}\n`,
348
590
  "utf8",
349
591
  );
350
592
  fsyncSync(fd);
@@ -387,14 +629,10 @@ function atomicCasWrite(
387
629
  throw new KernelStoreConflictError(
388
630
  `CAS mismatch for ${relativePath}: expected ${expectedRevision}, got ${actualRevision}`,
389
631
  );
390
- const tempPath = `${candidate.path}.${randomUUID()}.tmp`;
632
+ const tempPath = `${candidate.path}.${process.pid}.tmp`;
391
633
  let fd: number | null = null;
392
634
  try {
393
- fd = openSync(
394
- tempPath,
395
- constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
396
- 0o600,
397
- );
635
+ fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
398
636
  writeFileSync(fd, content, "utf8");
399
637
  fsyncSync(fd);
400
638
  closeSync(fd);
@@ -410,93 +648,7 @@ function atomicCasWrite(
410
648
  });
411
649
  }
412
650
 
413
- function validateTaskId(taskId: string): void {
414
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
415
- throw new KernelStoreSecurityError("task_id is not a safe file identity");
416
- }
417
-
418
- const JOURNAL_LOCK_NAME = ".journal.lock";
419
- const JOURNAL_READ_LIMIT = 64 * 1024 * 1024;
420
- const TRANSACTION_PATH = ".imm/tasks/.workspace-transaction.json";
421
- const V1_TRANSACTION_RETIRED = "workspace_transaction/v1 is retired after v4 storage retirement; use TaskRecord v3 + workspace_transaction/v2";
422
-
423
- let afterTaskTransactionWriteForTest: (() => void) | null = null;
424
-
425
- /** Test-only seam for a failure after the first file in a two-file transaction. */
426
- export function setAfterTaskTransactionWriteForTest(
427
- hook: (() => void) | null,
428
- ): void {
429
- afterTaskTransactionWriteForTest = hook;
430
- }
431
-
432
- function runAfterTaskTransactionWriteHook(): void {
433
- const hook = afterTaskTransactionWriteForTest;
434
- afterTaskTransactionWriteForTest = null;
435
- hook?.();
436
- }
437
-
438
- let terminalSettlementStepHookForTest: ((stepIndex: number) => void) | null = null;
439
-
440
- /** Test-only seam: fail deterministically after each terminal settlement step. */
441
- export function setTerminalSettlementStepHookForTest(
442
- hook: ((stepIndex: number) => void) | null,
443
- ): void {
444
- terminalSettlementStepHookForTest = hook;
445
- }
446
-
447
- function runTerminalSettlementStepHook(stepIndex: number): void {
448
- const hook = terminalSettlementStepHookForTest;
449
- if (!hook) return;
450
- hook(stepIndex);
451
- // A hook that did not interrupt keeps observing later steps; a thrown
452
- // interruption propagates before this line and is consumed by the caller
453
- // clearing the hook.
454
- }
455
-
456
-
457
- function parseWorkspaceContent(content: string): WorkspaceState {
458
- const raw = JSON.parse(content) as Record<string, unknown>;
459
- const unknown = Object.keys(raw).filter(
460
- (key) => !["contract", "current_working"].includes(key),
461
- );
462
- if (unknown.length > 0)
463
- throw new KernelStoreSecurityError(
464
- `workspace has unknown field: ${unknown[0]}`,
465
- );
466
- if (raw.contract !== "assurance_kernel/workspace/v1")
467
- throw new KernelStoreSecurityError("workspace contract is invalid");
468
- if (
469
- raw.current_working !== null &&
470
- (typeof raw.current_working !== "string" || !raw.current_working.trim())
471
- )
472
- throw new KernelStoreSecurityError("workspace current_working is invalid");
473
- if (typeof raw.current_working === "string") validateTaskId(raw.current_working);
474
- return raw as unknown as WorkspaceState;
475
- }
476
-
477
- function serializeWorkspace(state: WorkspaceState): string {
478
- return `${JSON.stringify(state, null, 2)}\n`;
479
- }
480
-
481
- export { serializeWorkspace };
482
-
483
- export function readWorkspaceStateRaw(root: string): {
484
- revision: string;
485
- state: WorkspaceState;
486
- } {
487
- const relativePath = stateWorkspacePath();
488
- if (currentRevision(root, relativePath) === MISSING_REVISION)
489
- return {
490
- revision: MISSING_REVISION,
491
- state: {
492
- contract: "assurance_kernel/workspace/v1",
493
- current_working: null,
494
- },
495
- };
496
- const content = readSecureProjectFile(root, relativePath);
497
- return { revision: revisionFor(content), state: parseWorkspaceContent(content) };
498
- }
499
-
651
+ /** Converge a tracked evidence file to exact bytes: same bytes are idempotent. */
500
652
  function convergeFile(
501
653
  root: string,
502
654
  relativePath: string,
@@ -510,91 +662,13 @@ function convergeFile(
510
662
  throw new KernelStoreConflictError(
511
663
  `transaction conflict for ${relativePath}: expected ${expectedRevision} or ${nextRevision}, got ${actualRevision}`,
512
664
  );
513
- return atomicCasWrite(
514
- root,
515
- relativePath,
516
- nextContent,
517
- expectedRevision,
518
- );
519
- }
520
-
521
- function appendJournalLineLocked(root: string, entry: JournalEntry): void {
522
- const directory = ensureSecureDirectory(root, dirname(JOURNAL_RELATIVE));
523
- const path = resolve(directory, basename(JOURNAL_RELATIVE));
524
- assertNoSymlinkSegments(canonicalRoot(root), path);
525
- const noFollow = constants.O_NOFOLLOW ?? 0;
526
- const fd = openSync(
527
- path,
528
- constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | noFollow,
529
- 0o600,
530
- );
531
- try {
532
- writeFileSync(fd, `${JSON.stringify(entry)}\n`, "utf8");
533
- fsyncSync(fd);
534
- } finally {
535
- closeSync(fd);
536
- }
537
- }
538
-
539
- function withJournalLock<T>(root: string, operation: () => T): T {
540
- const directory = ensureSecureDirectory(root, dirname(JOURNAL_RELATIVE));
541
- return withExclusiveLock(resolve(directory, JOURNAL_LOCK_NAME), operation);
542
- }
543
-
544
- export function appendJournalEntry(root: string, entry: JournalEntry): void {
545
- withJournalLock(root, () => appendJournalLineLocked(root, entry));
546
- }
547
-
548
- export function appendObservationJournalEntry(
549
- root: string,
550
- entry: JournalEntry & { observation: V3AuthorityObservation },
551
- ): "appended" | "duplicate" {
552
- return withJournalLock(root, () => {
553
- let existing = "";
554
- try {
555
- existing = readSecureProjectFile(root, JOURNAL_RELATIVE);
556
- } catch (error) {
557
- if (!(error instanceof Error) || !error.message.startsWith("source_missing:"))
558
- throw error;
559
- }
560
- if (Buffer.byteLength(existing, "utf8") > JOURNAL_READ_LIMIT)
561
- throw new KernelStoreSecurityError("kernel journal exceeds the observation read limit");
562
- for (const [index, line] of existing.split("\n").entries()) {
563
- if (!line.trim()) continue;
564
- let parsed: Record<string, unknown>;
565
- try {
566
- parsed = JSON.parse(line) as Record<string, unknown>;
567
- } catch {
568
- throw new KernelStoreSecurityError(
569
- `kernel journal line ${index + 1} is not valid JSON`,
570
- );
571
- }
572
- const observation = parsed.observation as
573
- | Record<string, unknown>
574
- | undefined;
575
- if (observation?.commit_id !== entry.observation.commit_id) continue;
576
- if (observation.observation_id === entry.observation.observation_id)
577
- return "duplicate";
578
- throw new KernelStoreConflictError(
579
- `observation commit identity conflict: ${entry.observation.commit_id}`,
580
- );
581
- }
582
- appendJournalLineLocked(root, entry);
583
- return "appended";
584
- });
665
+ return atomicCasWrite(root, relativePath, nextContent, expectedRevision);
585
666
  }
586
667
 
587
668
  // ---------------------------------------------------------------------------
588
- // R2C2 dedicated TaskRecord v3 transaction path.
589
- // Uses the existing exclusive store lock and rejects the retired v1 marker.
669
+ // Artifact relocation (documentation artifacts only; S2 removes the relocation).
590
670
  // ---------------------------------------------------------------------------
591
671
 
592
- const TRANSACTION_PATH_V2 = stateTransactionPath("workspace-transaction-v2.json");
593
- const ENROLLMENT_MARKER_PATH = stateTransactionPath("enrollment-marker.json");
594
- const DRAIN_MARKER_PATH = stateTransactionPath("drain-transaction.json");
595
- const TERMINAL_MARKER_PATH = stateTransactionPath("terminal-transaction.json");
596
- const AUTHORITY_REPAIR_MARKER_PATH = stateTransactionPath("authority-repair-transaction.json");
597
-
598
672
  export interface ArtifactRelocationV1 {
599
673
  from_path: string;
600
674
  to_path: string;
@@ -613,89 +687,6 @@ interface WorkspaceTransactionV2 {
613
687
 
614
688
  export type { WorkspaceTransactionV2 };
615
689
 
616
- export function revisionForContent(content: string): string {
617
- return revisionFor(content);
618
- }
619
-
620
- function parseWorkspaceTransactionV2(raw: Record<string, unknown>): WorkspaceTransactionV2 {
621
- const allowed = [
622
- "contract",
623
- "task_id",
624
- "expected_record_hash",
625
- "next_record_content",
626
- "expected_workspace_hash",
627
- "next_workspace_content",
628
- "artifact_relocations",
629
- ];
630
- const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
631
- if (unknown.length > 0)
632
- throw new KernelStoreSecurityError(
633
- `workspace transaction v2 has unknown field: ${unknown[0]}`,
634
- );
635
- if (raw.contract !== "assurance_kernel/workspace_transaction/v2")
636
- throw new KernelStoreSecurityError(
637
- "workspace transaction v2 contract is invalid",
638
- );
639
- for (const field of allowed.slice(1, 6)) {
640
- if (typeof raw[field] !== "string" || !String(raw[field]).trim())
641
- throw new KernelStoreSecurityError(
642
- `workspace transaction v2 ${field} is invalid`,
643
- );
644
- }
645
- const relocationRaw = raw.artifact_relocations;
646
- if (relocationRaw !== undefined && !Array.isArray(relocationRaw))
647
- throw new KernelStoreSecurityError("workspace transaction v2 artifact_relocations is invalid");
648
- const artifactRelocations = (relocationRaw ?? []).map((item, index) => {
649
- if (!item || typeof item !== "object" || Array.isArray(item))
650
- throw new KernelStoreSecurityError(`artifact relocation ${index} is invalid`);
651
- const value = item as Record<string, unknown>;
652
- const unknownFields = Object.keys(value).filter((key) => !["from_path", "to_path", "content_hash"].includes(key));
653
- if (unknownFields.length > 0)
654
- throw new KernelStoreSecurityError(`artifact relocation has unknown field: ${unknownFields[0]}`);
655
- for (const field of ["from_path", "to_path", "content_hash"])
656
- if (typeof value[field] !== "string" || !String(value[field]).trim())
657
- throw new KernelStoreSecurityError(`artifact relocation ${field} is invalid`);
658
- const relocation = value as unknown as ArtifactRelocationV1;
659
- assertArtifactRelocation(relocation);
660
- return relocation;
661
- });
662
- const transaction = {
663
- ...(raw as unknown as WorkspaceTransactionV2),
664
- ...(artifactRelocations.length > 0 ? { artifact_relocations: artifactRelocations } : {}),
665
- };
666
- validateTaskId(transaction.task_id);
667
- const record = parseTaskRecord(
668
- JSON.parse(transaction.next_record_content),
669
- );
670
- if (record.task_id !== transaction.task_id)
671
- throw new KernelStoreSecurityError(
672
- "workspace transaction v2 task identity is inconsistent",
673
- );
674
- parseWorkspaceContent(transaction.next_workspace_content);
675
- return transaction;
676
- }
677
-
678
- function readPendingTransactionV2(root: string): WorkspaceTransactionV2 | null {
679
- if (currentRevision(root, TRANSACTION_PATH_V2) === MISSING_REVISION) return null;
680
- const raw = JSON.parse(
681
- readSecureProjectFile(root, TRANSACTION_PATH_V2),
682
- ) as Record<string, unknown>;
683
- return parseWorkspaceTransactionV2(raw);
684
- }
685
-
686
- function removeTransactionMarkerV2(root: string): void {
687
- const candidate = safeCandidate(root, TRANSACTION_PATH_V2);
688
- assertNoSymlinkSegments(candidate.root, candidate.path);
689
- const stat = pathStatOrNull(candidate.path);
690
- if (!stat) return;
691
- if (!stat.isFile())
692
- throw new KernelStoreSecurityError(
693
- "workspace transaction v2 marker is not a regular file",
694
- );
695
- rmSync(candidate.path);
696
- fsyncDirectory(dirname(candidate.path));
697
- }
698
-
699
690
  function archiveArtifactPath(path: string): string | null {
700
691
  const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
701
692
  return matched ? `docs/${matched[1]}/archive/${matched[2]}` : null;
@@ -705,8 +696,8 @@ function assertArtifactRelocation(relocation: ArtifactRelocationV1): void {
705
696
  if (!/^sha256:[a-f0-9]{64}$/.test(relocation.content_hash))
706
697
  throw new KernelStoreSecurityError("artifact relocation content_hash is invalid");
707
698
  if (
708
- archiveArtifactPath(relocation.from_path) !== relocation.to_path
709
- && archiveArtifactPath(relocation.to_path) !== relocation.from_path
699
+ archiveArtifactPath(relocation.from_path) !== relocation.to_path &&
700
+ archiveArtifactPath(relocation.to_path) !== relocation.from_path
710
701
  )
711
702
  throw new KernelStoreSecurityError("artifact relocation paths must be one active/archive pair");
712
703
  }
@@ -730,118 +721,258 @@ function convergeArtifactRelocation(root: string, relocation: ArtifactRelocation
730
721
  if (dirname(from.path) !== dirname(to.path)) fsyncDirectory(dirname(to.path));
731
722
  }
732
723
 
733
- function completeTransactionV2Locked(
734
- root: string,
735
- transaction: WorkspaceTransactionV2,
736
- invokeTestHook: boolean,
737
- ): StoredTaskMutationV3 {
738
- for (const relocation of transaction.artifact_relocations ?? [])
739
- convergeArtifactRelocation(root, relocation);
740
- const taskPath = stateTaskRecordPath(transaction.task_id);
741
- const taskRevision = convergeFile(
742
- root,
743
- taskPath,
744
- transaction.expected_record_hash,
745
- transaction.next_record_content,
746
- );
747
- if (invokeTestHook) runAfterTaskTransactionWriteHook();
748
- const workspaceRevision = convergeFile(
749
- root,
750
- stateWorkspacePath(),
751
- transaction.expected_workspace_hash,
752
- transaction.next_workspace_content,
753
- );
754
- const record = parseTaskRecord(
755
- JSON.parse(transaction.next_record_content),
756
- );
757
- const workspace = parseWorkspaceContent(transaction.next_workspace_content);
758
- removeTransactionMarkerV2(root);
759
- return {
760
- revision: taskRevision,
761
- record,
762
- workspace: { revision: workspaceRevision, state: workspace },
763
- };
764
- }
765
-
766
- function recoverPendingTransactionV2Locked(root: string): void {
767
- const transaction = readPendingTransactionV2(root);
768
- if (transaction) completeTransactionV2Locked(root, transaction, false);
769
- }
724
+ // ---------------------------------------------------------------------------
725
+ // Test seams.
726
+ // ---------------------------------------------------------------------------
770
727
 
771
728
  /**
772
- * v4-only recovery gate: if a retired v1 transaction marker exists, every
773
- * store recovery (including v2) fails closed with the stable diagnostic.
774
- * This guarantees no v4 operation can silently complete or discard a legacy
775
- * v1 transaction; the operator must resolve it with the prior runtime first.
729
+ * Test-only seam. Runs after the authority writes of the current transaction
730
+ * and immediately before COMMIT, so a thrown fault must roll the whole
731
+ * transaction back with no partial authority.
776
732
  */
777
- function assertNoRetiredV1Marker(root: string): void {
778
- if (currentRevision(root, TRANSACTION_PATH) !== MISSING_REVISION)
779
- throw new KernelStoreSecurityError(V1_TRANSACTION_RETIRED);
780
- }
781
-
782
- /** Reject any retired v1 marker before considering v2 recovery. */
783
- function recoverAnyPendingTransactionLocked(root: string): void {
784
- const hasV1 = currentRevision(root, TRANSACTION_PATH) !== MISSING_REVISION;
785
- const hasV2 = currentRevision(root, TRANSACTION_PATH_V2) !== MISSING_REVISION;
786
- const hasEnrollment =
787
- currentRevision(root, ENROLLMENT_MARKER_PATH) !== MISSING_REVISION;
788
- const hasDrain = currentRevision(root, DRAIN_MARKER_PATH) !== MISSING_REVISION;
789
- const hasTerminal = currentRevision(root, TERMINAL_MARKER_PATH) !== MISSING_REVISION;
790
- const hasAuthorityRepair =
791
- currentRevision(root, AUTHORITY_REPAIR_MARKER_PATH) !== MISSING_REVISION;
792
- if (hasV1)
793
- throw new KernelStoreSecurityError(V1_TRANSACTION_RETIRED);
794
- const markers = [hasV2, hasEnrollment, hasDrain, hasTerminal, hasAuthorityRepair].filter(Boolean).length;
795
- if (markers > 1)
796
- throw new KernelStoreSecurityError(
797
- "simultaneous workspace transaction markers are forbidden",
798
- );
799
- if (hasV2) recoverPendingTransactionV2Locked(root);
800
- if (hasEnrollment) recoverPendingEnrollmentLocked(root);
801
- if (hasDrain) recoverPendingDrainLocked(root);
802
- if (hasTerminal) recoverPendingTerminalLocked(root, false);
803
- if (hasAuthorityRepair) recoverPendingAuthorityRepairLocked(root);
733
+ export function setAfterTaskTransactionWriteForTest(
734
+ hook: (() => void) | null,
735
+ ): void {
736
+ setStoreFaultForTest(hook);
804
737
  }
805
738
 
806
- export function readTaskRecordRaw(
807
- root: string,
808
- taskId: string,
809
- ): { revision: string; record: TaskRecord | null } {
810
- validateTaskId(taskId);
811
- const relativePath = stateTaskRecordPath(taskId);
812
- if (currentRevision(root, relativePath) === MISSING_REVISION)
813
- return { revision: MISSING_REVISION, record: null };
814
- const content = readSecureProjectFile(root, relativePath);
815
- const raw = JSON.parse(content) as { contract?: unknown };
816
- if (raw.contract === "assurance_kernel/task_record/v2")
817
- throw new KernelStoreSecurityError(
818
- "TaskRecord v2 is not supported in the state layout; v2 records belong to the historical audit layout",
819
- );
820
- const record = parseTaskRecord(raw);
821
- if (record.task_id !== taskId)
822
- throw new KernelStoreSecurityError("task record v3 identity is inconsistent");
823
- return { revision: revisionFor(content), record };
739
+ let auditExportFaultForTest: (() => void) | null = null;
740
+
741
+ /** Test-only seam: fail the terminal audit export after settlement commits. */
742
+ export function setAuditExportFaultForTest(hook: (() => void) | null): void {
743
+ auditExportFaultForTest = hook;
824
744
  }
825
745
 
826
- /**
827
- * Read the immutable terminal audit pair for one task:
828
- * `.imm/audit/<task-id>/task-record.json` plus `terminal-proof.json`.
829
- * Both files must exist, be identity-consistent, and the proof's
830
- * `final_record_hash` must equal the record bytes' revision. Only ENOENT on
831
- * the whole task directory means absent; a partial pair fails closed.
832
- * The audit record may be TaskRecord v3 or historical terminal v2.
833
- */
834
- export function readAuditTaskPair(
746
+ function runAuditExportFault(): void {
747
+ const hook = auditExportFaultForTest;
748
+ auditExportFaultForTest = null;
749
+ hook?.();
750
+ }
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // Workspace and TaskRecord reads.
754
+ // ---------------------------------------------------------------------------
755
+
756
+ function parseWorkspaceContent(content: string): WorkspaceState {
757
+ const raw = JSON.parse(content) as Record<string, unknown>;
758
+ const unknown = Object.keys(raw).filter(
759
+ (key) => !["contract", "current_working"].includes(key),
760
+ );
761
+ if (unknown.length > 0)
762
+ throw new KernelStoreSecurityError(`workspace has unknown field: ${unknown[0]}`);
763
+ if (raw.contract !== "assurance_kernel/workspace/v1")
764
+ throw new KernelStoreSecurityError("workspace contract is invalid");
765
+ if (
766
+ raw.current_working !== null &&
767
+ (typeof raw.current_working !== "string" || !raw.current_working.trim())
768
+ )
769
+ throw new KernelStoreSecurityError("workspace current_working is invalid");
770
+ if (typeof raw.current_working === "string") validateTaskId(raw.current_working);
771
+ return raw as unknown as WorkspaceState;
772
+ }
773
+
774
+ function serializeWorkspace(state: WorkspaceState): string {
775
+ return `${JSON.stringify(state, null, 2)}\n`;
776
+ }
777
+
778
+ export { serializeWorkspace };
779
+
780
+ function workspaceStateFromRow(db: DatabaseSync, runId: string | null): WorkspaceState {
781
+ if (!runId) return { contract: "assurance_kernel/workspace/v1", current_working: null };
782
+ const run = readRunRowById(db, runId);
783
+ return {
784
+ contract: "assurance_kernel/workspace/v1",
785
+ current_working: run && run.state === "active" ? run.task_id : null,
786
+ };
787
+ }
788
+
789
+ /**
790
+ * The run this worktree currently holds for a task, read straight from the
791
+ * store. Capability validation needs only this identity, so it never pays for
792
+ * a full authority projection on the mutation path.
793
+ */
794
+ /**
795
+ * The committed result of a terminal operation, if this worktree already
796
+ * committed it. Settlement clears the active run, so a lost response can only
797
+ * be answered from the durable operation row — and only with the committed
798
+ * result, never by re-running the operation.
799
+ */
800
+ export function readCommittedTerminalResult(
801
+ root: string,
802
+ taskId: string,
803
+ eventId: string,
804
+ requestDigest?: string,
805
+ ): { record: TaskRecord; workspace: WorkspaceState } | null {
806
+ validateTaskId(taskId);
807
+ const read = withKernelRead(root, (db) => {
808
+ const row = readOperationRow(db, terminalOperationId(taskId, eventId));
809
+ if (!row) return null;
810
+ const parsed = JSON.parse(row.result_json) as CommittedOperationResult;
811
+ if ((parsed.request_digest ?? null) !== (requestDigest ?? null))
812
+ throw new KernelStoreConflictError(
813
+ `terminal operation for ${taskId} was committed for a different request; resubmit the exact request that settled it`,
814
+ );
815
+ return decodeOperationResult(row.result_json);
816
+ });
817
+ return read ?? null;
818
+ }
819
+
820
+ /**
821
+ * The committed result of an enrollment, if this worktree already committed
822
+ * it. A lost response cannot pass the first-execution checks (the capability
823
+ * is consumed and the record exists), so the durable operation is the only
824
+ * correct answer — and only for the exact request that enrolled it.
825
+ */
826
+ export function readCommittedEnrollmentResult(
835
827
  root: string,
836
828
  taskId: string,
829
+ eventId: string,
830
+ requestDigest?: string,
831
+ ): { record: TaskRecord; workspace: WorkspaceState; claim: BackendClaim } | null {
832
+ validateTaskId(taskId);
833
+ const read = withKernelRead(root, (db) => {
834
+ const row = readOperationRow(db, enrollmentOperationId(taskId, eventId));
835
+ if (!row) return null;
836
+ const parsed = JSON.parse(row.result_json) as CommittedOperationResult;
837
+ if ((parsed.request_digest ?? null) !== (requestDigest ?? null))
838
+ throw new KernelStoreConflictError(
839
+ `enrollment operation for ${taskId} was committed for a different request; resubmit the exact request that enrolled it`,
840
+ );
841
+ const decoded = decodeOperationResult(row.result_json);
842
+ if (typeof parsed.claim_json !== "string")
843
+ throw new KernelStoreConflictError(
844
+ `enrollment operation for ${taskId} has no committed claim`,
845
+ );
846
+ return {
847
+ ...decoded,
848
+ claim: parseBackendClaim(JSON.parse(parsed.claim_json) as Record<string, unknown>),
849
+ };
850
+ });
851
+ return read ?? null;
852
+ }
853
+
854
+ /**
855
+ * This worktree's run for a task, including terminal runs. Batch commit and
856
+ * recovery read a *settled* child's evidence, so the active-only lookup is not
857
+ * enough, and another worktree's run of the same logical task must never be
858
+ * chosen: the run identity comes from the local store.
859
+ */
860
+ export function localRunId(root: string, taskId: string): string | null {
861
+ validateTaskId(taskId);
862
+ const row = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
863
+ return row?.run_id ?? null;
864
+ }
865
+
866
+ export function currentRunId(root: string, taskId: string): string | null {
867
+ validateTaskId(taskId);
868
+ const read = withKernelRead(root, (db) => {
869
+ const run = readRunRowByTask(db, taskId);
870
+ return run && run.state === "active" ? run.run_id : null;
871
+ });
872
+ return read ?? null;
873
+ }
874
+
875
+ export function readWorkspaceStateRaw(root: string): {
876
+ revision: string;
877
+ state: WorkspaceState;
878
+ } {
879
+ const read = withKernelRead(root, (db) => {
880
+ const row = readWorkspaceRow(db);
881
+ return {
882
+ // The CAS token is the store's monotonic revision, never a hash of the
883
+ // serialized owner: two idle workspaces with identical content are
884
+ // still different revisions.
885
+ revision: recordRevision(row.revision),
886
+ state: workspaceStateFromRow(db, row.current_run_id),
887
+ };
888
+ });
889
+ if (read) return read;
890
+ return {
891
+ revision: INITIAL_WORKSPACE_REVISION,
892
+ state: { contract: "assurance_kernel/workspace/v1", current_working: null },
893
+ };
894
+ }
895
+
896
+ function recordFromRun(run: KernelRunRow): TaskRecord {
897
+ const record = parseTaskRecord(JSON.parse(run.record_json) as Record<string, unknown>);
898
+ if (record.task_id !== run.task_id)
899
+ throw new KernelStoreSecurityError("task record identity is inconsistent with its run");
900
+ return record;
901
+ }
902
+
903
+ export function readTaskRecordRaw(
904
+ root: string,
905
+ taskId: string,
906
+ ): { revision: string; record: TaskRecord | null } {
907
+ validateTaskId(taskId);
908
+ const read = withKernelRead(root, (db) => {
909
+ const run = readRunRowByTask(db, taskId);
910
+ if (!run) return { revision: MISSING_REVISION, record: null };
911
+ // Terminal records stay durable in the store as the run index; the
912
+ // active-record surface mirrors the previous layout, where terminal
913
+ // evidence lives in the immutable audit pair.
914
+ if (run.state !== "active") return { revision: MISSING_REVISION, record: null };
915
+ const record = recordFromRun(run);
916
+ return { revision: canonicalRecordHash(record), record };
917
+ });
918
+ return read ?? { revision: MISSING_REVISION, record: null };
919
+ }
920
+
921
+ /**
922
+ * The committed record straight from the store, for a run of any lifecycle.
923
+ * This is the authority for a settled task; the audit pair is exported
924
+ * evidence and may still be in flight.
925
+ */
926
+ export function readCommittedRecord(
927
+ root: string,
928
+ taskId: string,
929
+ ): { revision: string; record: TaskRecord } | null {
930
+ validateTaskId(taskId);
931
+ const read = withKernelRead(root, (db) => {
932
+ const run = readRunRowByTask(db, taskId);
933
+ if (!run) return null;
934
+ // A settled run always carries the proof its settlement committed; a store
935
+ // missing it is corrupt and must never project as a settled task.
936
+ if (run.state !== "active" && run.terminal_proof_json === null)
937
+ throw new KernelStoreConflictError(
938
+ `task ${taskId} is ${run.state} without a committed terminal proof`,
939
+ );
940
+ const record = recordFromRun(run);
941
+ return { revision: canonicalRecordHash(record), record };
942
+ });
943
+ return read ?? null;
944
+ }
945
+
946
+ /**
947
+ * Read the immutable terminal audit pair for one task:
948
+ * `.imm/audit/<task-id>/task-record.json` plus `terminal-proof.json`.
949
+ * Both files must exist, be identity-consistent, and the proof's
950
+ * `final_record_hash` must equal the record bytes' revision. Only ENOENT on
951
+ * the whole task directory means absent; a partial pair fails closed.
952
+ * The audit record may be TaskRecord v3/v4 or historical terminal v2.
953
+ */
954
+ export function readAuditTaskPair(
955
+ root: string,
956
+ taskId: string,
957
+ runId?: string,
837
958
  ): {
838
959
  recordRevision: string;
839
- record: TaskRecord | TaskRecordV2;
960
+ record: TaskRecordV2 | TaskRecordV3 | TaskRecord;
840
961
  proof: TaskTombstone;
841
962
  } | null {
842
963
  validateTaskId(taskId);
843
- const recordPath = auditTaskRecordPath(taskId);
844
- const proofPath = auditTerminalProofPath(taskId);
964
+ let recordPath: string;
965
+ let proofPath: string;
966
+ if (runId !== undefined) {
967
+ // An exact run's own evidence, with no fallback: a run that has not
968
+ // exported yet reads as absent rather than as another run's proof.
969
+ recordPath = auditRunRecordPath(taskId, runId);
970
+ proofPath = auditRunTerminalProofPath(taskId, runId);
971
+ } else {
972
+ const resolved = auditEvidencePaths(root, taskId);
973
+ recordPath = resolved.record;
974
+ proofPath = resolved.proof;
975
+ }
845
976
  const recordRevision = currentRevision(root, recordPath);
846
977
  const proofRevision = currentRevision(root, proofPath);
847
978
  if (recordRevision === MISSING_REVISION && proofRevision === MISSING_REVISION)
@@ -853,15 +984,11 @@ export function readAuditTaskPair(
853
984
  JSON.parse(readSecureProjectFile(root, proofPath)) as Record<string, unknown>,
854
985
  );
855
986
  if (proof.task_id !== taskId)
856
- throw new KernelStoreSecurityError(
857
- "terminal audit proof identity is inconsistent",
858
- );
987
+ throw new KernelStoreSecurityError("terminal audit proof identity is inconsistent");
859
988
  if (proof.final_record_hash !== recordRevision)
860
- throw new KernelStoreSecurityError(
861
- "terminal audit proof does not match its task record",
862
- );
989
+ throw new KernelStoreSecurityError("terminal audit proof does not match its task record");
863
990
  const raw = JSON.parse(recordContent) as { contract?: unknown };
864
- let record: TaskRecord | TaskRecordV2;
991
+ let record: TaskRecordV2 | TaskRecordV3 | TaskRecord;
865
992
  if (raw.contract === "assurance_kernel/task_record/v2") {
866
993
  const legacy = parseTaskRecordV2(raw);
867
994
  if (
@@ -873,13 +1000,18 @@ export function readAuditTaskPair(
873
1000
  );
874
1001
  record = legacy;
875
1002
  } else {
876
- const current = parseTaskRecord(raw);
1003
+ let current: TaskRecordV3 | TaskRecord;
1004
+ try {
1005
+ current = parseTaskRecord(raw);
1006
+ } catch {
1007
+ current = parseTaskRecordV3(raw);
1008
+ }
877
1009
  if (
878
1010
  current.task_id !== taskId ||
879
1011
  (current.lifecycle !== "done" && current.lifecycle !== "stopped")
880
1012
  )
881
1013
  throw new KernelStoreSecurityError(
882
- "audit TaskRecord v3 must be terminal and identity-consistent",
1014
+ "audit TaskRecord must be terminal and identity-consistent",
883
1015
  );
884
1016
  record = current;
885
1017
  }
@@ -890,621 +1022,406 @@ export function readTaskRecord(
890
1022
  root: string,
891
1023
  taskId: string,
892
1024
  ): { revision: string; record: TaskRecord | null } {
893
- return withKernelStoreLock(root, () => readTaskRecordRaw(root, taskId));
1025
+ return readTaskRecordRaw(root, taskId);
894
1026
  }
895
1027
 
896
- /** Commit a TaskRecord v3 reducer result through the recoverable transaction. */
897
- export function commitTaskRecordLocked(
898
- root: string,
899
- taskId: string,
900
- expectedRecordHash: string,
901
- nextRecord: TaskRecord,
902
- expectedWorkspaceHash: string,
903
- nextWorkspace: WorkspaceState,
904
- artifactRelocations: ArtifactRelocationV1[] = [],
905
- ): StoredTaskMutationV3 {
906
- const transaction: WorkspaceTransactionV2 = {
907
- contract: "assurance_kernel/workspace_transaction/v2",
908
- task_id: taskId,
909
- expected_record_hash: expectedRecordHash,
910
- next_record_content: `${JSON.stringify(nextRecord, null, 2)}\n`,
911
- expected_workspace_hash: expectedWorkspaceHash,
912
- next_workspace_content: serializeWorkspace(nextWorkspace),
913
- ...(artifactRelocations.length > 0 ? { artifact_relocations: artifactRelocations } : {}),
914
- };
915
- atomicCasWrite(
1028
+ // ---------------------------------------------------------------------------
1029
+ // Audit export: deterministic, idempotent, retryable, never authority.
1030
+ // ---------------------------------------------------------------------------
1031
+
1032
+ function exportTerminalAudit(root: string, run: KernelRunRow): void {
1033
+ runAuditExportFault();
1034
+ if (!run.terminal_proof_json)
1035
+ throw new KernelStoreSecurityError(
1036
+ `terminal run ${run.run_id} has no committed terminal proof`,
1037
+ );
1038
+ // Keyed by run: another worktree's run of the same logical task owns its own
1039
+ // audit directory, so neither export can block the other.
1040
+ convergeFile(root, auditRunRecordPath(run.task_id, run.run_id), MISSING_REVISION, run.record_json);
1041
+ convergeFile(
916
1042
  root,
917
- TRANSACTION_PATH_V2,
918
- `${JSON.stringify(transaction, null, 2)}\n`,
1043
+ auditRunTerminalProofPath(run.task_id, run.run_id),
919
1044
  MISSING_REVISION,
1045
+ run.terminal_proof_json,
920
1046
  );
921
- try {
922
- return completeTransactionV2Locked(root, transaction, true);
923
- } catch (error) {
924
- try {
925
- return completeTransactionV2Locked(root, transaction, false);
926
- } catch (recoveryError) {
927
- throw new KernelStoreConflictError(
928
- `kernel v2 transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}; recovery: ${recoveryError instanceof Error ? recoveryError.message : recoveryError}`,
929
- );
930
- }
931
- }
932
1047
  }
933
1048
 
934
- export function withKernelStoreLock<T>(root: string, operation: () => T): T {
935
- const locksDirectory = ensureSecureDirectory(root, dirname(stateStoreLockPath()));
936
- return withExclusiveLock(resolve(locksDirectory, basename(stateStoreLockPath())), () => {
937
- assertNoRetiredV1Marker(root);
938
- recoverAnyPendingTransactionLocked(root);
939
- return operation();
940
- });
941
- }
942
-
943
- export type KernelAuthorityState =
944
- | "unowned"
945
- | "active_owner"
946
- | "terminal_owner"
947
- | "repairable_stale_claim"
948
- | "authority_conflict";
949
-
950
- export interface KernelAuthorityProjection {
951
- contract: "assurance_kernel/authority_projection/v1";
952
- requested_task_id: string;
953
- state: KernelAuthorityState;
954
- owner_task_id: string | null;
955
- owner_lifecycle: TaskLifecycle | null;
956
- claim_lifecycle_status: BackendClaim["lifecycle_status"] | null;
957
- diagnostic: string | null;
958
- revision: string;
1049
+ /** Retry any terminal settlement whose audit export did not complete. */
1050
+ function retryPendingAuditExports(root: string, db: DatabaseSync): void {
1051
+ for (const run of listPendingAuditExports(db)) {
1052
+ exportTerminalAudit(root, run);
1053
+ markAuditExported(db, run.run_id, nowIso());
1054
+ }
959
1055
  }
960
1056
 
961
1057
  /**
962
- * Recover durable Kernel transactions, then classify all ownership facts under
963
- * one lock. This is the shared fact boundary for host adapters; it never
964
- * invents terminality or removes a claim.
1058
+ * Converge document relocations that a committed record write still owes.
1059
+ * Relocation is a file move, so it runs after COMMIT and stays idempotent:
1060
+ * an interruption leaves the committed record authoritative and the move is
1061
+ * retried under the next lock.
965
1062
  */
966
- function projectKernelAuthorityLocked(
967
- root: string,
968
- taskId: string,
969
- ): KernelAuthorityProjection {
970
- try {
971
- const claim = readBackendClaim(root);
972
- const ownerTaskId = claim?.task_id ?? null;
973
- const inspectedTaskId = ownerTaskId ?? taskId;
974
- const stateRecord = readTaskRecordRaw(root, inspectedTaskId);
975
- const auditPair = readAuditTaskPair(root, inspectedTaskId);
976
- const workspace = readWorkspaceStateRaw(root).state;
977
- const record = stateRecord.record;
978
- const auditRecord = auditPair?.record;
979
- const authorityRecord = record
980
- ? {
981
- task_id: record.task_id,
982
- lifecycle: record.lifecycle,
983
- intent_revision: record.intent_snapshot.revision,
984
- intent_content_hash: record.intent_ref.content_hash,
985
- }
986
- : auditRecord
987
- ? "phase" in auditRecord
988
- ? {
989
- task_id: auditRecord.task_id,
990
- lifecycle: auditRecord.phase as "done" | "stopped",
991
- intent_revision: auditRecord.intent_revision,
992
- intent_content_hash: auditRecord.intent_ref.content_hash,
993
- }
994
- : {
995
- task_id: auditRecord.task_id,
996
- lifecycle: auditRecord.lifecycle,
997
- intent_revision: auditRecord.intent_snapshot.revision,
998
- intent_content_hash: auditRecord.intent_ref.content_hash,
999
- }
1000
- : null;
1001
- const terminal = auditPair !== null;
1002
- // review-1: a state record must never coexist with a terminal audit
1003
- // pair; only the settlement marker may hold them transiently, and a
1004
- // present marker routes to recovery/conflict before this projection.
1005
- const duplicateStateAndAudit = Boolean(stateRecord.record && auditPair);
1006
- const matchingTerminalProof = Boolean(auditPair && workspace.current_working === null);
1007
- const matchingClaimIdentity = Boolean(
1008
- claim &&
1009
- authorityRecord &&
1010
- claim.task_id === authorityRecord.task_id &&
1011
- claim.intent_revision === authorityRecord.intent_revision &&
1012
- claim.intent_content_hash === authorityRecord.intent_content_hash,
1013
- );
1014
- const sameOwner = claim ? workspace.current_working === claim.task_id : workspace.current_working === null;
1015
- const revision = revisionForContent(JSON.stringify({ claim, stateRecord, auditPair, workspace }));
1016
-
1017
- if (duplicateStateAndAudit)
1018
- return {
1019
- contract: "assurance_kernel/authority_projection/v1",
1020
- requested_task_id: taskId,
1021
- state: "authority_conflict",
1022
- owner_task_id: claim?.task_id ?? null,
1023
- owner_lifecycle: authorityRecord?.lifecycle ?? null,
1024
- claim_lifecycle_status: claim?.lifecycle_status ?? null,
1025
- diagnostic: `simultaneous state record and terminal audit pair for ${inspectedTaskId}; resolve or recover before authority interpretation`,
1026
- revision,
1027
- };
1028
- if (claim && !sameOwner && !matchingTerminalProof)
1029
- return {
1030
- contract: "assurance_kernel/authority_projection/v1",
1031
- requested_task_id: taskId,
1032
- state: "authority_conflict",
1033
- owner_task_id: claim.task_id,
1034
- owner_lifecycle: authorityRecord?.lifecycle ?? null,
1035
- claim_lifecycle_status: claim.lifecycle_status,
1036
- diagnostic: `workspace owner ${workspace.current_working ?? "null"} contradicts claim ${claim.task_id}`,
1037
- revision,
1038
- };
1039
- if (claim && !authorityRecord)
1040
- return {
1041
- contract: "assurance_kernel/authority_projection/v1",
1042
- requested_task_id: taskId,
1043
- state: "authority_conflict",
1044
- owner_task_id: claim.task_id,
1045
- owner_lifecycle: null,
1046
- claim_lifecycle_status: claim.lifecycle_status,
1047
- diagnostic: `claim ${claim.task_id} has no TaskRecord`,
1048
- revision,
1049
- };
1050
- if (claim && matchingTerminalProof && matchingClaimIdentity)
1051
- return {
1052
- contract: "assurance_kernel/authority_projection/v1",
1053
- requested_task_id: taskId,
1054
- state: "repairable_stale_claim",
1055
- owner_task_id: claim.task_id,
1056
- owner_lifecycle: authorityRecord?.lifecycle ?? null,
1057
- claim_lifecycle_status: claim.lifecycle_status,
1058
- diagnostic: null,
1059
- revision,
1060
- };
1061
- if (claim && terminal)
1062
- return {
1063
- contract: "assurance_kernel/authority_projection/v1",
1064
- requested_task_id: taskId,
1065
- state: "authority_conflict",
1066
- owner_task_id: claim.task_id,
1067
- owner_lifecycle: authorityRecord?.lifecycle ?? null,
1068
- claim_lifecycle_status: claim.lifecycle_status,
1069
- diagnostic: `claim ${claim.task_id} has contradictory terminal ownership evidence`,
1070
- revision,
1071
- };
1072
- if (claim)
1073
- return {
1074
- contract: "assurance_kernel/authority_projection/v1",
1075
- requested_task_id: taskId,
1076
- state: "active_owner",
1077
- owner_task_id: claim.task_id,
1078
- owner_lifecycle: authorityRecord?.lifecycle ?? null,
1079
- claim_lifecycle_status: claim.lifecycle_status,
1080
- diagnostic: null,
1081
- revision,
1082
- };
1083
- if (matchingTerminalProof)
1084
- return {
1085
- contract: "assurance_kernel/authority_projection/v1",
1086
- requested_task_id: taskId,
1087
- state: "terminal_owner",
1088
- owner_task_id: inspectedTaskId,
1089
- owner_lifecycle: authorityRecord?.lifecycle ?? null,
1090
- claim_lifecycle_status: null,
1091
- diagnostic: null,
1092
- revision,
1093
- };
1094
- if (authorityRecord || workspace.current_working !== null)
1095
- return {
1096
- contract: "assurance_kernel/authority_projection/v1",
1097
- requested_task_id: taskId,
1098
- state: "authority_conflict",
1099
- owner_task_id: workspace.current_working,
1100
- owner_lifecycle: authorityRecord?.lifecycle ?? null,
1101
- claim_lifecycle_status: null,
1102
- diagnostic: "nonterminal owner state exists without a backend claim",
1103
- revision,
1104
- };
1105
- return {
1106
- contract: "assurance_kernel/authority_projection/v1",
1107
- requested_task_id: taskId,
1108
- state: "unowned",
1109
- owner_task_id: null,
1110
- owner_lifecycle: null,
1111
- claim_lifecycle_status: null,
1112
- diagnostic: null,
1113
- revision,
1114
- };
1115
-
1116
- } catch (error) {
1117
- return {
1118
- contract: "assurance_kernel/authority_projection/v1",
1119
- requested_task_id: taskId,
1120
- state: "authority_conflict",
1121
- owner_task_id: null,
1122
- owner_lifecycle: null,
1123
- claim_lifecycle_status: null,
1124
- diagnostic: error instanceof Error ? error.message : String(error),
1125
- revision: "",
1126
- };
1063
+ function convergePendingRelocations(root: string, db: DatabaseSync): void {
1064
+ for (const run of listPendingRelocations(db)) {
1065
+ const relocations = JSON.parse(run.pending_relocations_json ?? "[]") as ArtifactRelocationV1[];
1066
+ for (const relocation of relocations) convergeArtifactRelocation(root, relocation);
1067
+ setPendingRelocations(db, run.run_id, null);
1127
1068
  }
1128
1069
  }
1129
1070
 
1130
- export function reconcileKernelAuthority(
1131
- root: string,
1132
- taskId: string,
1133
- ): KernelAuthorityProjection {
1134
- validateTaskId(taskId);
1135
- return withKernelStoreLock(root, () => projectKernelAuthorityLocked(root, taskId));
1136
- }
1137
-
1138
- interface AuthorityRepairMarker {
1139
- contract: "assurance_kernel/authority_repair_transaction/v1";
1140
- task_id: string;
1141
- expected_projection_revision: string;
1142
- expected_claim_content: string;
1143
- at: string;
1144
- }
1145
-
1146
- function readPendingAuthorityRepairMarker(root: string): AuthorityRepairMarker | null {
1147
- if (currentRevision(root, AUTHORITY_REPAIR_MARKER_PATH) === MISSING_REVISION) return null;
1148
- const raw = JSON.parse(
1149
- readSecureProjectFile(root, AUTHORITY_REPAIR_MARKER_PATH),
1150
- ) as Record<string, unknown>;
1151
- const allowed = [
1152
- "contract",
1153
- "task_id",
1154
- "expected_projection_revision",
1155
- "expected_claim_content",
1156
- "at",
1157
- ];
1158
- const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1159
- if (unknown.length > 0)
1160
- throw new KernelStoreSecurityError(
1161
- `authority repair marker has unknown field: ${unknown[0]}`,
1162
- );
1163
- if (raw.contract !== "assurance_kernel/authority_repair_transaction/v1")
1164
- throw new KernelStoreSecurityError("authority repair marker contract is invalid");
1165
- for (const field of ["task_id", "expected_projection_revision", "expected_claim_content", "at"]) {
1166
- if (typeof raw[field] !== "string" || !String(raw[field]).trim())
1167
- throw new KernelStoreSecurityError(`authority repair marker ${field} is invalid`);
1168
- }
1169
- const marker = raw as unknown as AuthorityRepairMarker;
1170
- validateTaskId(marker.task_id);
1171
- const claim = parseBackendClaim(JSON.parse(marker.expected_claim_content));
1172
- if (claim.task_id !== marker.task_id)
1173
- throw new KernelStoreSecurityError("authority repair claim identity is inconsistent");
1174
- return marker;
1175
- }
1071
+ // ---------------------------------------------------------------------------
1072
+ // Journal.
1073
+ // ---------------------------------------------------------------------------
1176
1074
 
1177
- function removeAuthorityRepairMarker(root: string): void {
1178
- const candidate = safeCandidate(root, AUTHORITY_REPAIR_MARKER_PATH);
1179
- assertNoSymlinkSegments(candidate.root, candidate.path);
1180
- const stat = pathStatOrNull(candidate.path);
1181
- if (!stat) return;
1182
- if (!stat.isFile())
1183
- throw new KernelStoreSecurityError("authority repair marker is not a regular file");
1184
- rmSync(candidate.path);
1185
- fsyncDirectory(dirname(candidate.path));
1186
- }
1187
-
1188
- function recoverPendingAuthorityRepairLocked(root: string): void {
1189
- const marker = readPendingAuthorityRepairMarker(root);
1190
- if (!marker) return;
1191
- const projection = projectKernelAuthorityLocked(root, marker.task_id);
1192
- const claimRevision = currentRevision(root, CLAIM_RELATIVE_PATH);
1193
- if (claimRevision === MISSING_REVISION) {
1194
- if (
1195
- projection.state !== "terminal_owner" ||
1196
- projection.owner_task_id !== marker.task_id
1197
- )
1198
- throw new KernelStoreConflictError(
1199
- "authority repair committed claim removal but terminal proof changed",
1075
+ export function appendJournalEntry(root: string, entry: JournalEntry): void {
1076
+ withKernelStoreLock(root, () => {
1077
+ withKernelTransaction(root, (db) => {
1078
+ appendJournalRow(
1079
+ db,
1080
+ JSON.stringify(entry),
1081
+ entry.task_id,
1082
+ entry.observation?.commit_id ?? null,
1083
+ entry.observation?.observation_id ?? null,
1200
1084
  );
1201
- removeAuthorityRepairMarker(root);
1202
- return;
1203
- }
1204
- if (
1205
- projection.state !== "repairable_stale_claim" ||
1206
- projection.owner_task_id !== marker.task_id ||
1207
- projection.revision !== marker.expected_projection_revision ||
1208
- claimRevision !== revisionFor(marker.expected_claim_content)
1209
- )
1210
- throw new KernelStoreConflictError(
1211
- "authority repair facts changed after confirmation",
1212
- );
1213
- const claimCandidate = safeCandidate(root, CLAIM_RELATIVE_PATH);
1214
- assertNoSymlinkSegments(claimCandidate.root, claimCandidate.path);
1215
- rmSync(claimCandidate.path);
1216
- fsyncDirectory(dirname(claimCandidate.path));
1217
- removeAuthorityRepairMarker(root);
1085
+ });
1086
+ });
1218
1087
  }
1219
1088
 
1220
- /** Remove one exactly proven stale terminal claim through a replayable marker. */
1221
- export function repairKernelAuthority(
1089
+ export function appendObservationJournalEntry(
1222
1090
  root: string,
1223
- taskId: string,
1224
- expectedProjectionRevision: string,
1225
- at = new Date().toISOString(),
1226
- ): KernelAuthorityProjection {
1227
- validateTaskId(taskId);
1228
- return withKernelStoreLock(root, () => {
1229
- const projection = projectKernelAuthorityLocked(root, taskId);
1230
- if (
1231
- projection.state !== "repairable_stale_claim" ||
1232
- projection.owner_task_id !== taskId ||
1233
- projection.revision !== expectedProjectionRevision
1234
- )
1235
- throw new KernelStoreConflictError("authority repair requires exact stale terminal proof");
1236
- const marker: AuthorityRepairMarker = {
1237
- contract: "assurance_kernel/authority_repair_transaction/v1",
1238
- task_id: taskId,
1239
- expected_projection_revision: expectedProjectionRevision,
1240
- expected_claim_content: readSecureProjectFile(root, CLAIM_RELATIVE_PATH),
1241
- at,
1242
- };
1243
- atomicCasWrite(
1244
- root,
1245
- AUTHORITY_REPAIR_MARKER_PATH,
1246
- `${JSON.stringify(marker, null, 2)}\n`,
1247
- MISSING_REVISION,
1248
- );
1249
- try {
1250
- recoverPendingAuthorityRepairLocked(root);
1251
- } catch (error) {
1091
+ entry: JournalEntry & { observation: V3AuthorityObservation },
1092
+ ): "appended" | "duplicate" {
1093
+ return withKernelTransaction(root, (db) => {
1094
+ const existing = findJournalObservation(db, entry.observation.commit_id);
1095
+ if (existing) {
1096
+ if (existing.observation_id === entry.observation.observation_id) return "duplicate";
1252
1097
  throw new KernelStoreConflictError(
1253
- `authority repair failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1098
+ `observation commit identity conflict: ${entry.observation.commit_id}`,
1254
1099
  );
1255
1100
  }
1256
- return projectKernelAuthorityLocked(root, taskId);
1101
+ appendJournalRow(
1102
+ db,
1103
+ JSON.stringify(entry),
1104
+ entry.task_id,
1105
+ entry.observation.commit_id,
1106
+ entry.observation.observation_id,
1107
+ );
1108
+ return "appended";
1257
1109
  });
1258
1110
  }
1259
1111
 
1260
- // P2B0 enrollment marker. The enrollment transaction embeds the v2
1261
- // task/workspace transaction plus the backend claim; recovery completes the
1262
- // v2 convergence then re-writes the claim if it was not yet durable.
1263
1112
  // ---------------------------------------------------------------------------
1264
-
1265
- interface EnrollmentMarker {
1266
- contract: "assurance_kernel/enrollment_transaction/v1";
1267
- task_id: string;
1268
- transaction: WorkspaceTransactionV2;
1269
- claim: Record<string, unknown>;
1270
- }
1271
-
1272
- function readPendingEnrollmentMarker(root: string): EnrollmentMarker | null {
1273
- if (currentRevision(root, ENROLLMENT_MARKER_PATH) === MISSING_REVISION) return null;
1274
- const raw = JSON.parse(
1275
- readSecureProjectFile(root, ENROLLMENT_MARKER_PATH),
1276
- ) as Record<string, unknown>;
1277
- const allowed = ["contract", "task_id", "transaction", "claim"];
1278
- const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1279
- if (unknown.length > 0)
1280
- throw new KernelStoreSecurityError(
1281
- `enrollment marker has unknown field: ${unknown[0]}`,
1282
- );
1283
- if (raw.contract !== "assurance_kernel/enrollment_transaction/v1")
1284
- throw new KernelStoreSecurityError(
1285
- "enrollment marker contract is invalid",
1286
- );
1287
- if (typeof raw.task_id !== "string" || !raw.task_id.trim())
1288
- throw new KernelStoreSecurityError(
1289
- "enrollment marker task_id is invalid",
1290
- );
1291
- const transaction = parseWorkspaceTransactionV2(
1292
- raw.transaction as Record<string, unknown>,
1293
- );
1294
- if (transaction.task_id !== raw.task_id)
1295
- throw new KernelStoreSecurityError(
1296
- "enrollment marker task identity is inconsistent",
1297
- );
1298
- return {
1299
- contract: "assurance_kernel/enrollment_transaction/v1",
1300
- task_id: raw.task_id,
1301
- transaction,
1302
- claim: raw.claim as Record<string, unknown>,
1303
- };
1304
- }
1305
-
1306
- function removeEnrollmentMarker(root: string): void {
1307
- const candidate = safeCandidate(root, ENROLLMENT_MARKER_PATH);
1308
- assertNoSymlinkSegments(candidate.root, candidate.path);
1309
- const stat = pathStatOrNull(candidate.path);
1310
- if (!stat) return;
1311
- if (!stat.isFile())
1312
- throw new KernelStoreSecurityError(
1313
- "enrollment marker is not a regular file",
1314
- );
1315
- rmSync(candidate.path);
1316
- fsyncDirectory(dirname(candidate.path));
1317
- }
1318
-
1319
- /** Recover a pending enrollment marker: complete the embedded v2 transaction, then re-write the claim. */
1320
- function recoverPendingEnrollmentLocked(root: string): void {
1321
- const marker = readPendingEnrollmentMarker(root);
1322
- if (!marker) return;
1323
- const transaction = marker.transaction;
1324
- convergeFile(
1325
- root,
1326
- stateTaskRecordPath(transaction.task_id),
1327
- transaction.expected_record_hash,
1328
- transaction.next_record_content,
1329
- );
1330
- convergeFile(
1331
- root,
1332
- stateWorkspacePath(),
1333
- transaction.expected_workspace_hash,
1334
- transaction.next_workspace_content,
1335
- );
1336
- // re-write the backend claim (last step of the enrollment transaction)
1337
- atomicCasWrite(
1338
- root,
1339
- stateClaimPath(),
1340
- `${JSON.stringify(marker.claim, null, 2)}\n`,
1341
- MISSING_REVISION,
1342
- );
1343
- removeEnrollmentMarker(root);
1344
- }
1113
+ // Locked transaction boundary.
1114
+ // ---------------------------------------------------------------------------
1345
1115
 
1346
1116
  /**
1347
- * Write an enrollment marker (embedded v2 transaction + claim) and complete it
1348
- * atomically under the store lock. Exported for the enrollment core.
1117
+ * Run one authority operation under the workspace write transaction. Retired
1118
+ * file-store authority blocks every mutation, and interrupted audit exports are
1119
+ * retried before the operation observes state.
1349
1120
  */
1350
- export function commitEnrollmentLocked(
1121
+ /**
1122
+ * Run one authority operation under the workspace write transaction for an
1123
+ * explicit task. A leftover derived claim/owner file whose task the store
1124
+ * already superseded is retired here; anything else fails closed.
1125
+ */
1126
+ export function withKernelStoreLockForTask<T>(
1351
1127
  root: string,
1352
1128
  taskId: string,
1353
- transaction: WorkspaceTransactionV2,
1354
- claim: Record<string, unknown>,
1355
- ): { record: TaskRecord; workspace: WorkspaceState } {
1356
- const marker: EnrollmentMarker = {
1357
- contract: "assurance_kernel/enrollment_transaction/v1",
1358
- task_id: taskId,
1359
- transaction,
1360
- claim,
1361
- };
1362
- atomicCasWrite(
1363
- root,
1364
- ENROLLMENT_MARKER_PATH,
1365
- `${JSON.stringify(marker, null, 2)}\n`,
1366
- MISSING_REVISION,
1367
- );
1368
- try {
1369
- recoverPendingEnrollmentLocked(root);
1370
- } catch (error) {
1129
+ operation: () => T,
1130
+ ): T {
1131
+ validateTaskId(taskId);
1132
+ return withKernelTransaction(root, (db) => {
1133
+ assertNoRetiredFileStore(root, db, taskId);
1134
+ convergePendingRelocations(root, db);
1135
+ retryPendingAuditExports(root, db);
1136
+ return operation();
1137
+ });
1138
+ }
1139
+
1140
+ /**
1141
+ * Converge committed-but-unapplied store work (pending artifact relocations and
1142
+ * terminal audit exports) before a Host reads anything the record points at.
1143
+ * Both Hosts call this at their mutation entry, so a freeze interrupted between
1144
+ * its commit and its file moves completes instead of failing the next read.
1145
+ */
1146
+ export function recoverKernelStoreFollowUps(root: string, taskId?: string): void {
1147
+ if (typeof taskId === "string" && taskId.length > 0)
1148
+ return void withKernelStoreLockForTask(root, taskId, () => undefined);
1149
+ return void withKernelStoreLock(root, () => undefined);
1150
+ }
1151
+
1152
+ export function withKernelStoreLock<T>(root: string, operation: () => T): T {
1153
+ const result = withKernelTransaction(root, (db) => {
1154
+ assertNoRetiredFileStore(root);
1155
+ return operation();
1156
+ });
1157
+ // Deterministic follow-ups run after the authority transaction committed:
1158
+ // document relocation and audit evidence can never roll back or revive an
1159
+ // already settled run, and an interruption stays retryable here.
1160
+ retryStoreFollowUps(root);
1161
+ return result;
1162
+ }
1163
+
1164
+ /** Converge owed document relocations and interrupted audit exports. */
1165
+ export function retryStoreFollowUps(root: string): void {
1166
+ withKernelTransaction(root, (db) => {
1167
+ convergePendingRelocations(root, db);
1168
+ retryPendingAuditExports(root, db);
1169
+ });
1170
+ }
1171
+
1172
+ // ---------------------------------------------------------------------------
1173
+ // CAS writes.
1174
+ // ---------------------------------------------------------------------------
1175
+
1176
+ function assertWorkspaceExpectation(db: DatabaseSync, expected: string, label: string): number {
1177
+ const row = readWorkspaceRow(db);
1178
+ const currentRevision = recordRevision(row.revision);
1179
+ if (expected === MISSING_REVISION) {
1180
+ // The store itself is created by this transaction: only the pristine
1181
+ // workspace may accept a missing expectation.
1182
+ if (row.revision !== 0)
1183
+ throw new KernelStoreConflictError(
1184
+ `CAS mismatch for ${label}: expected ${expected}, got ${currentRevision}`,
1185
+ );
1186
+ return row.revision;
1187
+ }
1188
+ if (currentRevision !== expected)
1371
1189
  throw new KernelStoreConflictError(
1372
- `enrollment transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1190
+ `CAS mismatch for ${label}: expected ${expected}, got ${currentRevision}`,
1373
1191
  );
1374
- }
1192
+ return row.revision;
1193
+ }
1194
+
1195
+ interface CommittedOperationResult {
1196
+ record_json: string;
1197
+ workspace_json: string;
1198
+ /**
1199
+ * Digest of the request this operation committed. A lost response replays
1200
+ * only when the retry carries the same request: operation, task and time are
1201
+ * not enough, because a different reason or actor at the same instant is a
1202
+ * different authorization decision. Absent on operations committed before
1203
+ * this field existed; absence never authorizes a different request.
1204
+ */
1205
+ request_digest?: string;
1206
+ /**
1207
+ * Enrollment's committed claim. Replays must return these bytes, not a
1208
+ * projection of the live run: drain or settlement can change claim_status
1209
+ * after the enrollment committed.
1210
+ */
1211
+ claim_json?: string;
1212
+ }
1213
+
1214
+ function decodeOperationResult(
1215
+ resultJson: string,
1216
+ ): { record: TaskRecord; workspace: WorkspaceState } {
1217
+ const parsed = JSON.parse(resultJson) as CommittedOperationResult;
1375
1218
  return {
1376
- record: parseTaskRecord(JSON.parse(transaction.next_record_content)),
1377
- workspace: parseWorkspaceContent(transaction.next_workspace_content),
1219
+ record: parseTaskRecord(JSON.parse(parsed.record_json) as Record<string, unknown>),
1220
+ workspace: parseWorkspaceContent(parsed.workspace_json),
1378
1221
  };
1379
1222
  }
1380
1223
 
1381
- // ---------------------------------------------------------------------------
1382
- // P2B2 drain and terminal ownership transactions. The workspace backend claim
1383
- // has exactly one write path: these recoverable markers owned by this module.
1384
- // `backend_claim.ts` exports no writer; enrollment uses the marker above.
1385
- // ---------------------------------------------------------------------------
1224
+ function requireActiveRun(db: DatabaseSync, taskId: string): KernelRunRow {
1225
+ const run = readRunRowByTask(db, taskId);
1226
+ if (!run)
1227
+ throw new KernelStoreConflictError(`task ${taskId} has no enrolled run in this worktree`);
1228
+ if (run.state !== "active")
1229
+ throw new KernelStoreConflictError(
1230
+ `task ${taskId} is ${run.state}; only an active run can be mutated`,
1231
+ );
1232
+ if (run.claim_status === null)
1233
+ throw new KernelStoreConflictError(`task ${taskId} has no workspace claim to mutate`);
1234
+ return run;
1235
+ }
1386
1236
 
1387
- interface DrainMarker {
1388
- contract: "assurance_kernel/drain_transaction/v1";
1389
- task_id: string;
1390
- expected_claim_content: string;
1391
- next_claim_content: string;
1392
- at: string;
1237
+ /**
1238
+ * Read-only store probe for commands that must not create authority state:
1239
+ * retired file-store authority fails closed, and an existing store is opened
1240
+ * and validated without writing.
1241
+ */
1242
+ export function probeKernelStore(root: string): void {
1243
+ assertNoRetiredFileStore(root);
1244
+ withKernelRead(root, () => undefined);
1393
1245
  }
1394
1246
 
1395
- interface TerminalMarker {
1396
- contract: "assurance_kernel/terminal_transaction/v2";
1397
- task_id: string;
1398
- expected_state_record_hash: string;
1399
- audit_record_content: string;
1400
- proof_content: string;
1401
- expected_workspace_hash: string;
1402
- next_workspace_content: string;
1403
- artifact_relocations?: ArtifactRelocationV1[];
1404
- expected_claim_sha256: string;
1405
- at: string;
1406
- }
1407
-
1408
- function parseArtifactRelocationsV1(raw: unknown): ArtifactRelocationV1[] {
1409
- if (raw === undefined) return [];
1410
- if (!Array.isArray(raw))
1411
- throw new KernelStoreSecurityError("artifact_relocations is invalid");
1412
- return raw.map((item, index) => {
1413
- if (!item || typeof item !== "object" || Array.isArray(item))
1414
- throw new KernelStoreSecurityError(`artifact relocation ${index} is invalid`);
1415
- const value = item as Record<string, unknown>;
1416
- const unknownFields = Object.keys(value).filter((key) => !["from_path", "to_path", "content_hash"].includes(key));
1417
- if (unknownFields.length > 0)
1418
- throw new KernelStoreSecurityError(`artifact relocation has unknown field: ${unknownFields[0]}`);
1419
- for (const field of ["from_path", "to_path", "content_hash"])
1420
- if (typeof value[field] !== "string" || !String(value[field]).trim())
1421
- throw new KernelStoreSecurityError(`artifact relocation ${field} is invalid`);
1422
- const relocation = value as unknown as ArtifactRelocationV1;
1423
- assertArtifactRelocation(relocation);
1424
- return relocation;
1247
+ /** Commit a TaskRecord result through one revision-checked transaction. */
1248
+ export function commitTaskRecordLocked(
1249
+ root: string,
1250
+ taskId: string,
1251
+ expectedRecordHash: string,
1252
+ nextRecord: TaskRecord,
1253
+ expectedWorkspaceHash: string,
1254
+ nextWorkspace: WorkspaceState,
1255
+ artifactRelocations: ArtifactRelocationV1[] = [],
1256
+ capabilityRunId?: string,
1257
+ ): StoredTaskMutationV3 {
1258
+ validateTaskId(taskId);
1259
+ const nextRecordContent = `${JSON.stringify(nextRecord, null, 2)}\n`;
1260
+ const timestamp = nowIso();
1261
+ const committed = withKernelTransaction(root, (db) => {
1262
+ assertNoRetiredFileStore(root);
1263
+ const run = requireActiveRun(db, taskId);
1264
+ const identity = runIdentity(db, run);
1265
+ assertRunBinding(identity, { task_id: taskId, run_id: run.run_id }, "task record commit");
1266
+ assertCapabilityRun(identity, capabilityRunId, "task record commit");
1267
+ const committedRecord = parseTaskRecord(nextRecord as unknown as Record<string, unknown>);
1268
+ const currentRevision = canonicalRecordHash(recordFromRun(run));
1269
+ if (currentRevision !== expectedRecordHash)
1270
+ throw new KernelStoreConflictError(
1271
+ `CAS mismatch for run ${run.run_id}: expected ${expectedRecordHash}, got ${currentRevision}`,
1272
+ );
1273
+ for (const relocation of artifactRelocations) assertArtifactRelocation(relocation);
1274
+ updateRunRecord(db, run.run_id, run.revision, nextRecordContent, timestamp);
1275
+ if (artifactRelocations.length > 0)
1276
+ setPendingRelocations(db, run.run_id, JSON.stringify(artifactRelocations));
1277
+ const workspaceRevision = assertWorkspaceExpectation(db, expectedWorkspaceHash, "workspace");
1278
+ const workspaceRevisionAfter = writeWorkspaceRow(
1279
+ db,
1280
+ workspaceRevision,
1281
+ run.run_id,
1282
+ timestamp,
1283
+ );
1284
+ return {
1285
+ revision: canonicalRecordHash(committedRecord),
1286
+ record: committedRecord,
1287
+ workspace: {
1288
+ revision: recordRevision(workspaceRevisionAfter),
1289
+ state: nextWorkspace,
1290
+ },
1291
+ };
1425
1292
  });
1293
+ return committed;
1426
1294
  }
1427
1295
 
1428
- // The workspace-active claim lives at the ignored state path (storage_paths).
1429
- const CLAIM_RELATIVE_PATH = stateClaimPath();
1430
-
1431
- function parseDrainMarker(raw: Record<string, unknown>): DrainMarker {
1432
- const allowed = [
1433
- "contract",
1434
- "task_id",
1435
- "expected_claim_content",
1436
- "next_claim_content",
1437
- "at",
1438
- ];
1439
- const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1440
- if (unknown.length > 0)
1441
- throw new KernelStoreSecurityError(
1442
- `drain marker has unknown field: ${unknown[0]}`,
1443
- );
1444
- if (raw.contract !== "assurance_kernel/drain_transaction/v1")
1445
- throw new KernelStoreSecurityError("drain marker contract is invalid");
1446
- if (typeof raw.task_id !== "string" || !raw.task_id.trim())
1447
- throw new KernelStoreSecurityError("drain marker task_id is invalid");
1448
- for (const field of ["expected_claim_content", "next_claim_content", "at"]) {
1449
- if (typeof raw[field] !== "string" || !String(raw[field]).trim())
1450
- throw new KernelStoreSecurityError(`drain marker ${field} is invalid`);
1451
- }
1452
- const marker = raw as unknown as DrainMarker;
1453
- validateTaskId(marker.task_id);
1454
- const expected = parseBackendClaim(JSON.parse(marker.expected_claim_content));
1455
- const next = parseBackendClaim(JSON.parse(marker.next_claim_content));
1456
- if (expected.task_id !== marker.task_id || next.task_id !== marker.task_id)
1457
- throw new KernelStoreSecurityError("drain marker claim identity is inconsistent");
1458
- if (expected.lifecycle_status !== "active" || next.lifecycle_status !== "draining")
1296
+ /**
1297
+ * A native capability names the exact run it was issued for. Two worktrees can
1298
+ * hold the same logical task with identical record, intent and diff content, so
1299
+ * task and content bindings alone cannot keep authority in the worktree that
1300
+ * issued it; the run identity can.
1301
+ */
1302
+ export function assertCapabilityRun(
1303
+ current: RunIdentity,
1304
+ capabilityRunId: string | undefined,
1305
+ operation: string,
1306
+ ): void {
1307
+ if (capabilityRunId === undefined) return;
1308
+ if (capabilityRunId !== current.run_id)
1459
1309
  throw new KernelStoreSecurityError(
1460
- "drain marker must transition active -> draining",
1310
+ `${operation} authority was issued for run ${capabilityRunId} but this worktree holds run ${current.run_id}`,
1461
1311
  );
1462
- return marker;
1463
1312
  }
1464
1313
 
1465
- function readPendingDrainMarker(root: string): DrainMarker | null {
1466
- if (currentRevision(root, DRAIN_MARKER_PATH) === MISSING_REVISION) return null;
1467
- const raw = JSON.parse(
1468
- readSecureProjectFile(root, DRAIN_MARKER_PATH),
1469
- ) as Record<string, unknown>;
1470
- return parseDrainMarker(raw);
1314
+ export function terminalProofRunId(proof: TaskTombstone): string | null {
1315
+ const value = (proof as unknown as { run_id?: unknown }).run_id;
1316
+ return typeof value === "string" && value.length > 0 ? value : null;
1471
1317
  }
1472
1318
 
1473
- function removeDrainMarker(root: string): void {
1474
- const candidate = safeCandidate(root, DRAIN_MARKER_PATH);
1475
- assertNoSymlinkSegments(candidate.root, candidate.path);
1476
- const stat = pathStatOrNull(candidate.path);
1477
- if (!stat) return;
1478
- if (!stat.isFile())
1479
- throw new KernelStoreSecurityError(
1480
- "drain marker is not a regular file",
1481
- );
1482
- rmSync(candidate.path);
1483
- fsyncDirectory(dirname(candidate.path));
1319
+ function claimBytesFromRun(run: KernelRunRow): string {
1320
+ return serializeBackendClaim(claimFromRunRow(run));
1484
1321
  }
1485
1322
 
1486
1323
  /**
1487
- * Converge the workspace claim to draining. Exact committed replay (claim
1488
- * already equals next content) is idempotent; a conflicting claim fails
1489
- * closed and leaves the marker recoverable.
1324
+ * Enroll one run: the TaskRecord, the workspace owner and the derived claim
1325
+ * commit together, or nothing does. A replayed call with the same enrollment
1326
+ * event returns the committed result instead of writing again.
1490
1327
  */
1491
- function recoverPendingDrainLocked(root: string): void {
1492
- const marker = readPendingDrainMarker(root);
1493
- if (!marker) return;
1494
- convergeFile(
1495
- root,
1496
- CLAIM_RELATIVE_PATH,
1497
- revisionFor(marker.expected_claim_content),
1498
- marker.next_claim_content,
1328
+ export function commitEnrollmentLocked(
1329
+ root: string,
1330
+ taskId: string,
1331
+ transaction: WorkspaceTransactionV2,
1332
+ claim: Record<string, unknown>,
1333
+ requestDigest?: string,
1334
+ ): { record: TaskRecord; workspace: WorkspaceState } {
1335
+ validateTaskId(taskId);
1336
+ if (transaction.task_id !== taskId)
1337
+ throw new KernelStoreSecurityError("enrollment transaction task identity is inconsistent");
1338
+ const parsedClaim = parseBackendClaim(claim);
1339
+ if (parsedClaim.task_id !== taskId)
1340
+ throw new KernelStoreSecurityError("enrollment claim task identity is inconsistent");
1341
+ if (parsedClaim.lifecycle_status !== "active")
1342
+ throw new KernelStoreSecurityError("enrollment claim must be active");
1343
+ const nextRecord = parseTaskRecord(
1344
+ JSON.parse(transaction.next_record_content) as Record<string, unknown>,
1499
1345
  );
1500
- removeDrainMarker(root);
1346
+ if (nextRecord.task_id !== taskId)
1347
+ throw new KernelStoreSecurityError("enrollment task record identity is inconsistent");
1348
+ const nextWorkspace = parseWorkspaceContent(transaction.next_workspace_content);
1349
+ if (nextWorkspace.current_working !== taskId)
1350
+ throw new KernelStoreSecurityError("enrollment must claim the workspace for its task");
1351
+
1352
+ const digest =
1353
+ requestDigest ??
1354
+ enrollmentRequestDigest({
1355
+ task_id: parsedClaim.task_id,
1356
+ intent_path: "",
1357
+ intent_revision: parsedClaim.intent_revision,
1358
+ intent_content_hash: parsedClaim.intent_content_hash,
1359
+ preparation_digest: "",
1360
+ enrollment_event_id: parsedClaim.enrollment_event_id,
1361
+ actor_id: "",
1362
+ confirmation_ref: "",
1363
+ nonce: "",
1364
+ });
1365
+ return withKernelTransaction(root, (db) => {
1366
+ assertNoRetiredFileStore(root, db, taskId);
1367
+ const runId = mintRunId();
1368
+ const operationId = enrollmentOperationId(taskId, parsedClaim.enrollment_event_id);
1369
+ const replay = readOperationRow(db, operationId);
1370
+ if (replay) {
1371
+ const parsed = JSON.parse(replay.result_json) as CommittedOperationResult;
1372
+ if ((parsed.request_digest ?? null) !== digest)
1373
+ throw new KernelStoreConflictError(
1374
+ `enrollment operation for ${taskId} was committed for a different request; resubmit the exact request that enrolled it`,
1375
+ );
1376
+ return decodeOperationResult(replay.result_json);
1377
+ }
1378
+ const existing = readRunRowByTask(db, taskId);
1379
+ if (existing)
1380
+ throw new KernelStoreConflictError(
1381
+ `task ${taskId} already has run ${existing.run_id} (${existing.state}); same-task re-enrollment is forbidden`,
1382
+ );
1383
+ const active = readRunRowById(db, activeRunId(db) ?? "");
1384
+ if (active)
1385
+ throw new KernelStoreConflictError(
1386
+ `workspace is already owned by ${active.task_id} (run ${active.run_id})`,
1387
+ );
1388
+ const workspaceRevision = assertWorkspaceExpectation(
1389
+ db,
1390
+ transaction.expected_workspace_hash,
1391
+ "workspace",
1392
+ );
1393
+ const run = insertRunRow(db, {
1394
+ run_id: runId,
1395
+ task_id: taskId,
1396
+ record_json: transaction.next_record_content,
1397
+ intent_revision: parsedClaim.intent_revision,
1398
+ intent_content_hash: parsedClaim.intent_content_hash,
1399
+ enrollment_event_id: parsedClaim.enrollment_event_id,
1400
+ claim_status: "active",
1401
+ created_at: parsedClaim.created_at,
1402
+ updated_at: parsedClaim.updated_at,
1403
+ });
1404
+ writeWorkspaceRow(db, workspaceRevision, run.run_id, parsedClaim.updated_at);
1405
+ insertOperationRow(db, {
1406
+ operation_id: operationId,
1407
+ kind: "enrollment",
1408
+ run_id: run.run_id,
1409
+ result_json: JSON.stringify({
1410
+ record_json: transaction.next_record_content,
1411
+ workspace_json: transaction.next_workspace_content,
1412
+ request_digest: digest,
1413
+ claim_json: JSON.stringify(parsedClaim),
1414
+ } satisfies CommittedOperationResult),
1415
+ committed_at: parsedClaim.updated_at,
1416
+ });
1417
+ return { record: nextRecord, workspace: nextWorkspace };
1418
+ });
1501
1419
  }
1502
1420
 
1503
1421
  /**
1504
1422
  * Commit the recoverable active -> draining claim transition under the store
1505
- * lock. Returns the committed draining claim. Caller must already hold the
1506
- * lock, have validated task/record/workspace ownership, and have consumed the
1507
- * user capability exactly once before the marker write.
1423
+ * transaction. The derived claim bytes are the CAS identity, so a divergent
1424
+ * claim fails closed before anything is written.
1508
1425
  */
1509
1426
  export function commitDrainLocked(
1510
1427
  root: string,
@@ -1512,233 +1429,82 @@ export function commitDrainLocked(
1512
1429
  expectedClaimContent: string,
1513
1430
  nextClaimContent: string,
1514
1431
  at: string,
1432
+ capabilityRunId?: string,
1515
1433
  ): BackendClaim {
1516
1434
  validateTaskId(taskId);
1517
- const expected = parseBackendClaim(JSON.parse(expectedClaimContent));
1518
- const next = parseBackendClaim(JSON.parse(nextClaimContent));
1435
+ const expected = parseBackendClaim(JSON.parse(expectedClaimContent) as Record<string, unknown>);
1436
+ const next = parseBackendClaim(JSON.parse(nextClaimContent) as Record<string, unknown>);
1519
1437
  if (expected.task_id !== taskId || next.task_id !== taskId)
1520
1438
  throw new KernelStoreSecurityError("drain claim identity is inconsistent");
1521
1439
  if (expected.lifecycle_status !== "active" || next.lifecycle_status !== "draining")
1522
- throw new KernelStoreSecurityError(
1523
- "drain transaction must transition active -> draining",
1524
- );
1525
- const marker: DrainMarker = {
1526
- contract: "assurance_kernel/drain_transaction/v1",
1527
- task_id: taskId,
1528
- expected_claim_content: expectedClaimContent,
1529
- next_claim_content: nextClaimContent,
1530
- at,
1531
- };
1532
- atomicCasWrite(
1533
- root,
1534
- DRAIN_MARKER_PATH,
1535
- `${JSON.stringify(marker, null, 2)}\n`,
1536
- MISSING_REVISION,
1537
- );
1538
- try {
1539
- recoverPendingDrainLocked(root);
1540
- } catch (error) {
1541
- throw new KernelStoreConflictError(
1542
- `drain transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1543
- );
1544
- }
1545
- return next;
1546
- }
1547
-
1548
- function parseTerminalMarker(raw: Record<string, unknown>): TerminalMarker {
1549
- const allowed = [
1550
- "contract",
1551
- "task_id",
1552
- "expected_state_record_hash",
1553
- "audit_record_content",
1554
- "proof_content",
1555
- "expected_workspace_hash",
1556
- "next_workspace_content",
1557
- "artifact_relocations",
1558
- "expected_claim_sha256",
1559
- "at",
1560
- ];
1561
- const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
1562
- if (unknown.length > 0)
1563
- throw new KernelStoreSecurityError(
1564
- `terminal marker has unknown field: ${unknown[0]}`,
1565
- );
1566
- if (raw.contract !== "assurance_kernel/terminal_transaction/v2")
1567
- throw new KernelStoreSecurityError("terminal marker contract is invalid");
1568
- if (typeof raw.task_id !== "string" || !raw.task_id.trim())
1569
- throw new KernelStoreSecurityError("terminal marker task_id is invalid");
1570
- validateTaskId(raw.task_id);
1571
- for (const field of ["expected_state_record_hash", "audit_record_content", "proof_content", "expected_workspace_hash", "next_workspace_content", "at"]) {
1572
- if (typeof raw[field] !== "string" || !String(raw[field]).trim())
1573
- throw new KernelStoreSecurityError(`terminal marker ${field} is invalid`);
1574
- }
1575
- const expectedClaim = raw.expected_claim_sha256;
1576
- if (typeof expectedClaim !== "string" || !/^sha256:[a-f0-9]{64}$/.test(expectedClaim))
1577
- throw new KernelStoreSecurityError("terminal marker expected_claim_sha256 is required");
1578
- const marker = {
1579
- contract: "assurance_kernel/terminal_transaction/v2" as const,
1580
- task_id: raw.task_id,
1581
- expected_state_record_hash: raw.expected_state_record_hash as string,
1582
- audit_record_content: raw.audit_record_content as string,
1583
- proof_content: raw.proof_content as string,
1584
- expected_workspace_hash: raw.expected_workspace_hash as string,
1585
- next_workspace_content: raw.next_workspace_content as string,
1586
- artifact_relocations: parseArtifactRelocationsV1(raw.artifact_relocations),
1587
- expected_claim_sha256: expectedClaim,
1588
- at: raw.at as string,
1589
- };
1590
- const record = parseTaskRecord(JSON.parse(marker.audit_record_content));
1591
- if (record.task_id !== marker.task_id)
1592
- throw new KernelStoreSecurityError("terminal marker task identity is inconsistent");
1593
- if (record.lifecycle !== "done" && record.lifecycle !== "stopped")
1594
- throw new KernelStoreSecurityError("terminal marker record must be terminal");
1595
- const proof = parseTaskTombstone(JSON.parse(marker.proof_content) as Record<string, unknown>);
1596
- if (proof.task_id !== marker.task_id)
1597
- throw new KernelStoreSecurityError("terminal marker proof identity is inconsistent");
1598
- if (proof.final_record_hash !== revisionFor(marker.audit_record_content))
1599
- throw new KernelStoreSecurityError("terminal marker proof does not match the terminal record bytes");
1600
- if (proof.terminal_lifecycle !== record.lifecycle)
1601
- throw new KernelStoreSecurityError("terminal marker proof lifecycle contradicts the terminal record");
1602
- const workspaceState = parseWorkspaceContent(marker.next_workspace_content);
1603
- if (workspaceState.current_working !== null)
1604
- throw new KernelStoreSecurityError("terminal marker requires a cleared workspace owner");
1605
- return marker;
1606
- }
1607
-
1608
- function readPendingTerminalMarker(root: string): TerminalMarker | null {
1609
- if (currentRevision(root, TERMINAL_MARKER_PATH) === MISSING_REVISION) return null;
1610
- const raw = JSON.parse(
1611
- readSecureProjectFile(root, TERMINAL_MARKER_PATH),
1612
- ) as Record<string, unknown>;
1613
- return parseTerminalMarker(raw);
1614
- }
1615
-
1616
- function removeTerminalMarker(root: string): void {
1617
- const candidate = safeCandidate(root, TERMINAL_MARKER_PATH);
1618
- assertNoSymlinkSegments(candidate.root, candidate.path);
1619
- const stat = pathStatOrNull(candidate.path);
1620
- if (!stat) return;
1621
- if (!stat.isFile())
1622
- throw new KernelStoreSecurityError(
1623
- "terminal marker is not a regular file",
1624
- );
1625
- rmSync(candidate.path);
1626
- fsyncDirectory(dirname(candidate.path));
1627
- }
1628
-
1629
- /**
1630
- * Converge terminal state: TaskRecord, workspace owner, active-claim removal,
1631
- * and task tombstone creation. Each step is idempotent on its committed
1632
- * result and fails closed on contradictory partial bytes.
1633
- */
1634
- function convergeStateRecordRemoval(root: string, taskId: string, expectedHash: string): void {
1635
- const relativePath = stateTaskRecordPath(taskId);
1636
- const actual = currentRevision(root, relativePath);
1637
- if (actual === MISSING_REVISION) return; // already removed
1638
- if (actual !== expectedHash)
1639
- throw new KernelStoreConflictError(
1640
- `state record changed during terminal settlement: expected ${expectedHash}, got ${actual}`,
1641
- );
1642
- const candidate = safeCandidate(root, relativePath);
1643
- assertNoSymlinkSegments(candidate.root, candidate.path);
1644
- const stat = pathStatOrNull(candidate.path);
1645
- if (!stat || !stat.isFile())
1646
- throw new KernelStoreSecurityError("state task record is not a regular file");
1647
- rmSync(candidate.path);
1648
- fsyncDirectory(dirname(candidate.path));
1649
- }
1650
-
1651
- /**
1652
- * Converge terminal state: create the immutable audit record/proof pair,
1653
- * clear the workspace owner, remove the active claim and the state record,
1654
- * and remove the marker. Each step is idempotent on its committed result and
1655
- * fails closed on contradictory partial bytes. The state record is removed
1656
- * only after the complete audit pair is durable.
1657
- */
1658
- function recoverPendingTerminalLocked(root: string, invokeStepHook = false): void {
1659
- const marker = readPendingTerminalMarker(root);
1660
- if (!marker) return;
1661
- for (const relocation of marker.artifact_relocations ?? [])
1662
- convergeArtifactRelocation(root, relocation);
1663
- if (invokeStepHook) runTerminalSettlementStepHook(0);
1664
- // Audit record: create-once; identical committed bytes are idempotent.
1665
- convergeFile(
1666
- root,
1667
- auditTaskRecordPath(marker.task_id),
1668
- MISSING_REVISION,
1669
- marker.audit_record_content,
1670
- );
1671
- // Terminal proof: create-once after the record is durable.
1672
- convergeFile(
1673
- root,
1674
- auditTerminalProofPath(marker.task_id),
1675
- MISSING_REVISION,
1676
- marker.proof_content,
1677
- );
1678
- if (invokeStepHook) runTerminalSettlementStepHook(1);
1679
- convergeFile(
1680
- root,
1681
- stateWorkspacePath(),
1682
- marker.expected_workspace_hash,
1683
- marker.next_workspace_content,
1684
- );
1685
- if (invokeStepHook) runTerminalSettlementStepHook(2);
1686
- // Active-claim removal: absence is the committed outcome. A present
1687
- // claim is removed only after its bytes match the marker's frozen
1688
- // expected hash; a foreign or changed claim fails closed and keeps the
1689
- // marker recoverable (review-1).
1690
- const claimCandidate = safeCandidate(root, stateClaimPath());
1691
- assertNoSymlinkSegments(claimCandidate.root, claimCandidate.path);
1692
- const claimStat = pathStatOrNull(claimCandidate.path);
1693
- if (claimStat) {
1694
- // review-2: a present claim at replay must byte-match the frozen
1695
- // claim hash; absence is the already-converged step, a foreign or
1696
- // changed claim fails closed instead of being destroyed.
1697
- if (!claimStat.isFile())
1698
- throw new KernelStoreSecurityError(
1699
- "backend claim is not a regular file",
1440
+ throw new KernelStoreSecurityError("drain transaction must transition active -> draining");
1441
+ return withKernelTransaction(root, (db) => {
1442
+ assertNoRetiredFileStore(root);
1443
+ const run = readRunRowByTask(db, taskId);
1444
+ if (!run)
1445
+ throw new KernelStoreConflictError(`task ${taskId} has no enrolled run in this worktree`);
1446
+ const operationId = drainOperationId(taskId, next.updated_at);
1447
+ const replay = readOperationRow(db, operationId);
1448
+ if (replay) {
1449
+ // A replay returns the committed claim, never the caller's request:
1450
+ // the same task and timestamp with different content is a conflicting
1451
+ // reuse of one operation identity, not a successful replay.
1452
+ const committed = parseBackendClaim(
1453
+ JSON.parse(replay.result_json) as Record<string, unknown>,
1700
1454
  );
1701
- const claimBytes = readSecureProjectFile(root, stateClaimPath());
1702
- if (revisionFor(claimBytes) !== marker.expected_claim_sha256)
1455
+ if (
1456
+ committed.task_id !== next.task_id ||
1457
+ committed.lifecycle_status !== next.lifecycle_status ||
1458
+ committed.intent_content_hash !== next.intent_content_hash ||
1459
+ committed.enrollment_event_id !== next.enrollment_event_id
1460
+ )
1461
+ throw new KernelStoreConflictError(
1462
+ `drain transaction ${operationId} was already committed with different facts`,
1463
+ );
1464
+ return committed;
1465
+ }
1466
+ const active = requireActiveRun(db, taskId);
1467
+ const identity = runIdentity(db, active);
1468
+ assertRunBinding(identity, { task_id: taskId, run_id: active.run_id }, "drain transaction");
1469
+ assertCapabilityRun(identity, capabilityRunId, "drain transaction");
1470
+ if (claimBytesFromRun(active) !== expectedClaimContent)
1703
1471
  throw new KernelStoreConflictError(
1704
- `backend claim changed during terminal settlement: expected ${marker.expected_claim_sha256}, got ${revisionFor(claimBytes)}`,
1472
+ `drain transaction claim bytes changed for ${taskId}`,
1705
1473
  );
1706
- rmSync(claimCandidate.path);
1707
- fsyncDirectory(dirname(claimCandidate.path));
1708
- }
1709
- if (invokeStepHook) runTerminalSettlementStepHook(3);
1710
- // Active state record removal: only after the audit pair is verified.
1711
- convergeStateRecordRemoval(root, marker.task_id, marker.expected_state_record_hash);
1712
- if (invokeStepHook) runTerminalSettlementStepHook(4);
1713
- terminalSettlementStepHookForTest = null;
1714
- removeTerminalMarker(root);
1474
+ updateRunClaim(db, active.run_id, "active", "draining", at);
1475
+ insertOperationRow(db, {
1476
+ operation_id: operationId,
1477
+ kind: "drain",
1478
+ run_id: active.run_id,
1479
+ result_json: JSON.stringify(next),
1480
+ committed_at: at,
1481
+ });
1482
+ return next;
1483
+ });
1715
1484
  }
1485
+
1716
1486
  /**
1717
- * Commit the recoverable terminal ownership transfer under the store lock:
1718
- * terminal TaskRecord, cleared workspace owner, removed active claim, created
1719
- * task tombstone. Caller must already hold the lock and have validated the
1720
- * exact before/after identities.
1487
+ * Commit terminal ownership transfer under the store transaction: the terminal
1488
+ * TaskRecord, the cleared workspace owner and the released claim commit
1489
+ * together. Audit evidence is exported afterwards; an interrupted export stays
1490
+ * retryable and never reactivates the run.
1721
1491
  */
1722
1492
  export function commitTerminalLocked(
1723
1493
  root: string,
1724
1494
  taskId: string,
1725
1495
  transaction: WorkspaceTransactionV2,
1726
1496
  tombstone: TaskTombstone,
1497
+ capabilityRunId?: string,
1498
+ requestDigest?: string,
1727
1499
  ): { record: TaskRecord; workspace: WorkspaceState } {
1728
1500
  validateTaskId(taskId);
1729
1501
  if (transaction.task_id !== taskId)
1730
- throw new KernelStoreSecurityError(
1731
- "terminal transaction task identity is inconsistent",
1732
- );
1502
+ throw new KernelStoreSecurityError("terminal transaction task identity is inconsistent");
1733
1503
  if (tombstone.task_id !== taskId)
1734
- throw new KernelStoreSecurityError(
1735
- "terminal tombstone task identity is inconsistent",
1736
- );
1504
+ throw new KernelStoreSecurityError("terminal tombstone task identity is inconsistent");
1737
1505
  const nextWorkspaceState = parseWorkspaceContent(transaction.next_workspace_content);
1738
1506
  if (nextWorkspaceState.current_working !== null)
1739
- throw new KernelStoreSecurityError(
1740
- "terminal settlement requires a cleared workspace owner",
1741
- );
1507
+ throw new KernelStoreSecurityError("terminal settlement requires a cleared workspace owner");
1742
1508
  if (tombstone.final_record_hash !== revisionFor(transaction.next_record_content))
1743
1509
  throw new KernelStoreSecurityError(
1744
1510
  "terminal proof must match the terminal record bytes",
@@ -1746,50 +1512,332 @@ export function commitTerminalLocked(
1746
1512
  const terminalRecord = parseTaskRecord(
1747
1513
  JSON.parse(transaction.next_record_content) as Record<string, unknown>,
1748
1514
  );
1515
+ if (terminalRecord.task_id !== taskId)
1516
+ throw new KernelStoreSecurityError("terminal record identity is inconsistent");
1749
1517
  if (tombstone.terminal_lifecycle !== terminalRecord.lifecycle)
1750
1518
  throw new KernelStoreSecurityError(
1751
1519
  "terminal proof lifecycle contradicts the terminal TaskRecord",
1752
1520
  );
1753
- const claimBytes = readSecureProjectFile(root, stateClaimPath());
1754
- const claim = parseBackendClaim(JSON.parse(claimBytes) as Record<string, unknown>);
1755
- if (claim.task_id !== taskId)
1756
- throw new KernelStoreSecurityError(
1757
- `terminal settlement claim belongs to ${claim.task_id}, not ${taskId}`,
1521
+ const proofBytes = serializeTaskTombstone(tombstone);
1522
+
1523
+ const committed = withKernelTransaction(root, (db) => {
1524
+ assertNoRetiredFileStore(root, db, taskId);
1525
+ const run = readRunRowByTask(db, taskId);
1526
+ if (!run)
1527
+ throw new KernelStoreConflictError(`task ${taskId} has no enrolled run in this worktree`);
1528
+ const operationId = terminalOperationId(taskId, tombstone.terminal_event_id);
1529
+ // A lost response replays the same event identity: the committed
1530
+ // settlement is reused before any state guard runs.
1531
+ const replay = readOperationRow(db, operationId);
1532
+ if (replay) return decodeOperationResult(replay.result_json);
1533
+ if (run.state !== "active")
1534
+ throw new KernelStoreConflictError(
1535
+ `terminal settlement refused: run ${run.run_id} is already ${run.state}`,
1536
+ );
1537
+ if (run.claim_status !== "active" && run.claim_status !== "draining")
1538
+ throw new KernelStoreSecurityError("terminal settlement claim must be active or draining");
1539
+ const identity = runIdentity(db, run);
1540
+ assertRunBinding(identity, { task_id: taskId, run_id: run.run_id }, "terminal settlement");
1541
+ assertCapabilityRun(identity, capabilityRunId, "terminal settlement");
1542
+ for (const relocation of transaction.artifact_relocations ?? [])
1543
+ assertArtifactRelocation(relocation);
1544
+ const workspaceRevision = assertWorkspaceExpectation(
1545
+ db,
1546
+ transaction.expected_workspace_hash,
1547
+ "workspace",
1758
1548
  );
1759
- if (claim.lifecycle_status !== "active" && claim.lifecycle_status !== "draining")
1760
- throw new KernelStoreSecurityError(
1761
- "terminal settlement claim must be active or draining",
1549
+ const lifecycle = terminalRecord.lifecycle === "done" ? "done" : "stopped";
1550
+ updateRunTerminal(
1551
+ db,
1552
+ run.run_id,
1553
+ lifecycle,
1554
+ transaction.next_record_content,
1555
+ proofBytes,
1556
+ tombstone.terminalized_at,
1762
1557
  );
1763
- const expectedClaimSha256 = revisionFor(claimBytes);
1764
- const marker: TerminalMarker = {
1765
- contract: "assurance_kernel/terminal_transaction/v2",
1766
- task_id: taskId,
1767
- expected_state_record_hash: transaction.expected_record_hash,
1768
- audit_record_content: transaction.next_record_content,
1769
- proof_content: serializeTaskTombstone(tombstone),
1770
- expected_workspace_hash: transaction.expected_workspace_hash,
1771
- next_workspace_content: transaction.next_workspace_content,
1772
- ...(transaction.artifact_relocations
1773
- ? { artifact_relocations: transaction.artifact_relocations }
1774
- : {}),
1775
- expected_claim_sha256: expectedClaimSha256,
1776
- at: tombstone.terminalized_at,
1558
+ writeWorkspaceRow(db, workspaceRevision, null, tombstone.terminalized_at);
1559
+ if ((transaction.artifact_relocations ?? []).length > 0)
1560
+ setPendingRelocations(db, run.run_id, JSON.stringify(transaction.artifact_relocations));
1561
+ insertOperationRow(db, {
1562
+ operation_id: operationId,
1563
+ kind: "terminal",
1564
+ run_id: run.run_id,
1565
+ result_json: JSON.stringify({
1566
+ record_json: transaction.next_record_content,
1567
+ workspace_json: transaction.next_workspace_content,
1568
+ ...(requestDigest ? { request_digest: requestDigest } : {}),
1569
+ } satisfies CommittedOperationResult),
1570
+ committed_at: tombstone.terminalized_at,
1571
+ });
1572
+ return { record: terminalRecord, workspace: nextWorkspaceState };
1573
+ });
1574
+
1575
+ // Settlement is committed. The relocation and the audit export are
1576
+ // deterministic follow-ups performed after this transaction by
1577
+ // `withKernelStoreLock` (or by the next locked operation); an interruption
1578
+ // stays retryable and can never reactivate the run.
1579
+ return committed;
1580
+ }
1581
+
1582
+ // ---------------------------------------------------------------------------
1583
+ // Authority projection (owner matrix) derived from the store.
1584
+ // ---------------------------------------------------------------------------
1585
+
1586
+ export type KernelAuthorityState =
1587
+ | "unowned"
1588
+ | "active_owner"
1589
+ | "terminal_owner"
1590
+ | "repairable_stale_claim"
1591
+ | "authority_conflict";
1592
+
1593
+ export interface KernelAuthorityProjection {
1594
+ contract: "assurance_kernel/authority_projection/v1";
1595
+ requested_task_id: string;
1596
+ state: KernelAuthorityState;
1597
+ owner_task_id: string | null;
1598
+ owner_run_id: string | null;
1599
+ owner_lifecycle: TaskLifecycle | null;
1600
+ claim_lifecycle_status: BackendClaim["lifecycle_status"] | null;
1601
+ diagnostic: string | null;
1602
+ revision: string;
1603
+ }
1604
+
1605
+ interface AuthorityFacts {
1606
+ workspace_revision: number;
1607
+ current_run_id: string | null;
1608
+ active_run_id: string | null;
1609
+ requested_run_state: TaskLifecycle | null;
1610
+ requested_run_id: string | null;
1611
+ terminal_proof_present: boolean;
1612
+ }
1613
+
1614
+ function authorityFacts(
1615
+ db: DatabaseSync,
1616
+ root: string,
1617
+ taskId: string,
1618
+ ): AuthorityFacts {
1619
+ const workspace = readWorkspaceRow(db);
1620
+ const active = readRunRowById(db, activeRunId(db) ?? "");
1621
+ const requested = readRunRowByTask(db, taskId);
1622
+ return {
1623
+ workspace_revision: workspace.revision,
1624
+ current_run_id: workspace.current_run_id,
1625
+ active_run_id: active && active.state === "active" ? active.run_id : null,
1626
+ requested_run_state: requested ? requested.state : null,
1627
+ requested_run_id: requested ? requested.run_id : null,
1628
+ // Settlement commits the terminal proof with the run. The audit export is
1629
+ // evidence with its own retryable state, so it never gates the projection.
1630
+ terminal_proof_present: requested?.terminal_proof_json !== null && requested !== null,
1777
1631
  };
1778
- atomicCasWrite(
1779
- root,
1780
- TERMINAL_MARKER_PATH,
1781
- `${JSON.stringify(marker, null, 2)}\n`,
1782
- MISSING_REVISION,
1783
- );
1632
+ }
1633
+
1634
+ function projectKernelAuthorityLocked(
1635
+ db: DatabaseSync,
1636
+ root: string,
1637
+ taskId: string,
1638
+ ): KernelAuthorityProjection {
1639
+ const projection = (fields: {
1640
+ state: KernelAuthorityState;
1641
+ owner_task_id?: string | null;
1642
+ owner_run_id?: string | null;
1643
+ owner_lifecycle?: TaskLifecycle | null;
1644
+ claim_lifecycle_status?: BackendClaim["lifecycle_status"] | null;
1645
+ diagnostic?: string | null;
1646
+ revision: string;
1647
+ }): KernelAuthorityProjection => ({
1648
+ contract: "assurance_kernel/authority_projection/v1",
1649
+ requested_task_id: taskId,
1650
+ state: fields.state,
1651
+ owner_task_id: fields.owner_task_id ?? null,
1652
+ owner_run_id: fields.owner_run_id ?? null,
1653
+ owner_lifecycle: fields.owner_lifecycle ?? null,
1654
+ claim_lifecycle_status: fields.claim_lifecycle_status ?? null,
1655
+ diagnostic: fields.diagnostic ?? null,
1656
+ revision: fields.revision,
1657
+ });
1784
1658
  try {
1785
- recoverPendingTerminalLocked(root, true);
1659
+ const facts = authorityFacts(db, root, taskId);
1660
+ const revision = revisionFor(JSON.stringify(facts));
1661
+ const active = facts.active_run_id ? readRunRowById(db, facts.active_run_id) : null;
1662
+ if (facts.current_run_id && !active)
1663
+ return projection({
1664
+ state: "authority_conflict",
1665
+ owner_task_id: facts.current_run_id,
1666
+ diagnostic: `workspace owner references run ${facts.current_run_id}, which is not active`,
1667
+ revision,
1668
+ });
1669
+ if (active) {
1670
+ if (facts.current_run_id !== active.run_id)
1671
+ return projection({
1672
+ state: "authority_conflict",
1673
+ owner_task_id: active.task_id,
1674
+ owner_run_id: active.run_id,
1675
+ diagnostic: "workspace owner contradicts the active run",
1676
+ revision,
1677
+ });
1678
+ if (active.claim_status === null)
1679
+ return projection({
1680
+ state: "authority_conflict",
1681
+ owner_task_id: active.task_id,
1682
+ owner_run_id: active.run_id,
1683
+ diagnostic: "active run carries no workspace claim",
1684
+ revision,
1685
+ });
1686
+ return projection({
1687
+ state: "active_owner",
1688
+ owner_task_id: active.task_id,
1689
+ owner_run_id: active.run_id,
1690
+ owner_lifecycle: "active",
1691
+ claim_lifecycle_status: active.claim_status,
1692
+ revision,
1693
+ });
1694
+ }
1695
+ if (facts.requested_run_state && facts.requested_run_state !== "active")
1696
+ return projection({
1697
+ state: "terminal_owner",
1698
+ owner_task_id: taskId,
1699
+ owner_run_id: facts.requested_run_id,
1700
+ owner_lifecycle: facts.requested_run_state,
1701
+ revision,
1702
+ });
1703
+ if (facts.requested_run_state)
1704
+ return projection({
1705
+ state: "authority_conflict",
1706
+ owner_task_id: taskId,
1707
+ owner_run_id: facts.requested_run_id,
1708
+ diagnostic: "nonterminal run exists without a workspace owner",
1709
+ revision,
1710
+ });
1711
+ return projection({ state: "unowned", revision });
1786
1712
  } catch (error) {
1787
- throw new KernelStoreConflictError(
1788
- `terminal transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
1789
- );
1713
+ return projection({
1714
+ state: "authority_conflict",
1715
+ diagnostic: error instanceof Error ? error.message : String(error),
1716
+ revision: "",
1717
+ });
1790
1718
  }
1719
+ }
1720
+
1721
+ function conflictProjection(taskId: string, diagnostic: string): KernelAuthorityProjection {
1791
1722
  return {
1792
- record: parseTaskRecord(JSON.parse(transaction.next_record_content)),
1793
- workspace: parseWorkspaceContent(transaction.next_workspace_content),
1723
+ contract: "assurance_kernel/authority_projection/v1",
1724
+ requested_task_id: taskId,
1725
+ state: "authority_conflict",
1726
+ owner_task_id: null,
1727
+ owner_run_id: null,
1728
+ owner_lifecycle: null,
1729
+ claim_lifecycle_status: null,
1730
+ diagnostic,
1731
+ revision: "",
1794
1732
  };
1795
1733
  }
1734
+
1735
+ export function reconcileKernelAuthority(
1736
+ root: string,
1737
+ taskId: string,
1738
+ ): KernelAuthorityProjection {
1739
+ validateTaskId(taskId);
1740
+ const projected = withKernelRead(root, (db) => {
1741
+ const conflict = retiredFileStoreConflict(root, db, taskId);
1742
+ if (conflict) return conflictProjection(taskId, conflict);
1743
+ return projectKernelAuthorityLocked(db, root, taskId);
1744
+ });
1745
+ // A store that confirms no run for this task is unowned *in this worktree*,
1746
+ // but committed audit evidence may still describe the task (a fresh clone
1747
+ // that has since recorded one unrelated mutation). Terminal evidence alone
1748
+ // classifies as terminal_owner in both states.
1749
+ if (projected && projected.state !== "unowned") return projected;
1750
+ const legacy = projected ? null : retiredFileStoreDiagnostic(root, null, taskId);
1751
+ if (legacy) return conflictProjection(taskId, legacy);
1752
+ try {
1753
+ const audit = readAuditTaskPair(root, taskId);
1754
+ if (audit)
1755
+ return {
1756
+ contract: "assurance_kernel/authority_projection/v1",
1757
+ requested_task_id: taskId,
1758
+ state: "terminal_owner",
1759
+ owner_task_id: taskId,
1760
+ owner_run_id: null,
1761
+ owner_lifecycle: null,
1762
+ claim_lifecycle_status: null,
1763
+ diagnostic: null,
1764
+ revision: audit.recordRevision,
1765
+ };
1766
+ } catch (error) {
1767
+ return {
1768
+ contract: "assurance_kernel/authority_projection/v1",
1769
+ requested_task_id: taskId,
1770
+ state: "authority_conflict",
1771
+ owner_task_id: taskId,
1772
+ owner_run_id: null,
1773
+ owner_lifecycle: null,
1774
+ claim_lifecycle_status: null,
1775
+ diagnostic: error instanceof Error ? error.message : String(error),
1776
+ revision: "",
1777
+ };
1778
+ }
1779
+ return (
1780
+ projected ?? {
1781
+ contract: "assurance_kernel/authority_projection/v1",
1782
+ requested_task_id: taskId,
1783
+ state: "unowned",
1784
+ owner_task_id: null,
1785
+ owner_run_id: null,
1786
+ owner_lifecycle: null,
1787
+ claim_lifecycle_status: null,
1788
+ diagnostic: null,
1789
+ revision: "",
1790
+ }
1791
+ );
1792
+ }
1793
+
1794
+ function retiredFileStoreDiagnostic(
1795
+ root: string,
1796
+ db: DatabaseSync | null = null,
1797
+ taskId: string | null = null,
1798
+ ): string | null {
1799
+ return retiredFileStoreConflict(root, db, taskId);
1800
+ }
1801
+
1802
+ /**
1803
+ * Remove one exactly proven stale terminal claim.
1804
+ *
1805
+ * The SQLite store derives ownership from the single active run, so a claim
1806
+ * that disagrees with the run index cannot exist: the state this operation
1807
+ * repaired is unreachable by construction. The entry point stays fail-closed
1808
+ * and is retained until the coordinated major release retires the tool.
1809
+ */
1810
+ export function repairKernelAuthority(
1811
+ root: string,
1812
+ taskId: string,
1813
+ expectedProjectionRevision: string,
1814
+ _at = nowIso(),
1815
+ ): KernelAuthorityProjection {
1816
+ validateTaskId(taskId);
1817
+ return withKernelTransaction(root, (db) => {
1818
+ assertNoRetiredFileStore(root, db, taskId);
1819
+ retireSupersededRetiredFiles(root, db, taskId);
1820
+ const projection = projectKernelAuthorityLocked(db, root, taskId);
1821
+ if (projection.state === "repairable_stale_claim") {
1822
+ if (
1823
+ projection.owner_task_id !== taskId ||
1824
+ projection.revision !== expectedProjectionRevision
1825
+ )
1826
+ throw new KernelStoreConflictError(
1827
+ "authority repair requires exact stale terminal proof",
1828
+ );
1829
+ return projection;
1830
+ }
1831
+ // The workspace owner is derived from the single active run, so a claim
1832
+ // that contradicts the run index cannot exist. This task's own retired
1833
+ // claim file is a proved duplicate and was just retired; the committed
1834
+ // authority is the answer.
1835
+ if (projection.state === "terminal_owner" || projection.state === "unowned")
1836
+ return projection;
1837
+ throw new KernelStoreConflictError(
1838
+ `authority repair is not available while authority is ${projection.state}`,
1839
+ );
1840
+ });
1841
+ }
1842
+
1843
+ export { stateDatabasePath };