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.
@@ -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 = snapshot.record.artifact_state === "frozen"
330
- && (
331
- operation.op === "request_rework"
332
- || operation.op === "approve_breaking_intent_revision"
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
- ? transitionFor(input.root, snapshot.record, "restore")
335
- : operation.op === "stop" && snapshot.record.artifact_state !== "frozen"
336
- ? transitionFor(input.root, snapshot.record, "freeze", true)
337
- : undefined;
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
- const mutation = commitEnrollmentLocked(
268
- root,
269
- input.task_id,
270
- {
271
- contract: "assurance_kernel/workspace_transaction/v2",
272
- task_id: input.task_id,
273
- expected_record_hash: checks.current.revision,
274
- next_record_content: `${JSON.stringify(record, null, 2)}\n`,
275
- expected_workspace_hash: checks.workspace.revision,
276
- next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
277
- },
278
- claim as unknown as Record<string, unknown>,
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,