immune-brain 3.6.4 → 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 +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +45 -18
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +147 -48
- 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 +18 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +31 -9
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +14 -1
- package/plugins/immune-brain/runtime/commands/kernel.ts +15 -13
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +94 -3
- package/plugins/immune-brain/runtime/kernel/application.ts +1 -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 +72 -13
- package/plugins/immune-brain/runtime/kernel/intent.ts +67 -23
- package/plugins/immune-brain/runtime/kernel/reducer.ts +34 -8
- package/plugins/immune-brain/runtime/kernel/types.ts +1 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +10 -6
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
|
@@ -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
|
+
}
|
|
@@ -55,6 +55,7 @@ export type CanaryOperation =
|
|
|
55
55
|
| { op: "approve_breaking_intent_revision"; capability: object; next_intent: TaskIntentV1; actor_id: string }
|
|
56
56
|
| { op: "complete"; actor_id: string }
|
|
57
57
|
| { op: "stop"; capability: object; reason: string; actor_id: string }
|
|
58
|
+
| { op: "authorize_rework"; capability: object; actor_id: string }
|
|
58
59
|
| { op: "resolve_user_decision"; capability: object; finding_id: string; resolution: string; actor_id: string };
|
|
59
60
|
|
|
60
61
|
export interface CanaryExecuteInput {
|
|
@@ -142,6 +143,8 @@ export function capabilityActionFor(input: {
|
|
|
142
143
|
return { ...base, approval: input.approval } as TaskAction;
|
|
143
144
|
case "request_rework":
|
|
144
145
|
return { ...base, findings: input.findings } as TaskAction;
|
|
146
|
+
case "authorize_rework":
|
|
147
|
+
return { ...base, type: "authorize_rework" } as TaskAction;
|
|
145
148
|
case "stop":
|
|
146
149
|
return { ...base, reason: input.reason } as TaskAction;
|
|
147
150
|
case "approve_breaking_intent_revision":
|
|
@@ -326,15 +329,17 @@ export function createCanaryApplication(
|
|
|
326
329
|
);
|
|
327
330
|
if (operation.op === "complete" && hasBoundSpec && snapshot.record.artifact_state !== "frozen")
|
|
328
331
|
throw new KernelInvariantError(["complete requires frozen planning artifacts"]);
|
|
329
|
-
const artifactTransition =
|
|
330
|
-
&&
|
|
331
|
-
|
|
332
|
-
|
|
332
|
+
const artifactTransition =
|
|
333
|
+
snapshot.record.artifact_state === "frozen" &&
|
|
334
|
+
(
|
|
335
|
+
operation.op === "request_rework" ||
|
|
336
|
+
operation.op === "authorize_rework" ||
|
|
337
|
+
operation.op === "approve_breaking_intent_revision"
|
|
333
338
|
)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
339
|
+
? transitionFor(input.root, snapshot.record, "restore")
|
|
340
|
+
: operation.op === "stop" && snapshot.record.artifact_state !== "frozen"
|
|
341
|
+
? transitionFor(input.root, snapshot.record, "freeze", true)
|
|
342
|
+
: undefined;
|
|
338
343
|
const event_id = `${operation.op}:${input.task_id}:${at}`;
|
|
339
344
|
const base = {
|
|
340
345
|
event_id,
|
|
@@ -400,6 +405,10 @@ export function createCanaryApplication(
|
|
|
400
405
|
capability = operation.capability;
|
|
401
406
|
action = { ...base, type: "stop", reason: operation.reason };
|
|
402
407
|
break;
|
|
408
|
+
case "authorize_rework":
|
|
409
|
+
capability = operation.capability;
|
|
410
|
+
action = { ...base, type: "authorize_rework" };
|
|
411
|
+
break;
|
|
403
412
|
case "resolve_user_decision":
|
|
404
413
|
capability = operation.capability;
|
|
405
414
|
action = {
|
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
type EnrollmentAuthorityRegistry,
|
|
9
9
|
type EnrollmentCapabilityBinding,
|
|
10
10
|
} from "./enrollment_authority";
|
|
11
|
+
import type {
|
|
12
|
+
BatchAuthorityRegistry,
|
|
13
|
+
BatchAuthorizationBinding,
|
|
14
|
+
} from "./batch_authority";
|
|
11
15
|
import { readTaskTombstone, type BackendClaim } from "./backend_claim";
|
|
12
16
|
import { preparePiCanary, readGitHead } from "./pi_canary_prepare";
|
|
13
17
|
import {
|
|
@@ -18,6 +22,19 @@ import {
|
|
|
18
22
|
} from "./storage";
|
|
19
23
|
import type { TaskRecord, TaskRecordV4, WorkspaceStateLike } from "./types";
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Present only for a batch-derived enrollment. The child slot is consumed
|
|
27
|
+
* inside the same store lock as the TaskRecord write and released again if that
|
|
28
|
+
* write does not commit, so a consumed slot and a TaskRecord always agree.
|
|
29
|
+
*/
|
|
30
|
+
export interface EnrollBatchContext {
|
|
31
|
+
registry: BatchAuthorityRegistry;
|
|
32
|
+
capability: object;
|
|
33
|
+
binding: BatchAuthorizationBinding;
|
|
34
|
+
/** base_head, then each commit this batch created on its own branch. */
|
|
35
|
+
expected_head: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
export interface EnrollCanaryInput {
|
|
22
39
|
task_id: string;
|
|
23
40
|
intent_path: string;
|
|
@@ -25,6 +42,7 @@ export interface EnrollCanaryInput {
|
|
|
25
42
|
preparation_digest: string;
|
|
26
43
|
capability: object;
|
|
27
44
|
capability_binding: EnrollmentCapabilityBinding;
|
|
45
|
+
batch?: EnrollBatchContext;
|
|
28
46
|
now: string;
|
|
29
47
|
}
|
|
30
48
|
|
|
@@ -241,8 +259,41 @@ export function enrollCanaryTask(
|
|
|
241
259
|
if (checks.gitBaseHead !== gitBaseHead)
|
|
242
260
|
throw new Error("Git HEAD moved after the enrollment confirmation");
|
|
243
261
|
|
|
262
|
+
// A batch-derived enrollment must still stand on the lineage the
|
|
263
|
+
// literal user confirmed: child N's base is the commit child N-1
|
|
264
|
+
// produced on the batch branch, and child 1's base is base_head.
|
|
265
|
+
// expected_head is caller-supplied, so anchor its origin here rather
|
|
266
|
+
// than trusting the caller's own assertion: before this batch has
|
|
267
|
+
// consumed any slot it has created no commit, so the only lineage
|
|
268
|
+
// value it can hold is the confirmed base_head.
|
|
269
|
+
if (input.batch) {
|
|
270
|
+
const batch = input.batch.registry.inspect(
|
|
271
|
+
input.batch.capability,
|
|
272
|
+
input.batch.binding,
|
|
273
|
+
Date.parse(input.now),
|
|
274
|
+
);
|
|
275
|
+
if (
|
|
276
|
+
input.batch.registry.consumedChildren(input.batch.capability).length === 0 &&
|
|
277
|
+
input.batch.expected_head !== batch.base_head
|
|
278
|
+
)
|
|
279
|
+
throw new Error(
|
|
280
|
+
`batch_head_lineage_broken: the first child must enroll on the confirmed base_head ${batch.base_head}, not ${input.batch.expected_head}`,
|
|
281
|
+
);
|
|
282
|
+
if (checks.gitBaseHead !== input.batch.expected_head)
|
|
283
|
+
throw new Error(
|
|
284
|
+
`batch_head_lineage_broken: expected ${input.batch.expected_head}, found ${checks.gitBaseHead}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
244
288
|
// consume immediately before the marker write
|
|
245
289
|
registry.consume(input.capability, input.capability_binding);
|
|
290
|
+
if (input.batch)
|
|
291
|
+
input.batch.registry.consumeChild(
|
|
292
|
+
input.batch.capability,
|
|
293
|
+
input.batch.binding,
|
|
294
|
+
input.task_id,
|
|
295
|
+
Date.parse(input.now),
|
|
296
|
+
);
|
|
246
297
|
|
|
247
298
|
// Set by beforeLock above, which throws when the repository has no
|
|
248
299
|
// committed HEAD. Re-assert it here: the compiler cannot carry a
|
|
@@ -264,19 +315,27 @@ export function enrollCanaryTask(
|
|
|
264
315
|
created_at: input.now,
|
|
265
316
|
updated_at: input.now,
|
|
266
317
|
};
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
318
|
+
let mutation: ReturnType<typeof commitEnrollmentLocked>;
|
|
319
|
+
try {
|
|
320
|
+
mutation = commitEnrollmentLocked(
|
|
321
|
+
root,
|
|
322
|
+
input.task_id,
|
|
323
|
+
{
|
|
324
|
+
contract: "assurance_kernel/workspace_transaction/v2",
|
|
325
|
+
task_id: input.task_id,
|
|
326
|
+
expected_record_hash: checks.current.revision,
|
|
327
|
+
next_record_content: `${JSON.stringify(record, null, 2)}\n`,
|
|
328
|
+
expected_workspace_hash: checks.workspace.revision,
|
|
329
|
+
next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
|
|
330
|
+
},
|
|
331
|
+
claim as unknown as Record<string, unknown>,
|
|
332
|
+
);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
// No TaskRecord was written, so the child slot must not stay used.
|
|
335
|
+
if (input.batch)
|
|
336
|
+
input.batch.registry.releaseChild(input.batch.capability, input.task_id);
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
280
339
|
return {
|
|
281
340
|
record: mutation.record,
|
|
282
341
|
backend_claim: claim,
|