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