immune-brain 3.6.3 → 3.6.5

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 (31) hide show
  1. package/package.json +6 -3
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +117 -47
  4. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +6 -4
  5. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -44
  6. package/plugins/immune-brain/dist/claude/mcp-server.mjs +213 -81
  7. package/plugins/immune-brain/dist/imm-loop.md +8 -2
  8. package/plugins/immune-brain/dist/imm-planner.md +9 -4
  9. package/plugins/immune-brain/runtime/assurance/coordinator.ts +35 -2
  10. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +23 -13
  11. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +133 -45
  12. package/plugins/immune-brain/runtime/claude/mcp_server.ts +14 -1
  13. package/plugins/immune-brain/runtime/claude/review_host.ts +0 -10
  14. package/plugins/immune-brain/runtime/commands/kernel.ts +15 -13
  15. package/plugins/immune-brain/runtime/github_issue_tracker.ts +107 -11
  16. package/plugins/immune-brain/runtime/kernel/application.ts +6 -0
  17. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  18. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +407 -0
  19. package/plugins/immune-brain/runtime/kernel/canary_application.ts +17 -8
  20. package/plugins/immune-brain/runtime/kernel/enrollment.ts +76 -13
  21. package/plugins/immune-brain/runtime/kernel/index.ts +2 -0
  22. package/plugins/immune-brain/runtime/kernel/intent.ts +67 -23
  23. package/plugins/immune-brain/runtime/kernel/observation.ts +2 -0
  24. package/plugins/immune-brain/runtime/kernel/reducer.ts +34 -8
  25. package/plugins/immune-brain/runtime/kernel/storage.ts +17 -2
  26. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +1 -1
  27. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +0 -4
  28. package/plugins/immune-brain/runtime/kernel/types.ts +10 -0
  29. package/plugins/immune-brain/runtime/kernel/validation.ts +10 -6
  30. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +2 -2
  31. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
@@ -57,6 +57,7 @@ export interface InitiativePublicationInput {
57
57
  tasks: Array<{
58
58
  slice_id: string;
59
59
  intent: string;
60
+ acceptance: Array<{ id: string; summary: string }>;
60
61
  projection?: TaskProjection;
61
62
  }>;
62
63
  }
@@ -85,6 +86,18 @@ export interface GithubInitiativePublicationResult {
85
86
  message: string;
86
87
  }
87
88
 
