immune-brain 4.5.0 → 5.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 (79) hide show
  1. package/README.md +157 -39
  2. package/README.zh-CN.md +160 -40
  3. package/package.json +1 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -45
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +189 -476
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +77 -102
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
  9. package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
  10. package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
  11. package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
  12. package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
  13. package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
  14. package/plugins/immune-brain/dist/BASELINE.md +4 -4
  15. package/plugins/immune-brain/dist/claude/mcp-server.mjs +9924 -9654
  16. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
  17. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
  18. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  19. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
  20. package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
  21. package/plugins/immune-brain/dist/imm-planner.md +60 -20
  22. package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
  23. package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
  24. package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +44 -11
  25. package/plugins/immune-brain/dist/registry.yaml +9 -9
  26. package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
  27. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
  28. package/plugins/immune-brain/runtime/assurance/coordinator.ts +138 -69
  29. package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
  30. package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
  31. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  32. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +135 -396
  35. package/plugins/immune-brain/runtime/claude/mcp_server.ts +5 -4
  36. package/plugins/immune-brain/runtime/claude/review_host.ts +63 -16
  37. package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
  40. package/plugins/immune-brain/runtime/kernel/application.ts +7 -7
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
  42. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  43. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  44. package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -5
  45. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  46. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  47. package/plugins/immune-brain/runtime/kernel/enrollment.ts +170 -17
  48. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  49. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
  51. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
  52. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  53. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  54. package/plugins/immune-brain/runtime/loop_contract.ts +22 -22
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
  58. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
  59. package/plugins/immune-brain/runtime/unattended/batch_git.ts +66 -28
  60. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  62. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +132 -45
  63. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  64. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +40 -36
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +98 -209
  66. package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
  67. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  68. package/plugins/immune-brain/runtime/workspace_scope.ts +78 -8
  69. package/plugins/immune-brain/skills/BASELINE.md +4 -4
  70. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
  71. package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
  72. package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
  73. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
  74. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
  75. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
  76. package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
  77. package/plugins/immune-brain/skills/registry.yaml +9 -9
  78. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -584
  79. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -1,7 +1,10 @@
1
- // P2B0 canary enrollment core. NOT exported from kernel/index.ts.
2
- // Atomically creates TaskRecord v3 + workspace working claim + backend claim
3
- // for one confirmed canary task. Requires a valid EnrollmentCapability.
4
- // No CLI, runtime route, or production issuer exists in P2B0.
1
+ // Canary enrollment core. NOT exported from kernel/index.ts.
2
+ // Atomically creates TaskRecord v4 + workspace working claim + backend claim
3
+ // for one confirmed canary task through a valid EnrollmentCapability.
4
+ // Production callers go through the single `enrollTask` entry (D3): it issues
5
+ // the capability, rehearses zero-write, runs the optional caller checkpoint,
6
+ // then commits. `runEnrollmentRehearsal` stays exported as a zero-write
7
+ // precheck for tests and diagnostics only.
5
8
 
6
9
  import { readRunRowByTask, withKernelRead } from "./sqlite_store";
7
10
  import { readTaskIntent } from "./intent";
@@ -15,11 +18,13 @@ import type {
15
18
  BatchAuthorizationBinding,
16
19
  } from "./batch_authority";
17
20
  import type { BackendClaim } from "./backend_claim";
21
+ import { assertBatchLineageOrigin } from "./batch_authority";
18
22
  import { preparePiCanary, readGitHead } from "./pi_canary_prepare";
19
- import { writeEnrollmentBaseline } from "../workspace_scope";
23
+ import { dirtyScopePaths, writeEnrollmentBaseline } from "../workspace_scope";
20
24
  import { enrollmentRequestDigest } from "./run_identity";
