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.
- package/package.json +6 -3
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +117 -47
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +6 -4
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -44
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +213 -81
- package/plugins/immune-brain/dist/imm-loop.md +8 -2
- package/plugins/immune-brain/dist/imm-planner.md +9 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +35 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +23 -13
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +133 -45
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +14 -1
- package/plugins/immune-brain/runtime/claude/review_host.ts +0 -10
- package/plugins/immune-brain/runtime/commands/kernel.ts +15 -13
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +107 -11
- package/plugins/immune-brain/runtime/kernel/application.ts +6 -0
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +407 -0
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +17 -8
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +76 -13
- package/plugins/immune-brain/runtime/kernel/index.ts +2 -0
- package/plugins/immune-brain/runtime/kernel/intent.ts +67 -23
- package/plugins/immune-brain/runtime/kernel/observation.ts +2 -0
- package/plugins/immune-brain/runtime/kernel/reducer.ts +34 -8
- package/plugins/immune-brain/runtime/kernel/storage.ts +17 -2
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +0 -4
- package/plugins/immune-brain/runtime/kernel/types.ts +10 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +10 -6
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +2 -2
- 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:
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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) => (
|
|
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
|
+
}
|