89
+ export interface GithubInitiativeObservation {
90
+ contract: "immune_brain/github_initiative_observation/v1";
91
+ initiative_id: string;
92
+ issue_number: number;
93
+ tasks: Array<{
94
+ task_id: string;
95
+ slice_id: string;
96
+ issue_number: number;
97
+ blocked_by: string[];
98
+ }>;
99
+ }
100
+
88
101
  export interface TaskProjection {
89
102
  result?: string;
90
103
  current_behavior?: string;
@@ -275,8 +288,8 @@ export function createGhTransport(binary = "gh"): GhTransport {
275
288
  return {
276
289
  run(args, options = {}) {
277
290
  return new Promise((complete) => {
278
- let stdout = Buffer.alloc(0);
279
- let stderr = Buffer.alloc(0);
291
+ let stdout: Buffer = Buffer.alloc(0);
292
+ let stderr: Buffer = Buffer.alloc(0);
280
293
  let timedOut = false;
281
294
  let outputExceeded = false;
282
295
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -304,7 +317,7 @@ export function createGhTransport(binary = "gh"): GhTransport {
304
317
  finish(1, error instanceof Error ? error.message : String(error));
305
318
  return;
306
319
  }
307
- const append = (current: Buffer, chunk: Buffer): Buffer => {
320
+ const append = (current: Buffer, chunk: Uint8Array): Buffer => {
308
321
  const available = Math.max(0, MAX_GH_OUTPUT - stdout.length - stderr.length);
309
322
  if (chunk.length > available) {
310
323
  outputExceeded = true;
@@ -312,17 +325,22 @@ export function createGhTransport(binary = "gh"): GhTransport {
312
325
  }
313
326
  return available > 0 ? Buffer.concat([current, chunk.subarray(0, available)]) : current;
314
327
  };
315
- child.stdout.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
316
- child.stderr.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
328
+ const { stdout: childOut, stderr: childErr, stdin: childIn } = child;
329
+ if (!childOut || !childErr || !childIn) {
330
+ finish(1, "gh was spawned without the stdio pipes this reader requires");
331
+ return;
332
+ }
333
+ childOut.on("data", (chunk: Uint8Array) => { stdout = append(stdout, chunk); });
334
+ childErr.on("data", (chunk: Uint8Array) => { stderr = append(stderr, chunk); });
317
335
  child.once("error", (error) => { finish(1, error.message); });
318
- child.stdin.once("error", (error) => { finish(1, error.message); });
336
+ childIn.once("error", (error) => { finish(1, error.message); });
319
337
  timer = setTimeout(() => {
320
338
  timedOut = true;
321
339
  child.kill("SIGKILL");
322
340
  }, GH_TIMEOUT_MS);
323
341
  child.once("close", (code) => { finish(code ?? 1); });
324
342
  try {
325
- child.stdin.end(options.stdin ?? "");
343
+ childIn.end(options.stdin ?? "");
326
344
  } catch (error) {
327
345
  finish(1, error instanceof Error ? error.message : String(error));
328
346
  }
@@ -371,7 +389,7 @@ function parseSubIssueNumbers(raw: string): number[] {
371
389
  if (!Array.isArray(pages)) throw new Error("gh returned malformed Sub-issue list");
372
390
  return pages.map((item, index) => {
373
391
  const number = (item as { number?: unknown })?.number;
374
- if (!Number.isSafeInteger(number)) throw new Error(`gh returned a malformed Sub-issue entry at ${index}`);
392
+ if (typeof number !== "number" || !Number.isSafeInteger(number)) throw new Error(`gh returned a malformed Sub-issue entry at ${index}`);
375
393
  return number;
376
394
  });
377
395
  }
@@ -547,6 +565,63 @@ async function readBlockedByIds(
547
565
  }
548
566
  }
549
567
 
568
+ export async function observeGithubInitiative(
569
+ root: string,
570
+ initiativeId: string,
571
+ gh: GhTransport = createGhTransport(),
572
+ ): Promise<GithubInitiativeObservation> {
573
+ const id = identifier(initiativeId, "initiative_id");
574
+ const source = await snapshot(resolve(root), gh, "create-initiative");
575
+ if ("contract" in source) throw new Error(source.message);
576
+ const parent = initiativeLookup(source.issues, source.repository.id, id);
577
+ if (parent.kind === "missing") throw new Error(`Initiative ${id} is not published`);
578
+ if (parent.kind === "ambiguous") throw new Error(parent.message);
579
+ const subIssueNumbers = await readSubIssueNumbers(root, gh, "create-initiative", source.repository, parent.issue.number);
580
+ if (!Array.isArray(subIssueNumbers)) throw new Error(subIssueNumbers.message);
581
+ if (new Set(subIssueNumbers).size !== subIssueNumbers.length)
582
+ throw new Error(`Initiative ${id} has duplicate native Sub-issue relations`);
583
+ const tasks = subIssueNumbers.map((issueNumber) => {
584
+ const matches = source.issues.filter((issue) => issue.number === issueNumber);
585
+ if (matches.length !== 1) throw new Error(`Initiative ${id} references an unreadable Sub-issue #${issueNumber}`);
586
+ const issue = matches[0];
587
+ const taskId = ownershipMarkerValue(issue.body, "task-id");
588
+ const sliceId = ownershipMarkerValue(issue.body, "slice-id");
589
+ if (!taskId || !sliceId || ownershipMarkerValue(issue.body, "initiative-id") !== id)
590
+ throw new Error(`Sub-issue #${issueNumber} has invalid Initiative ownership markers`);
591
+ const owned = ownedTaskLookup(source.issues, source.repository.id, taskId, id, sliceId);
592
+ if (owned.kind !== "found" || owned.issue.number !== issueNumber)
593
+ throw new Error(owned.kind === "ambiguous" ? owned.message : `Sub-issue #${issueNumber} has invalid Task ownership`);
594
+ return { task_id: taskId, slice_id: sliceId, issue_number: issueNumber, issue_id: issue.id };
595
+ });
596
+ if (new Set(tasks.map((task) => task.task_id)).size !== tasks.length)
597
+ throw new Error(`Initiative ${id} has duplicate Task identities`);
598
+ if (new Set(tasks.map((task) => task.slice_id)).size !== tasks.length)
599
+ throw new Error(`Initiative ${id} has duplicate Slice identities`);
600
+ const taskByIssueId = new Map(tasks.map((task) => [task.issue_id, task.task_id]));
601
+ const observed: GithubInitiativeObservation["tasks"] = [];
602
+ for (const task of tasks.sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0)) {
603
+ const blockerIds = await readBlockedByIds(root, gh, "create-initiative", source.repository, task.issue_number);
604
+ if (!Array.isArray(blockerIds)) throw new Error(blockerIds.message);
605
+ const blockedBy = blockerIds.map((blockerId) => {
606
+ const blocker = taskByIssueId.get(blockerId);
607
+ if (!blocker) throw new Error(`Task ${task.task_id} depends on an Issue outside Initiative ${id}`);
608
+ return blocker;
609
+ }).sort();
610
+ observed.push({
611
+ task_id: task.task_id,
612
+ slice_id: task.slice_id,
613
+ issue_number: task.issue_number,
614
+ blocked_by: blockedBy,
615
+ });
616
+ }
617
+ return {
618
+ contract: "immune_brain/github_initiative_observation/v1",
619
+ initiative_id: id,
620
+ issue_number: parent.issue.number,
621
+ tasks: observed,
622
+ };
623
+ }
624
+
550
625
  async function confirmBlockedBy(
551
626
  root: string,
552
627
  gh: GhTransport,
@@ -1065,7 +1140,7 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
1065
1140
  const publications = input.tasks.map((task, index) => {
1066
1141
  if (!task || typeof task !== "object" || Array.isArray(task)) throw new Error(`tasks[${index}] must be an object`);
1067
1142
  if (typeof task.intent !== "string") throw new Error(`tasks[${index}].intent must be a string`);
1068
- return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.projection);
1143
+ return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.acceptance, task.projection);
1069
1144
  });
1070
1145
  const operations = publications.map((publication) => publication.operation);
1071
1146
  const taskIds = new Set<string>();
@@ -1259,7 +1334,14 @@ function isSuccessfulTrackerStatus(status: TrackerStatus): boolean {
1259
1334
  return status === "created" || status === "updated" || status === "already_current";
1260
1335
  }
1261
1336
 
1262
- function taskPublication(root: string, initiativeId: string, sliceId: string, intentPath: string, projection?: TaskProjection): PreparedPublicationTask {
1337
+ function taskPublication(
1338
+ root: string,
1339
+ initiativeId: string,
1340
+ sliceId: string,
1341
+ intentPath: string,
1342
+ acceptance: unknown,
1343
+ projection?: TaskProjection,
1344
+ ): PreparedPublicationTask {
1263
1345
  const absoluteRoot = resolve(root);
1264
1346
  const absolutePath = resolve(absoluteRoot, intentPath);
1265
1347
  const rel = relative(absoluteRoot, absolutePath);
@@ -1270,6 +1352,20 @@ function taskPublication(root: string, initiativeId: string, sliceId: string, in
1270
1352
  const read = readTaskIntent(absoluteRoot, taskId);
1271
1353
  if (read.intent_ref.path !== rel) throw new Error("TaskIntent path must match its canonical sidecar path");
1272
1354
  const intent = read.intent;
1355
+ if (!Array.isArray(acceptance)) throw new Error(`Task ${taskId} requires public acceptance summaries`);
1356
+ const expectedIds = new Set(intent.acceptance.map((item) => item.id));
1357
+ const publicById = new Map<string, { id: string; summary: string }>();
1358
+ acceptance.forEach((item, index) => {
1359
+ if (!item || typeof item !== "object" || Array.isArray(item))
1360
+ throw new Error(`Task ${taskId} acceptance[${index}] must be an object`);
1361
+ const raw = item as Record<string, unknown>;
1362
+ const id = identifier(raw.id, `Task ${taskId} acceptance[${index}].id`);
1363
+ if (!expectedIds.has(id)) throw new Error(`Task ${taskId} has unknown public acceptance id: ${id}`);
1364
+ if (publicById.has(id)) throw new Error(`Task ${taskId} has duplicate public acceptance id: ${id}`);
1365
+ publicById.set(id, { id, summary: projectionText(raw.summary, `Task ${taskId} acceptance[${index}].summary`, 500) });
1366
+ });
1367
+ const missingIds = [...expectedIds].filter((id) => !publicById.has(id));
1368
+ if (missingIds.length) throw new Error(`Task ${taskId} is missing public acceptance ids: ${missingIds.join(", ")}`);
1273
1369
  return {
1274
1370
  operation: validateOperation({
1275
1371
  op: "upsert-task",
@@ -1278,7 +1374,7 @@ function taskPublication(root: string, initiativeId: string, sliceId: string, in
1278
1374
  slice_id: sliceId,
1279
1375
  goal: intent.goal,
1280
1376
  risk: intent.risk,
1281
- acceptance: intent.acceptance.map((item) => ({ id: item.id, summary: item.assertion })),
1377
+ acceptance: intent.acceptance.map((item) => publicById.get(item.id)!),
1282
1378
  projection,
1283
1379
  }) as Extract<TrackerOperation, { op: "upsert-task" }>,
1284
1380
  intent_path: read.intent_ref.path,
@@ -160,6 +160,7 @@ export function applyTaskAction(
160
160
  action.type === "record_approval" ||
161
161
  action.type === "approve_breaking_intent_revision" ||
162
162
  action.type === "request_rework" ||
163
+ action.type === "authorize_rework" ||
163
164
  action.type === "stop" ||
164
165
  action.type === "resolve_user_decision";
165
166
 
@@ -260,6 +261,11 @@ export function applyTaskAction(
260
261
  }
261
262
 
262
263
  if (input.terminal) {
264
+ // A tombstone may only record a terminal lifecycle. Nothing upstream
265
+ // proved that, so an active record could have been tombstoned as
266
+ // `terminal_lifecycle: "active"` in violation of its own contract.
267
+ if (nextRecord.lifecycle === "active")
268
+ throw new Error("terminal settlement requires a done or stopped TaskRecord lifecycle");
263
269
  const tombstone: TaskTombstone = {
264
270
  contract: TASK_TOMBSTONE_CONTRACT,
265
271
  task_id,
@@ -25,9 +25,10 @@ export interface AssuranceAuthorizationReadiness {
25
25
  /**
26
26
  * Kernel-decidable authorization readiness:
27
27
  * - "resolve_user_decision": exactly one open unresolved-user-decision finding;
28
+ * - "authorize_rework": an open replan boundary can be overridden by the user;
28
29
  * - "none": nothing uniquely decidable from Kernel facts.
29
30
  */
30
- state: "resolve_user_decision" | "none";
31
+ state: "resolve_user_decision" | "authorize_rework" | "none";
31
32
  /** Non-null only when Kernel facts prove the authorization is blocked. */
32
33
  blocked: string | null;
33
34
  }
@@ -75,6 +76,8 @@ export function deriveAssuranceAuthorization(input: {
75
76
  state: "none",
76
77
  blocked: `resolve-user-decision requires exactly one open user decision; found ${input.open_user_decision_count}`,
77
78
  };
79
+ if (input.next_obligation === "revise_intent")
80
+ return { state: "authorize_rework", blocked: null };
78
81
  return { state: "none", blocked: null };
79
82
  }
80
83
 
@@ -0,0 +1,407 @@
1
+ // Batch Authorization for unattended Initiative batch runs. NOT exported from
2
+ // kernel/index.ts. One literal-user confirmation binds an ordered child plan;
3
+ // each child consumes exactly one slot and derives its own Enrollment
4
+ // capability from a freshly recomputed preparation at enroll time.
5
+ //
6
+ // The confirmation cannot bind a preparation digest: enrollment rejects a moved
7
+ // Git HEAD, and every settled child moves it. The batch binds `plan_digest`
8
+ // plus an advancing HEAD lineage instead.
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { createCapabilityRegistry } from "./capability_registry";
12
+ import type { EnrollmentCapabilityBinding } from "./enrollment_authority";
13
+ import { preparePiCanary, type PiCanaryPreparation } from "./pi_canary_prepare";
14
+
15
+ export const BATCH_AUTHORITY_CAPABILITY_BRAND = Symbol.for(
16
+ "assurance-kernel.batch-authority-capability-brand",
17
+ );
18
+
19
+ const GIT_COMMIT_ID = /^[a-f0-9]{40}$/;
20
+
21
+ /** review-7(3rd rework): typed expiry marker thrown at the Kernel enrollment
22
+ * boundary so the driver can classify a real enrollment-time expiry as an
23
+ * intentional budget stop without free-form message matching. */
24
+ export class BatchAuthorizationExpiryError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "BatchAuthorizationExpiryError";
28
+ }
29
+ }
30
+
31
+ export interface BatchPlanChild {
32
+ task_id: string;
33
+ intent_path: string;
34
+ intent_revision: number;
35
+ intent_content_hash: string;
36
+ blocked_by: string[];
37
+ }
38
+
39
+ export interface BatchBudget {
40
+ max_children: number;
41
+ deadline_at: string;
42
+ qa_failure_limit: number;
43
+ }
44
+
45
+ export interface BatchAuthorizationBinding {
46
+ batch_id: string;
47
+ initiative_slug: string;
48
+ plan_digest: string;
49
+ branch: string;
50
+ base_head: string;
51
+ budget: BatchBudget;
52
+ actor_id: string;
53
+ confirmation_ref: string;
54
+ expires_at: string;
55
+ nonce: string;
56
+ }
57
+
58
+ export interface ValidatedBatchAuthorization {
59
+ batch_id: string;
60
+ initiative_slug: string;
61
+ plan_digest: string;
62
+ branch: string;
63
+ base_head: string;
64
+ budget: BatchBudget;
65
+ actor_id: string;
66
+ confirmation_ref: string;
67
+ issued_at: string;
68
+ expires_at: string;
69
+ nonce: string;
70
+ }
71
+
72
+ export interface BatchAuthorityRegistry {
73
+ readonly brand: symbol;
74
+ /**
75
+ * Issue one Batch Authorization bound to an exact ordered child plan.
76
+ * The plan is retained so per-child consumption can prove membership.
77
+ */
78
+ issue(
79
+ binding: BatchAuthorizationBinding,
80
+ children: BatchPlanChild[],
81
+ issuedAt?: string,
82
+ ): object;
83
+ inspect(
84
+ capability: object,
85
+ expected: BatchAuthorizationBinding,
86
+ now?: number,
87
+ ): ValidatedBatchAuthorization;
88
+ children(capability: object): BatchPlanChild[];
89
+ consumedChildren(capability: object): string[];
90
+ isChildConsumed(capability: object, taskId: string): boolean;
91
+ /** Mark exactly one child slot used; the authorization stays valid for the rest. */
92
+ consumeChild(
93
+ capability: object,
94
+ expected: BatchAuthorizationBinding,
95
+ taskId: string,
96
+ now?: number,
97
+ ): ValidatedBatchAuthorization;
98
+ /** Undo one slot consumption when the bound write did not commit. */
99
+ releaseChild(capability: object, taskId: string): void;
100
+ isExhausted(capability: object): boolean;
101
+ }
102
+
103
+ function sha256Hex(bytes: string): string {
104
+ return createHash("sha256").update(bytes).digest("hex");
105
+ }
106
+
107
+ function stableStringify(value: unknown): string {
108
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
109
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
110
+ const record = value as Record<string, unknown>;
111
+ return `{${Object.keys(record)
112
+ .sort()
113
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
114
+ .join(",")}}`;
115
+ }
116
+
117
+ /**
118
+ * The authorization subject. A TaskIntent carries no Initiative or dependency
119
+ * field, so the confirmed ordered plan — not remote tracker state — is what the
120
+ * literal user approves, and this digest is what binds it.
121
+ */
122
+ export function computeBatchPlanDigest(children: BatchPlanChild[]): string {
123
+ const canonical = children.map((child) => ({
124
+ blocked_by: [...child.blocked_by],
125
+ intent_content_hash: child.intent_content_hash,
126
+ intent_path: child.intent_path,
127
+ intent_revision: child.intent_revision,
128
+ task_id: child.task_id,
129
+ }));
130
+ return `sha256:${sha256Hex(stableStringify(canonical))}`;
131
+ }
132
+
133
+ function requireNonEmpty(binding: BatchAuthorizationBinding): void {
134
+ const missing: string[] = [];
135
+ for (const key of [
136
+ "batch_id",
137
+ "initiative_slug",
138
+ "plan_digest",
139
+ "branch",
140
+ "base_head",
141
+ "actor_id",
142
+ "confirmation_ref",
143
+ "expires_at",
144
+ "nonce",
145
+ ] as const) {
146
+ const value = binding[key];
147
+ if (value === undefined || value === null || value === "") missing.push(key);
148
+ }
149
+ if (!binding.budget || typeof binding.budget !== "object") missing.push("budget");
150
+ if (missing.length > 0)
151
+ throw new Error(`batch authorization binding is incomplete: ${missing.join(", ")}`);
152
+ }
153
+
154
+ function validateBudget(budget: BatchBudget, issuedAt: string): void {
155
+ if (!Number.isInteger(budget.max_children) || budget.max_children <= 0)
156
+ throw new Error("batch budget max_children must be a positive integer");
157
+ if (!Number.isInteger(budget.qa_failure_limit) || budget.qa_failure_limit <= 0)
158
+ throw new Error("batch budget qa_failure_limit must be a positive integer");
159
+ const deadline = Date.parse(budget.deadline_at);
160
+ if (Number.isNaN(deadline) || deadline <= Date.parse(issuedAt))
161
+ throw new Error("batch budget must have a future deadline_at");
162
+ }
163
+
164
+ function validateChildren(children: BatchPlanChild[], planDigest: string): void {
165
+ if (!Array.isArray(children) || children.length === 0)
166
+ throw new Error("batch authorization requires a non-empty child plan");
167
+ const seen = new Set<string>();
168
+ for (const child of children) {
169
+ if (!child || typeof child !== "object")
170
+ throw new Error("batch plan child must be an object");
171
+ for (const key of ["task_id", "intent_path", "intent_content_hash"] as const) {
172
+ if (typeof child[key] !== "string" || child[key] === "")
173
+ throw new Error(`batch plan child ${key} must be a non-empty string`);
174
+ }
175
+ if (!Number.isInteger(child.intent_revision) || child.intent_revision <= 0)
176
+ throw new Error("batch plan child intent_revision must be a positive integer");
177
+ if (!Array.isArray(child.blocked_by) || child.blocked_by.some((id) => typeof id !== "string" || id === ""))
178
+ throw new Error("batch plan child blocked_by must be an array of task ids");
179
+ if (seen.has(child.task_id))
180
+ throw new Error(`batch plan child ${child.task_id} appears more than once`);
181
+ seen.add(child.task_id);
182
+ }
183
+ for (const child of children) {
184
+ for (const blocker of child.blocked_by) {
185
+ if (!seen.has(blocker))
186
+ throw new Error(
187
+ `batch plan child ${child.task_id} is blocked by ${blocker}, which is not in the confirmed plan`,
188
+ );
189
+ }
190
+ }
191
+ const dependencies = new Map(children.map((child) => [child.task_id, child.blocked_by]));
192
+ const visiting = new Set<string>();
193
+ const visited = new Set<string>();
194
+ function visit(taskId: string): void {
195
+ if (visiting.has(taskId)) throw new Error(`batch plan dependency cycle at ${taskId}`);
196
+ if (visited.has(taskId)) return;
197
+ visiting.add(taskId);
198
+ for (const blocker of dependencies.get(taskId)!) visit(blocker);
199
+ visiting.delete(taskId);
200
+ visited.add(taskId);
201
+ }
202
+ for (const child of children) visit(child.task_id);
203
+ if (computeBatchPlanDigest(children) !== planDigest)
204
+ throw new Error("batch plan digest does not match the confirmed child plan");
205
+ }
206
+
207
+ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
208
+ const inner = createCapabilityRegistry<
209
+ BatchAuthorizationBinding,
210
+ BatchAuthorizationBinding,
211
+ ValidatedBatchAuthorization
212
+ >(
213
+ BATCH_AUTHORITY_CAPABILITY_BRAND,
214
+ {
215
+ validateBinding(binding, issuedAt) {
216
+ requireNonEmpty(binding);
217
+ if (binding.actor_id !== "user")
218
+ throw new Error("batch authorization requires a literal-user actor_id");
219
+ if (!GIT_COMMIT_ID.test(binding.base_head))
220
+ throw new Error("batch authorization base_head must be a committed 40-hex commit id");
221
+ const expires = Date.parse(binding.expires_at);
222
+ if (Number.isNaN(expires) || expires <= Date.parse(issuedAt))
223
+ throw new Error("batch authorization must have a future expiry");
224
+ validateBudget(binding.budget, issuedAt);
225
+ },
226
+ validateAndProject(state, expected, now) {
227
+ // Fail closed on an unusable clock. `now` reaches here as
228
+ // Date.parse(...) from callers, and NaN makes every `<=` compare
229
+ // false, which would silently accept an expired authorization.
230
+ if (!Number.isFinite(now))
231
+ throw new Error("batch authorization requires a valid clock");
232
+ const expires = Date.parse(state.expires_at);
233
+ if (Number.isNaN(expires) || expires <= now)
234
+ throw new BatchAuthorizationExpiryError("batch authorization has expired");
235
+ if (Date.parse(state.budget.deadline_at) <= now)
236
+ throw new BatchAuthorizationExpiryError("batch authorization deadline has expired");
237
+ for (const key of Object.keys(expected) as Array<keyof BatchAuthorizationBinding>) {
238
+ if (key === "budget") {
239
+ const a = state.budget ?? ({} as BatchBudget);
240
+ const b = expected.budget ?? ({} as BatchBudget);
241
+ if (
242
+ a.max_children !== b.max_children ||
243
+ a.deadline_at !== b.deadline_at ||
244
+ a.qa_failure_limit !== b.qa_failure_limit
245
+ )
246
+ throw new Error("batch authorization budget mismatch");
247
+ continue;
248
+ }
249
+ if (state[key] !== expected[key])
250
+ throw new Error(`batch authorization ${key} mismatch`);
251
+ }
252
+ return {
253
+ batch_id: state.batch_id,
254
+ initiative_slug: state.initiative_slug,
255
+ plan_digest: state.plan_digest,
256
+ branch: state.branch,
257
+ base_head: state.base_head,
258
+ budget: { ...state.budget },
259
+ actor_id: state.actor_id,
260
+ confirmation_ref: state.confirmation_ref,
261
+ issued_at: state.issued_at,
262
+ expires_at: state.expires_at,
263
+ nonce: state.nonce,
264
+ };
265
+ },
266
+ },
267
+ "batch authorization",
268
+ );
269
+
270
+ const plans = new WeakMap<object, BatchPlanChild[]>();
271
+ const consumed = new WeakMap<object, Set<string>>();
272
+
273
+ function planOf(capability: object): BatchPlanChild[] {
274
+ const plan = plans.get(capability);
275
+ if (!plan) throw new Error("batch authorization capability is not recognized by this registry");
276
+ return plan;
277
+ }
278
+
279
+ function slotsOf(capability: object): Set<string> {
280
+ const slots = consumed.get(capability);
281
+ if (!slots) throw new Error("batch authorization capability is not recognized by this registry");
282
+ return slots;
283
+ }
284
+
285
+ return {
286
+ brand: inner.brand,
287
+ issue(binding, children, issuedAt = new Date().toISOString()) {
288
+ requireNonEmpty(binding);
289
+ validateChildren(children, binding.plan_digest);
290
+ const capability = inner.issue(binding, issuedAt) as object;
291
+ plans.set(
292
+ capability,
293
+ children.map((child) => ({ ...child, blocked_by: [...child.blocked_by] })),
294
+ );
295
+ consumed.set(capability, new Set<string>());
296
+ return capability;
297
+ },
298
+ inspect(capability, expected, now = Date.now()) {
299
+ planOf(capability);
300
+ return inner.inspect(capability, expected, now);
301
+ },
302
+ children(capability) {
303
+ return planOf(capability).map((child) => ({ ...child, blocked_by: [...child.blocked_by] }));
304
+ },
305
+ consumedChildren(capability) {
306
+ return [...slotsOf(capability)];
307
+ },
308
+ isChildConsumed(capability, taskId) {
309
+ return slotsOf(capability).has(taskId);
310
+ },
311
+ consumeChild(capability, expected, taskId, now = Date.now()) {
312
+ const validated = this.inspect(capability, expected, now);
313
+ const plan = planOf(capability);
314
+ if (!plan.some((child) => child.task_id === taskId))
315
+ throw new Error(`batch_child_not_in_plan: ${taskId}`);
316
+ const slots = slotsOf(capability);
317
+ if (slots.has(taskId)) throw new Error(`batch_child_slot_consumed: ${taskId}`);
318
+ slots.add(taskId);
319
+ return validated;
320
+ },
321
+ releaseChild(capability, taskId) {
322
+ slotsOf(capability).delete(taskId);
323
+ },
324
+ isExhausted(capability) {
325
+ return slotsOf(capability).size >= planOf(capability).length;
326
+ },
327
+ };
328
+ }
329
+
330
+ export interface DeriveChildEnrollmentInput {
331
+ capability: object;
332
+ binding: BatchAuthorizationBinding;
333
+ task_id: string;
334
+ /** Batch HEAD lineage: base_head, then each commit this batch created. */
335
+ expected_head: string;
336
+ now: string;
337
+ }
338
+
339
+ export interface DerivedChildEnrollment {
340
+ child: BatchPlanChild;
341
+ preparation: PiCanaryPreparation;
342
+ binding: EnrollmentCapabilityBinding;
343
+ }
344
+
345
+ /**
346
+ * Derive one child's Enrollment binding from a Batch Authorization. The
347
+ * preparation digest is recomputed here, never carried from the confirmation.
348
+ */
349
+ export function deriveChildEnrollment(
350
+ root: string,
351
+ registry: BatchAuthorityRegistry,
352
+ input: DeriveChildEnrollmentInput,
353
+ ): DerivedChildEnrollment {
354
+ const validated = registry.inspect(input.capability, input.binding, Date.parse(input.now));
355
+ const child = registry
356
+ .children(input.capability)
357
+ .find((entry) => entry.task_id === input.task_id);
358
+ if (!child) throw new Error(`batch_child_not_in_plan: ${input.task_id}`);
359
+ if (registry.isChildConsumed(input.capability, input.task_id))
360
+ throw new Error(`batch_child_slot_consumed: ${input.task_id}`);
361
+ if (!GIT_COMMIT_ID.test(input.expected_head))
362
+ throw new Error("batch_head_lineage_broken: expected_head is not a commit id");
363
+
364
+ const preparation = preparePiCanary(root, { task_id: input.task_id, now: input.now });
365
+ if (!preparation.git_base_head)
366
+ throw new Error(preparation.git_error ?? "enrollment requires a committed Git HEAD");
367
+ if (preparation.git_base_head !== input.expected_head)
368
+ throw new Error(
369
+ `batch_head_lineage_broken: expected ${input.expected_head}, found ${preparation.git_base_head}`,
370
+ );
371
+ if (!preparation.intent)
372
+ throw new Error(`batch_child_intent_changed: ${input.task_id} intent sidecar is unreadable`);
373
+ if (
374
+ preparation.intent.path !== child.intent_path ||
375
+ preparation.intent.revision !== child.intent_revision ||
376
+ preparation.intent.content_hash !== child.intent_content_hash
377
+ )
378
+ throw new Error(`batch_child_intent_changed: ${input.task_id}`);
379
+
380
+ // The lineage starts at the confirmed base_head. Until this batch has
381
+ // settled a child there is no commit it could have created, so a
382
+ // caller-supplied expected_head other than base_head is not a lineage.
383
+ // Checked last so the intent-divergence reason keeps its precedence.
384
+ if (
385
+ registry.consumedChildren(input.capability).length === 0 &&
386
+ input.expected_head !== validated.base_head
387
+ )
388
+ throw new Error(
389
+ `batch_head_lineage_broken: the first child must enroll on the confirmed base_head ${validated.base_head}, not ${input.expected_head}`,
390
+ );
391
+
392
+ return {
393
+ child,
394
+ preparation,
395
+ binding: {
396
+ task_id: child.task_id,
397
+ intent_path: child.intent_path,
398
+ intent_revision: child.intent_revision,
399
+ intent_content_hash: child.intent_content_hash,
400
+ preparation_digest: preparation.digest,
401
+ actor_id: validated.actor_id,
402
+ confirmation_ref: validated.confirmation_ref,
403
+ expires_at: validated.expires_at,
404
+ nonce: `${validated.nonce}:${child.task_id}`,
405
+ },
406
+ };
407
+ }