21
25
  import {
22
26
  commitEnrollmentLocked,
27
+ enrollmentEventIdFor,
23
28
  readCommittedEnrollmentResult,
24
29
  readTaskRecordRaw,
25
30
  readWorkspaceStateRaw,
@@ -52,6 +57,48 @@ export interface EnrollCanaryInput {
52
57
  now: string;
53
58
  }
54
59
 
60
+ /**
61
+ * A rehearsal that is not ready rejects through this single typed error so the
62
+ * "Kernel enrollment rehearsal failed" message is produced in exactly one place
63
+ * (deepen-authority-seams D3). Callers that decorate it — the Pi enroll Tool
64
+ * appends its empty-initial-commit note — read `message` or `blockers`.
65
+ */
66
+ export class EnrollmentRehearsalError extends Error {
67
+ readonly blockers: readonly string[];
68
+ constructor(blockers: readonly string[]) {
69
+ super(`Kernel enrollment rehearsal failed: ${blockers.join("; ")}`);
70
+ this.name = "EnrollmentRehearsalError";
71
+ this.blockers = blockers;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The one Enrollment entry's request. The entry issues the capability for this
77
+ * binding itself, so a caller supplies the binding, not a pre-issued capability.
78
+ */
79
+ export interface EnrollmentEntryRequest {
80
+ binding: EnrollmentCapabilityBinding;
81
+ /** Present only for a batch-derived child enrollment. */
82
+ batch?: EnrollBatchContext;
83
+ now: string;
84
+ /**
85
+ * Optional caller checkpoint run after a ready rehearsal and before the
86
+ * commit. Declining cancels the Enrollment with zero Kernel writes; once the
87
+ * commit has started, cancellation has no effect.
88
+ */
89
+ checkpoint?: () => boolean;
90
+ }
91
+
92
+ /** The same request with a mandatory checkpoint: the entry yields a discriminated outcome. */
93
+ export interface EnrollmentEntryRequestWithCheckpoint extends EnrollmentEntryRequest {
94
+ checkpoint: () => boolean;
95
+ }
96
+
97
+ /** The entry's outcome when a checkpoint is supplied: enrolled, or cancelled. */
98
+ export type EnrollmentEntryOutcome =
99
+ | { outcome: "enrolled"; result: EnrollCanaryResult }
100
+ | { outcome: "cancelled" };
101
+
55
102
  export interface EnrollCanaryResult {
56
103
  record: TaskRecord;
57
104
  backend_claim: BackendClaim;
@@ -163,6 +210,20 @@ function runEnrollmentPreconditionChecks<T>(
163
210
  } catch (error) {
164
211
  fail(`git base: ${error instanceof Error ? error.message : String(error)}`, error);
165
212
  }
213
+ // In-scope dirt present now would be recorded in the Enrollment baseline as
214
+ // user dirt and withheld from every later task snapshot, so the task could
215
+ // never deliver it. Refuse before the capability is consumed.
216
+ if (intent && gitBaseHead) {
217
+ try {
218
+ const dirty = dirtyScopePaths(root, intent.intent.scope_hint, input.task_id);
219
+ if (dirty.length > 0) {
220
+ const message = `task scope is already dirty before Enrollment: ${dirty.join(", ")}; commit, stash or revert these paths first`;
221
+ fail(message, new Error(message));
222
+ }
223
+ } catch (error) {
224
+ fail(`scope: ${error instanceof Error ? error.message : String(error)}`, error);
225
+ }
226
+ }
166
227
 
167
228
  const state: EnrollmentPreconditionState = {
168
229
  validated,
@@ -240,7 +301,105 @@ export function runEnrollmentRehearsal(
240
301
  }
241
302
 
242
303
  function enrollmentEventId(taskId: string, now: string): string {
243
- return `enroll-${taskId}-${now}`;
304
+ return enrollmentEventIdFor(taskId, now);
305
+ }
306
+
307
+ /**
308
+ * The single Enrollment entry (deepen-authority-seams D3): issues the
309
+ * capability for the supplied binding, runs the zero-write rehearsal, calls the
310
+ * optional caller checkpoint, and commits. A rehearsal that is not ready
311
+ * rejects with its blockers in the one shared message and writes nothing; a
312
+ * declined checkpoint cancels with zero writes; once commit has started,
313
+ * cancellation has no effect. Replay of a lost Enrollment keeps today's
314
+ * behavior because the commit step is the unchanged durable operation.
315
+ */
316
+ /**
317
+ * A request that supplies no checkpoint, or one that is `undefined`. Only such
318
+ * a request is guaranteed to return the plain result: the entry has no caller
319
+ * window in which it could cancel.
320
+ */
321
+ export interface EnrollmentEntryRequestWithoutCheckpoint extends EnrollmentEntryRequest {
322
+ checkpoint?: undefined;
323
+ }
324
+
325
+ /**
326
+ * The one Cancellation boundary. Between the readiness decision and the
327
+ * caller checkpoint the entry yields once, so a cancellation already queued by
328
+ * the host (a microtask, an already-resolved signal) is observed by the
329
+ * checkpoint instead of losing the race against the synchronous step that
330
+ * follows. Zero writes either way.
331
+ */
332
+ const CANCELLATION_BOUNDARY = Promise.resolve();
333
+
334
+ export async function enrollTask(
335
+ root: string,
336
+ registry: EnrollmentAuthorityRegistry,
337
+ request: EnrollmentEntryRequestWithCheckpoint,
338
+ ): Promise<EnrollmentEntryOutcome>;
339
+ export async function enrollTask(
340
+ root: string,
341
+ registry: EnrollmentAuthorityRegistry,
342
+ request: EnrollmentEntryRequestWithoutCheckpoint,
343
+ ): Promise<EnrollCanaryResult>;
344
+ /**
345
+ * The general interface shape, whose checkpoint is optional: the caller can
346
+ * still hold it back, so the return has to stay the full union. Without this
347
+ * overload a value typed `EnrollmentEntryRequest` matches neither of the two
348
+ * above and the call does not compile, even though the runtime answers it.
349
+ */
350
+ export async function enrollTask(
351
+ root: string,
352
+ registry: EnrollmentAuthorityRegistry,
353
+ request: EnrollmentEntryRequest,
354
+ ): Promise<EnrollCanaryResult | EnrollmentEntryOutcome>;
355
+ export async function enrollTask(
356
+ root: string,
357
+ registry: EnrollmentAuthorityRegistry,
358
+ request: EnrollmentEntryRequest,
359
+ ): Promise<EnrollCanaryResult | EnrollmentEntryOutcome> {
360
+ const { binding, batch, now, checkpoint } = request;
361
+ // (1) Issue the capability for the supplied binding.
362
+ const capability = registry.issue(binding);
363
+ const input: EnrollCanaryInput = {
364
+ task_id: binding.task_id,
365
+ intent_path: binding.intent_path,
366
+ intent_revision: binding.intent_revision,
367
+ preparation_digest: binding.preparation_digest,
368
+ capability,
369
+ capability_binding: binding,
370
+ ...(batch ? { batch } : {}),
371
+ now,
372
+ };
373
+ // Replay of a lost Enrollment: the exact request already committed here, so
374
+ // the durable operation is the only correct answer. This must precede the
375
+ // rehearsal, whose precondition checks would otherwise report the now-existing
376
+ // TaskRecord and workspace owner as blockers and make the durable replay
377
+ // unreachable. A request committed for a different binding throws, so the
378
+ // divergent-request refusal is preserved.
379
+ const { eventId, digest } = digestForEnrollment(input);
380
+ const committed = readCommittedEnrollmentResult(root, input.task_id, eventId, digest);
381
+ if (committed) {
382
+ const result: EnrollCanaryResult = {
383
+ record: committed.record,
384
+ backend_claim: committed.claim,
385
+ workspace: { revision: "", state: committed.workspace },
386
+ };
387
+ return checkpoint ? { outcome: "enrolled", result } : result;
388
+ }
389
+ // (2) Rehearse: not ready rejects with the blockers and writes nothing.
390
+ const rehearsal = runEnrollmentRehearsal(root, input, capability, registry);
391
+ if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready")
392
+ throw new EnrollmentRehearsalError(rehearsal.evidence.blockers);
393
+ // The boundary: a cancellation the host queued while the rehearsal ran is
394
+ // delivered here, before the checkpoint can start the commit.
395
+ await CANCELLATION_BOUNDARY;
396
+ // (3) Optional caller checkpoint: declining cancels before any write.
397
+ if (checkpoint && !checkpoint()) return { outcome: "cancelled" };
398
+ // (4) Commit; not cancellable from here. Without a caller checkpoint the
399
+ // entry is the plain durable operation the replay of a lost Enrollment
400
+ // already handles inside `enrollCanaryTask`.
401
+ const result = enrollCanaryTask(root, input, registry);
402
+ return checkpoint ? { outcome: "enrolled", result } : result;
244
403
  }
245
404
 
246
405
  function digestForEnrollment(input: EnrollCanaryInput): {
@@ -332,24 +491,19 @@ export function enrollCanaryTask(
332
491
 
333
492
  // A batch-derived enrollment must still stand on the lineage the
334
493
  // literal user confirmed: child N's base is the commit child N-1
335
- // produced on the batch branch, and child 1's base is base_head.
494
+ // produced on the batch branch, and child 1's base is base_head or a
495
+ // fast-forward of it the runner adopted.
336
496
  // expected_head is caller-supplied, so anchor its origin here rather
337
497
  // than trusting the caller's own assertion: before this batch has
338
498
  // consumed any slot it has created no commit, so the only lineage
339
- // value it can hold is the confirmed base_head.
499
+ // value it can hold descends from the confirmed base_head.
340
500
  if (input.batch) {
341
501
  const batch = input.batch.registry.inspect(
342
502
  input.batch.capability,
343
503
  input.batch.binding,
344
- Date.parse(input.now),
345
504
  );
346
- if (
347
- input.batch.registry.consumedChildren(input.batch.capability).length === 0 &&
348
- input.batch.expected_head !== batch.base_head
349
- )
350
- throw new Error(
351
- `batch_head_lineage_broken: the first child must enroll on the confirmed base_head ${batch.base_head}, not ${input.batch.expected_head}`,
352
- );
505
+ if (input.batch.registry.consumedChildren(input.batch.capability).length === 0)
506
+ assertBatchLineageOrigin(root, batch.base_head, input.batch.expected_head);
353
507
  if (checks.gitBaseHead !== input.batch.expected_head)
354
508
  throw new Error(
355
509
  `batch_head_lineage_broken: expected ${input.batch.expected_head}, found ${checks.gitBaseHead}`,
@@ -364,7 +518,6 @@ export function enrollCanaryTask(
364
518
  input.batch.capability,
365
519
  input.batch.binding,
366
520
  input.task_id,
367
- Date.parse(input.now),
368
521
  );
369
522
 
370
523
  // Set by beforeLock above, which throws when the repository has no
@@ -7,7 +7,7 @@ import { createCapabilityRegistry } from "./capability_registry";
7
7
  export const ENROLLMENT_CAPABILITY_BRAND = Symbol.for("assurance-kernel.enrollment-capability-brand");
8
8
 
9
9
  /**
10
- * The three fields every capability binding shares, whichever authority issues
10
+ * The two fields every capability binding shares, whichever authority issues
11
11
  * it. `nonce` is deliberately not part of this base: the enrollment and batch
12
12
  * bindings carry it for their own replay digest, while `CapabilityBindingV2`
13
13
  * uses `action_digest` instead and has no `nonce` field at all.
@@ -15,7 +15,6 @@ export const ENROLLMENT_CAPABILITY_BRAND = Symbol.for("assurance-kernel.enrollme
15
15
  export interface BaseCapabilityBinding {
16
16
  actor_id: string;
17
17
  confirmation_ref: string;
18
- expires_at: string;
19
18
  }
20
19
 
21
20
  export interface EnrollmentCapabilityBinding extends BaseCapabilityBinding {
@@ -36,7 +35,6 @@ export interface ValidatedEnrollment {
36
35
  actor_id: string;
37
36
  confirmation_ref: string;
38
37
  issued_at: string;
39
- expires_at: string;
40
38
  nonce: string;
41
39
  }
42
40
 
@@ -44,8 +42,8 @@ export interface EnrollmentAuthorityRegistry {
44
42
  readonly brand: symbol;
45
43
  /** Issue one capability bound to an exact binding. Library primitive. */
46
44
  issue(binding: EnrollmentCapabilityBinding, issuedAt?: string): object;
47
- inspect(capability: object, expected: EnrollmentCapabilityBinding, now?: number): ValidatedEnrollment;
48
- consume(capability: object, expected: EnrollmentCapabilityBinding, now?: number): ValidatedEnrollment;
45
+ inspect(capability: object, expected: EnrollmentCapabilityBinding): ValidatedEnrollment;
46
+ consume(capability: object, expected: EnrollmentCapabilityBinding): ValidatedEnrollment;
49
47
  isConsumed(capability: object): boolean;
50
48
  }
51
49
 
@@ -53,18 +51,15 @@ export function createEnrollmentAuthorityRegistry(): EnrollmentAuthorityRegistry
53
51
  return createCapabilityRegistry<EnrollmentCapabilityBinding, EnrollmentCapabilityBinding, ValidatedEnrollment>(
54
52
  ENROLLMENT_CAPABILITY_BRAND,
55
53
  {
56
- validateBinding(binding, issuedAt) {
54
+ validateBinding(binding) {
57
55
  const missing: string[] = [];
58
56
  for (const [key, value] of Object.entries(binding)) {
59
57
  if (value === undefined || value === null || value === "") missing.push(key);
60
58
  }
61
59
  if (missing.length > 0)
62
60
  throw new Error(`enrollment capability binding is incomplete: ${missing.join(", ")}`);
63
- if (Number.isNaN(Date.parse(binding.expires_at)) || Date.parse(binding.expires_at) <= Date.parse(issuedAt))
64
- throw new Error("enrollment capability must have a future expiry");
65
61
  },
66
- validateAndProject(state, expected, now) {
67
- if (Date.parse(state.expires_at) <= now) throw new Error("enrollment capability has expired");
62
+ validateAndProject(state, expected) {
68
63
  for (const key of Object.keys(expected) as Array<keyof EnrollmentCapabilityBinding>) {
69
64
  if (state[key] !== expected[key])
70
65
  throw new Error(`enrollment capability ${key} mismatch`);
@@ -78,7 +73,6 @@ export function createEnrollmentAuthorityRegistry(): EnrollmentAuthorityRegistry
78
73
  actor_id: state.actor_id,
79
74
  confirmation_ref: state.confirmation_ref,
80
75
  issued_at: state.issued_at,
81
- expires_at: state.expires_at,
82
76
  nonce: state.nonce,
83
77
  };
84
78
  },
@@ -10,7 +10,9 @@ export * from "./completion";
10
10
  export {
11
11
  readWorkspaceStateRaw,
12
12
  readTaskRecordRaw,
13
- readTaskRecord,
13
+ readSettledRunEvidence,
14
+ readSettledTaskEvidence,
15
+ enrollmentEventIdFor,
14
16
  readSecureProjectFile,
15
17
  commitTaskRecordLocked,
16
18
  withKernelStoreLock,
@@ -56,7 +56,7 @@ import {
56
56
  stateDatabasePath,
57
57
  } from "./storage_paths";
58
58
  import { canonicalRecordHash } from "./reducer";
59
- import { parseTaskRecord } from "./validation";
59
+ import { KernelInvariantError, parseTaskRecord } from "./validation";
60
60
  import { parseTaskRecordV2, parseTaskRecordV3, type TaskRecordV2 } from "./legacy_task_record";
61
61
  import {
62
62
  assertRunBinding,
@@ -101,6 +101,7 @@ import type {
101
101
  StoredTaskMutationV3,
102
102
  V3AuthorityObservation,
103
103
  } from "./types";
104
+ import { TASK_RECORD_CONTRACT_V4 } from "./types";
104
105
 
105
106
  export {
106
107
  KernelSchemaError,
@@ -898,13 +899,33 @@ export function readWorkspaceStateRaw(root: string): {
898
899
  };
899
900
  }
900
901
 
901
- function recordFromRun(run: KernelRunRow): TaskRecord {
902
- const record = parseTaskRecord(JSON.parse(run.record_json) as Record<string, unknown>);
902
+ /**
903
+ * The record a settled run carries, for a run of any historical contract.
904
+ *
905
+ * A migrated store preserves the terminal record bytes it was imported with:
906
+ * a genuine legacy v3 record is settled evidence the same way a live v4 one
907
+ * is, so the settled read parses both and validates nothing less. A record
908
+ * satisfying neither parser is unreadable and fails closed.
909
+ */
910
+ function settledRecordFromRun(run: KernelRunRow): TaskRecord | TaskRecordV3 {
911
+ let record: TaskRecord | TaskRecordV3;
912
+ try {
913
+ record = parseTaskRecord(JSON.parse(run.record_json) as Record<string, unknown>);
914
+ } catch {
915
+ record = parseTaskRecordV3(JSON.parse(run.record_json) as Record<string, unknown>);
916
+ }
903
917
  if (record.task_id !== run.task_id)
904
918
  throw new KernelStoreSecurityError("task record identity is inconsistent with its run");
905
919
  return record;
906
920
  }
907
921
 
922
+ function recordFromRun(run: KernelRunRow): TaskRecord {
923
+ const record = settledRecordFromRun(run);
924
+ if (record.contract !== TASK_RECORD_CONTRACT_V4)
925
+ throw new KernelInvariantError([`a live run must hold a v4 record, found ${record.contract}`]);
926
+ return record;
927
+ }
928
+
908
929
  export function readTaskRecordRaw(
909
930
  root: string,
910
931
  taskId: string,
@@ -948,6 +969,93 @@ export function readCommittedRecord(
948
969
  return read ?? null;
949
970
  }
950
971
 
972
+ /** The one definition of the enrollment Run-id convention: `enroll-<task>-<created_at>`. */
973
+ export function enrollmentEventIdFor(taskId: string, createdAt: string): string {
974
+ validateTaskId(taskId);
975
+ return `enroll-${taskId}-${createdAt}`;
976
+ }
977
+
978
+ /**
979
+ * The Authority Store's answer to "the settled Run of this Task": this
980
+ * worktree's run row, its identity-validated record, its proof, whether its
981
+ * audit pair is exported, and the raw bytes for byte-exact comparison. Run
982
+ * resolution and proof matching happen behind this read, so a Task with
983
+ * several Runs never yields another Run's evidence, and an active Task or a
984
+ * Task with no Run reads as null.
985
+ */
986
+ export interface SettledRunEvidence {
987
+ contract: "assurance_kernel/settled_run_evidence/v1";
988
+ task_id: string;
989
+ run_id: string;
990
+ created_at: string;
991
+ enrollment_event_id: string;
992
+ lifecycle: "done" | "stopped";
993
+ /** The settled record, which may be a migrated historical v3. */
994
+ record: TaskRecord | TaskRecordV3;
995
+ /** canonicalRecordHash(record): the record's canonical CAS identity. */
996
+ record_revision: string;
997
+ /** revisionForContent(record_json): the identity the proof binds. */
998
+ record_revision_raw: string;
999
+ /** The store's raw record bytes; equal to the exported audit record. */
1000
+ record_json: string;
1001
+ proof: TaskTombstone;
1002
+ /** The store's raw proof bytes; equal to the exported audit proof. */
1003
+ proof_json: string;
1004
+ audit_exported: boolean;
1005
+ }
1006
+
1007
+ export function readSettledRunEvidence(root: string, taskId: string): SettledRunEvidence | null {
1008
+ validateTaskId(taskId);
1009
+ const read = withKernelRead(root, (db) => {
1010
+ const run = readRunRowByTask(db, taskId);
1011
+ if (!run || run.state === "active") return null;
1012
+ // A settled run always carries the proof its settlement committed; a
1013
+ // store missing it is corrupt and must never project as a settled task.
1014
+ if (run.terminal_proof_json === null)
1015
+ throw new KernelStoreSecurityError(
1016
+ `task ${taskId} is ${run.state} without a committed terminal proof`,
1017
+ );
1018
+ const record = settledRecordFromRun(run);
1019
+ const record_revision = canonicalRecordHash(record as TaskRecord);
1020
+ // The proof binds the raw committed bytes, not the canonical
1021
+ // re-serialization: a store migrated from the prior runtime keeps
1022
+ // historical record bytes whose serialization need not be canonical.
1023
+ const record_revision_raw = revisionForContent(run.record_json);
1024
+ const proof = parseTaskTombstone(
1025
+ JSON.parse(run.terminal_proof_json) as Record<string, unknown>,
1026
+ );
1027
+ if (proof.task_id !== taskId)
1028
+ throw new KernelStoreSecurityError("settled run proof identity is inconsistent");
1029
+ if (proof.final_record_hash !== record_revision_raw)
1030
+ throw new KernelStoreSecurityError("settled run proof does not match its task record");
1031
+ // Three independent facts of one settlement must agree before the
1032
+ // read answers: the run row's state, the record's own lifecycle, and
1033
+ // the proof's terminal lifecycle. A store that disagrees with itself
1034
+ // is corrupt evidence and must fail closed rather than project a
1035
+ // settled task whose record is still live.
1036
+ if (record.lifecycle !== run.state || proof.terminal_lifecycle !== run.state)
1037
+ throw new KernelStoreSecurityError(
1038
+ `settled run facts disagree: run state ${run.state}, record lifecycle ${record.lifecycle}, proof terminal ${proof.terminal_lifecycle}`,
1039
+ );
1040
+ return {
1041
+ contract: "assurance_kernel/settled_run_evidence/v1" as const,
1042
+ task_id: taskId,
1043
+ run_id: run.run_id,
1044
+ created_at: run.created_at,
1045
+ enrollment_event_id: run.enrollment_event_id,
1046
+ lifecycle: run.state as "done" | "stopped",
1047
+ record,
1048
+ record_revision,
1049
+ record_revision_raw,
1050
+ record_json: run.record_json,
1051
+ proof,
1052
+ proof_json: run.terminal_proof_json,
1053
+ audit_exported: run.audit_exported_at !== null,
1054
+ };
1055
+ });
1056
+ return read ?? null;
1057
+ }
1058
+
951
1059
  /**
952
1060
  * Read the immutable terminal audit pair for one task:
953
1061
  * `.imm/audit/<task-id>/task-record.json` plus `terminal-proof.json`.
@@ -1023,13 +1131,6 @@ export function readAuditTaskPair(
1023
1131
  return { recordRevision, record, proof };
1024
1132
  }
1025
1133
 
1026
- export function readTaskRecord(
1027
- root: string,
1028
- taskId: string,
1029
- ): { revision: string; record: TaskRecord | null } {
1030
- return readTaskRecordRaw(root, taskId);
1031
- }
1032
-
1033
1134
  // ---------------------------------------------------------------------------
1034
1135
  // Audit export: deterministic, idempotent, retryable, never authority.
1035
1136
  // ---------------------------------------------------------------------------
@@ -1073,6 +1174,60 @@ function convergePendingRelocations(root: string, db: DatabaseSync): void {
1073
1174
  }
1074
1175
  }
1075
1176
 
1177
+ /** Settled task evidence: the terminal record and proof of a task's own run. */
1178
+ export interface SettledTaskEvidence {
1179
+ /** The run that owns this evidence; `null` for the legacy flat layout. */
1180
+ runId: string | null;
1181
+ record: TaskRecordV2 | TaskRecordV3 | TaskRecord;
1182
+ recordRevision: string;
1183
+ proof: TaskTombstone;
1184
+ }
1185
+
1186
+ /**
1187
+ * The settled evidence of a task, read run-aware.
1188
+ *
1189
+ * One read decides which of three worlds this worktree is in, because a
1190
+ * decision made from two reads can be stale between them:
1191
+ *
1192
+ * - **absent** (no Kernel store, or no row for the task): a pre-store terminal
1193
+ * or a repository fixture, whose single flat audit pair is this worktree's
1194
+ * evidence. This is the only world where the flat layout is read at all.
1195
+ * - **active**: the task's run is in flight, so it has no settled evidence; an
1196
+ * exported or fetched pair of the same task is another run's and never
1197
+ * answers while this worktree's own run is live.
1198
+ * - **terminal**: the task's own terminal run, read run-scoped; until its
1199
+ * export exists the evidence is unreadable (fail closed), never another run's.
1200
+ *
1201
+ * A settlement committed between two reads cannot turn the terminal world into
1202
+ * the absent one, because the world is decided before any pair is resolved.
1203
+ */
1204
+ export function readSettledTaskEvidence(root: string, taskId: string): SettledTaskEvidence | null {
1205
+ validateTaskId(taskId);
1206
+ const localRun = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
1207
+ if (!localRun) {
1208
+ const legacy = readAuditTaskPair(root, taskId);
1209
+ if (!legacy) return null;
1210
+ return {
1211
+ runId: null,
1212
+ record: legacy.record as SettledTaskEvidence["record"],
1213
+ recordRevision: legacy.recordRevision,
1214
+ proof: legacy.proof,
1215
+ };
1216
+ }
1217
+ if (localRun.state === "active") return null;
1218
+ const settled = readSettledRunEvidence(root, taskId);
1219
+ if (!settled) return null;
1220
+ return {
1221
+ runId: settled.run_id,
1222
+ record: settled.record,
1223
+ // The pair identity is the raw committed bytes the proof binds; the
1224
+ // canonical revision stays available on the run evidence for callers
1225
+ // that need the TaskRecord's canonical CAS identity.
1226
+ recordRevision: settled.record_revision_raw,
1227
+ proof: settled.proof,
1228
+ };
1229
+ }
1230
+
1076
1231
  // ---------------------------------------------------------------------------
1077
1232
  // Journal.
1078
1233
  // ---------------------------------------------------------------------------
@@ -1755,7 +1910,11 @@ export function reconcileKernelAuthority(
1755
1910
  const legacy = projected ? null : retiredFileStoreDiagnostic(root, null, taskId);
1756
1911
  if (legacy) return conflictProjection(taskId, legacy);
1757
1912
  try {
1758
- const audit = readAuditTaskPair(root, taskId);
1913
+ // Run-aware: when this worktree's store carries the task's run, only that
1914
+ // run's own evidence may classify the task; the resolved/flat fallback
1915
+ // below serves only pre-run-layout evidence of a task with no local run.
1916
+ const localRun = localRunId(root, taskId);
1917
+ const audit = readAuditTaskPair(root, taskId, localRun ?? undefined);
1759
1918
  if (audit)
1760
1919
  return {
1761
1920
  contract: "assurance_kernel/authority_projection/v1",
@@ -212,6 +212,19 @@ export function auditTerminalProofPath(taskId: string): string {
212
212
  return `${auditTaskDirPath(taskId)}/terminal-proof.json`;
213
213
  }
214
214
 
215
+ /** Unattended batch run state: `.imm/state/batches/<batch-id>.json`. */
216
+ export function batchStatePath(batchId: string): string {
217
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(batchId))
218
+ throw new Error(`invalid batch identity: ${batchId}`);
219
+ return `${BATCH_STATE_RELATIVE}/${batchId}.json`;
220
+ }
221
+
222
+ /** Durable batch child commit evidence: `.imm/state/batches/commits/<batch-id>-<task-id>.json`. */
223
+ export function batchCommitEvidencePath(batchId: string, taskId: string): string {
224
+ validateTaskId(taskId);
225
+ return `${BATCH_STATE_RELATIVE}/commits/${batchId}-${taskId}.json`;
226
+ }
227
+
215
228
  /** Historical v3 machine evidence root under the tracked audit store. */
216
229
  export function legacyV3Path(...segments: string[]): string {
217
230
  for (const segment of segments)
@@ -265,7 +265,8 @@ export interface AuthorityAuditDescriptor {
265
265
  actor_id: string;
266
266
  confirmation_ref: string;
267
267
  issued_at: string;
268
- expires_at: string;
268
+ /** Read-only: history written before authority lost its clock still carries it. Never written. */
269
+ expires_at?: string;
269
270
  }
270
271
 
271
272
  export interface TaskActionBase {
@@ -287,7 +287,9 @@ function parseHistoryV3(
287
287
  actor_id: stringAt(auth.actor_id, `${path}.authority.actor_id`, violations),
288
288
  confirmation_ref: stringAt(auth.confirmation_ref, `${path}.authority.confirmation_ref`, violations),
289
289
  issued_at: stringAt(auth.issued_at, `${path}.authority.issued_at`, violations),
290
- expires_at: stringAt(auth.expires_at, `${path}.authority.expires_at`, violations),
290
+ ...(auth.expires_at !== undefined
291
+ ? { expires_at: stringAt(auth.expires_at, `${path}.authority.expires_at`, violations) }
292
+ : {}),
291
293
  };
292
294
  }
293
295
  return {