immune-brain 3.6.8 → 4.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 +11 -4
- package/README.zh-CN.md +10 -3
- package/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +53 -32
- package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
- package/plugins/immune-brain/dist/registry.yaml +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
- package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
- package/plugins/immune-brain/skills/registry.yaml +9 -0
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
|
@@ -1,67 +1,122 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Kernel authority store adapter.
|
|
3
|
+
*
|
|
4
|
+
* One worktree-local SQLite database (`.imm/state/kernel.sqlite`) is the single
|
|
5
|
+
* transaction boundary for runs, lifecycle, revisions, findings, attestations
|
|
6
|
+
* and operation outcomes. This module owns the authority decisions that used to
|
|
7
|
+
* be spread across file-level CAS writes and recoverable transaction markers:
|
|
8
|
+
*
|
|
9
|
+
* - the workspace owner is derived from the single active run,
|
|
10
|
+
* - concurrent writes are checked by monotonic integer revisions,
|
|
11
|
+
* - every replayable operation records its result in the same transaction, so a
|
|
12
|
+
* lost response reuses durable facts instead of writing again,
|
|
13
|
+
* - terminal settlement commits atomically and exports audit evidence
|
|
14
|
+
* afterwards, where an interrupted export stays retryable and can never
|
|
15
|
+
* reactivate a settled run.
|
|
16
|
+
*
|
|
17
|
+
* File-level helpers still exist for tracked evidence: documentation artifacts
|
|
18
|
+
* (freeze/rework relocation) and the immutable audit pair under `.imm/audit/`.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
2
21
|
import {
|
|
3
|
-
closeSync,
|
|
4
22
|
constants,
|
|
23
|
+
closeSync,
|
|
24
|
+
existsSync,
|
|
5
25
|
fstatSync,
|
|
6
26
|
fsyncSync,
|
|
7
27
|
lstatSync,
|
|
8
28
|
mkdirSync,
|
|
9
29
|
openSync,
|
|
10
30
|
readFileSync,
|
|
31
|
+
readdirSync,
|
|
11
32
|
realpathSync,
|
|
12
33
|
renameSync,
|
|
13
34
|
rmSync,
|
|
14
35
|
writeFileSync,
|
|
15
36
|
} from "node:fs";
|
|
16
|
-
import {
|
|
37
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
38
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
39
|
+
|
|
17
40
|
import {
|
|
41
|
+
claimFromRunRow,
|
|
18
42
|
parseBackendClaim,
|
|
19
|
-
readBackendClaim,
|
|
20
43
|
parseTaskTombstone,
|
|
21
|
-
|
|
44
|
+
serializeBackendClaim,
|
|
22
45
|
serializeTaskTombstone,
|
|
23
46
|
type BackendClaim,
|
|
24
47
|
type TaskTombstone,
|
|
25
48
|
} from "./backend_claim";
|
|
26
49
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
stateWorkspacePath,
|
|
50
|
+
auditEvidencePaths,
|
|
51
|
+
auditRunRecordPath,
|
|
52
|
+
auditRunTerminalProofPath,
|
|
53
|
+
FILE_STORE_CLAIM_RELATIVE,
|
|
54
|
+
FILE_STORE_TRANSACTIONS_RELATIVE,
|
|
55
|
+
FILE_STORE_WORKSPACE_RELATIVE,
|
|
56
|
+
stateDatabasePath,
|
|
35
57
|
} from "./storage_paths";
|
|
36
|
-
import {
|
|
58
|
+
import { canonicalRecordHash } from "./reducer";
|
|
59
|
+
import { parseTaskRecord } from "./validation";
|
|
60
|
+
import { parseTaskRecordV2, parseTaskRecordV3, type TaskRecordV2 } from "./legacy_task_record";
|
|
61
|
+
import {
|
|
62
|
+
assertRunBinding,
|
|
63
|
+
drainOperationId,
|
|
64
|
+
enrollmentOperationId,
|
|
65
|
+
enrollmentRequestDigest,
|
|
66
|
+
mintRunId,
|
|
67
|
+
runIdentity,
|
|
68
|
+
terminalOperationId,
|
|
69
|
+
type RunIdentity,
|
|
70
|
+
} from "./run_identity";
|
|
71
|
+
import {
|
|
72
|
+
KernelStoreConflictError,
|
|
73
|
+
KernelStoreSecurityError,
|
|
74
|
+
activeRunId,
|
|
75
|
+
appendJournalRow,
|
|
76
|
+
findJournalObservation,
|
|
77
|
+
insertOperationRow,
|
|
78
|
+
insertRunRow,
|
|
79
|
+
listPendingAuditExports,
|
|
80
|
+
listPendingRelocations,
|
|
81
|
+
markAuditExported,
|
|
82
|
+
setPendingRelocations,
|
|
83
|
+
readOperationRow,
|
|
84
|
+
readRunRowById,
|
|
85
|
+
readRunRowByTask,
|
|
86
|
+
readWorkspaceRow,
|
|
87
|
+
setStoreFaultForTest,
|
|
88
|
+
updateRunClaim,
|
|
89
|
+
updateRunRecord,
|
|
90
|
+
updateRunTerminal,
|
|
91
|
+
withKernelRead,
|
|
92
|
+
withKernelTransaction,
|
|
93
|
+
writeWorkspaceRow,
|
|
94
|
+
type KernelRunRow,
|
|
95
|
+
} from "./sqlite_store";
|
|
37
96
|
import type {
|
|
38
97
|
TaskLifecycle,
|
|
39
98
|
TaskPhase,
|
|
40
|
-
TaskRecordV2,
|
|
41
99
|
TaskRecord,
|
|
100
|
+
TaskRecordV3,
|
|
42
101
|
StoredTaskMutationV3,
|
|
43
102
|
V3AuthorityObservation,
|
|
44
103
|
} from "./types";
|
|
45
104
|
|
|
46
|
-
export
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
constructor(message: string) {
|
|
52
|
-
super(message);
|
|
53
|
-
this.name = "KernelStoreConflictError";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
105
|
+
export {
|
|
106
|
+
KernelSchemaError,
|
|
107
|
+
KernelStoreConflictError,
|
|
108
|
+
KernelStoreSecurityError,
|
|
109
|
+
} from "./sqlite_store";
|
|
56
110
|
|
|
57
|
-
export
|
|
58
|
-
readonly code = "kernel_store_security_error";
|
|
111
|
+
export const MISSING_REVISION = "missing";
|
|
59
112
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The revision token of a workspace that has recorded no write yet. A worktree
|
|
115
|
+
* whose store has not been created reports this same token, so a preparation
|
|
116
|
+
* taken before the store exists still matches the store's own bootstrap
|
|
117
|
+
* revision instead of changing identity when the first writer creates it.
|
|
118
|
+
*/
|
|
119
|
+
export const INITIAL_WORKSPACE_REVISION = recordRevision(0);
|
|
65
120
|
|
|
66
121
|
export interface WorkspaceState {
|
|
67
122
|
contract: "assurance_kernel/workspace/v1";
|
|
@@ -109,10 +164,37 @@ export interface JournalEntry {
|
|
|
109
164
|
observation?: V3AuthorityObservation;
|
|
110
165
|
}
|
|
111
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Revision vocabulary: monotonic integers instead of serialized byte hashes.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
112
171
|
function revisionFor(content: string): string {
|
|
113
172
|
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
114
173
|
}
|
|
115
174
|
|
|
175
|
+
export function revisionForContent(content: string): string {
|
|
176
|
+
return revisionFor(content);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The workspace CAS token. The contract requires a `sha256:` token, and the
|
|
181
|
+
* value must be revision-based: two workspaces with identical serialized
|
|
182
|
+
* content at different revisions are different states, so the token binds the
|
|
183
|
+
* store's monotonic revision instead of the bytes.
|
|
184
|
+
*/
|
|
185
|
+
function recordRevision(revision: number): string {
|
|
186
|
+
return revisionFor(`assurance_kernel/workspace_revision/v1:${revision}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function nowIso(): string {
|
|
190
|
+
return new Date().toISOString();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateTaskId(taskId: string): void {
|
|
194
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
|
|
195
|
+
throw new KernelStoreSecurityError("task_id is not a safe file identity");
|
|
196
|
+
}
|
|
197
|
+
|
|
116
198
|
function canonicalRoot(root: string): string {
|
|
117
199
|
try {
|
|
118
200
|
return realpathSync(root);
|
|
@@ -121,6 +203,174 @@ function canonicalRoot(root: string): string {
|
|
|
121
203
|
}
|
|
122
204
|
}
|
|
123
205
|
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Retired file-store guard: a worktree must never mix two authority stores.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Retire the retired claim/owner files that provably duplicate this task's own
|
|
212
|
+
* committed run. Called only from an authority repair, whose proof is what
|
|
213
|
+
* authorizes the deletion; ordinary mutations never delete retired authority.
|
|
214
|
+
*/
|
|
215
|
+
function retireSupersededRetiredFiles(root: string, db: DatabaseSync, taskId: string): void {
|
|
216
|
+
const canonical = canonicalRoot(root);
|
|
217
|
+
for (const path of [FILE_STORE_CLAIM_RELATIVE, FILE_STORE_WORKSPACE_RELATIVE]) {
|
|
218
|
+
const full = resolve(canonical, path);
|
|
219
|
+
if (!existsSync(full)) continue;
|
|
220
|
+
if (isRetiredFileProvablySuperseded(full, db, taskId)) rmSync(full, { force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* True only when the retired file's own identity matches the committed run for
|
|
226
|
+
* this task: the same task id, and — when the file carries them — the same
|
|
227
|
+
* enrollment event and intent hash. An unreadable or foreign file is never
|
|
228
|
+
* treated as superseded.
|
|
229
|
+
*/
|
|
230
|
+
function isRetiredFileProvablySuperseded(
|
|
231
|
+
path: string,
|
|
232
|
+
db: DatabaseSync,
|
|
233
|
+
taskId: string,
|
|
234
|
+
): boolean {
|
|
235
|
+
const run = readRunRowByTask(db, taskId);
|
|
236
|
+
if (!run) return false;
|
|
237
|
+
let raw: Record<string, unknown>;
|
|
238
|
+
try {
|
|
239
|
+
raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
// Ownership, not freshness: a stale copy of this task's own claim is exactly
|
|
244
|
+
// what a repair removes, while another task's claim is never this task's to
|
|
245
|
+
// retire. The store holding this task's run is what makes the file a
|
|
246
|
+
// duplicate of committed authority — but only the *same execution* proves it:
|
|
247
|
+
// another enrollment of the same task (different event, revision or content
|
|
248
|
+
// hash) is a different authority decision and must survive.
|
|
249
|
+
if (raw.task_id !== taskId) return false;
|
|
250
|
+
if (raw.contract !== "assurance_kernel/backend_claim/v2") {
|
|
251
|
+
// The retired workspace owner names its owner directly.
|
|
252
|
+
return raw.current_working === taskId;
|
|
253
|
+
}
|
|
254
|
+
if (raw.backend !== "kernel") return false;
|
|
255
|
+
if (raw.enrollment_event_id !== run.enrollment_event_id) return false;
|
|
256
|
+
if (raw.intent_revision !== run.intent_revision) return false;
|
|
257
|
+
if (raw.intent_content_hash !== run.intent_content_hash) return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Refuse mutation while the retired `.imm/state/*.json` file store (or the
|
|
263
|
+
* pre-cutover `.imm/tasks` layout) still holds authority. The check is bounded
|
|
264
|
+
* to `existsSync` paths so it can run on every locked mutation.
|
|
265
|
+
*/
|
|
266
|
+
function assertNoRetiredFileStore(
|
|
267
|
+
root: string,
|
|
268
|
+
db?: DatabaseSync,
|
|
269
|
+
taskId?: string | null,
|
|
270
|
+
): void {
|
|
271
|
+
const canonical = canonicalRoot(root);
|
|
272
|
+
// Real authority in the retired store: must be imported, never ignored.
|
|
273
|
+
const retired: Array<[string, string]> = [
|
|
274
|
+
[".imm/tasks", "pre-cutover task store"],
|
|
275
|
+
[".imm/workspace.json", "pre-cutover workspace owner"],
|
|
276
|
+
[".imm/state/tasks", "task records"],
|
|
277
|
+
];
|
|
278
|
+
for (const [path, label] of retired) {
|
|
279
|
+
if (existsSync(resolve(canonical, path)))
|
|
280
|
+
throw new KernelStoreSecurityError(
|
|
281
|
+
`retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
// A retired claim/owner file is inert only when its own bytes prove that this
|
|
285
|
+
// exact task and enrollment already own the SQLite run: it is then a
|
|
286
|
+
// duplicate of committed authority. Nothing is deleted here — removal
|
|
287
|
+
// belongs to the supported migration — and a file whose owner is anything
|
|
288
|
+
// else keeps its authority and fails the mutation closed.
|
|
289
|
+
const derived: Array<[string, string]> = [
|
|
290
|
+
[FILE_STORE_CLAIM_RELATIVE, "workspace claim"],
|
|
291
|
+
[FILE_STORE_WORKSPACE_RELATIVE, "workspace owner"],
|
|
292
|
+
];
|
|
293
|
+
for (const [path, label] of derived) {
|
|
294
|
+
const full = resolve(canonical, path);
|
|
295
|
+
if (!existsSync(full)) continue;
|
|
296
|
+
if (db === undefined || typeof taskId !== "string" || taskId.length === 0)
|
|
297
|
+
throw new KernelStoreSecurityError(
|
|
298
|
+
`retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`,
|
|
299
|
+
);
|
|
300
|
+
if (!isRetiredFileProvablySuperseded(full, db, taskId))
|
|
301
|
+
throw new KernelStoreSecurityError(
|
|
302
|
+
`retired file-store authority is present (${label}: ${path}) and does not belong to this task; import it with the supported migration before mutating this worktree`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (existsSync(resolve(canonical, FILE_STORE_TRANSACTIONS_RELATIVE))) {
|
|
306
|
+
const entries = readdirNames(resolve(canonical, FILE_STORE_TRANSACTIONS_RELATIVE));
|
|
307
|
+
const pending = entries.filter((entry) => entry.endsWith(".json") && entry !== "storage-layout-migration.json");
|
|
308
|
+
if (pending.length > 0)
|
|
309
|
+
throw new KernelStoreSecurityError(
|
|
310
|
+
`retired file-store transaction marker is present (${pending[0]}); settle it with the runtime that wrote it before mutating this worktree`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Read-only file-store conflict inspection. Real authority in the retired store
|
|
317
|
+
* always conflicts; a derived claim/owner file conflicts only while the store
|
|
318
|
+
* has no run for the requested task (a leftover for a known task is inert and
|
|
319
|
+
* gets retired by the next mutation).
|
|
320
|
+
*/
|
|
321
|
+
function retiredFileStoreConflict(
|
|
322
|
+
root: string,
|
|
323
|
+
db: DatabaseSync | null,
|
|
324
|
+
taskId: string | null,
|
|
325
|
+
): string | null {
|
|
326
|
+
const canonical = canonicalRoot(root);
|
|
327
|
+
const authority: Array<[string, string]> = [
|
|
328
|
+
[".imm/tasks", "pre-cutover task store"],
|
|
329
|
+
[".imm/workspace.json", "pre-cutover workspace owner"],
|
|
330
|
+
[".imm/state/tasks", "task records"],
|
|
331
|
+
];
|
|
332
|
+
for (const [path, label] of authority)
|
|
333
|
+
if (existsSync(resolve(canonical, path)))
|
|
334
|
+
return `retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`;
|
|
335
|
+
const storeHasTask = db !== null && taskId !== null && readRunRowByTask(db, taskId) !== null;
|
|
336
|
+
if (!storeHasTask) {
|
|
337
|
+
const derived: Array<[string, string]> = [
|
|
338
|
+
[FILE_STORE_CLAIM_RELATIVE, "workspace claim"],
|
|
339
|
+
[FILE_STORE_WORKSPACE_RELATIVE, "workspace owner"],
|
|
340
|
+
];
|
|
341
|
+
for (const [path, label] of derived)
|
|
342
|
+
if (existsSync(resolve(canonical, path)))
|
|
343
|
+
return `retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`;
|
|
344
|
+
}
|
|
345
|
+
const transactions = resolve(canonical, FILE_STORE_TRANSACTIONS_RELATIVE);
|
|
346
|
+
if (existsSync(transactions)) {
|
|
347
|
+
const pending = readdirNames(transactions).filter(
|
|
348
|
+
(entry) => entry.endsWith(".json") && entry !== "storage-layout-migration.json",
|
|
349
|
+
);
|
|
350
|
+
if (pending.length > 0)
|
|
351
|
+
return `retired file-store transaction marker is present (${pending[0]}); settle it with the runtime that wrote it before mutating this worktree`;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readdirNames(path: string): string[] {
|
|
357
|
+
try {
|
|
358
|
+
return readdirSync(path);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
361
|
+
// A missing directory is an empty one; anything else (permissions, I/O) is
|
|
362
|
+
// an unverifiable listing and must never be read as "no retired authority".
|
|
363
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
364
|
+
throw new KernelStoreSecurityError(
|
|
365
|
+
`retired store directory ${path} could not be listed (${code ?? "unknown"}); resolve the filesystem condition before mutating`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Secure file helpers (tracked evidence + artifact relocation only).
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
124
374
|
function withinRoot(root: string, candidate: string): boolean {
|
|
125
375
|
const rel = relative(root, candidate);
|
|
126
376
|
return (
|
|
@@ -281,9 +531,7 @@ function clearStaleLock(lockPath: string): boolean {
|
|
|
281
531
|
const before = pathStatOrNull(lockPath);
|
|
282
532
|
if (!before) return true;
|
|
283
533
|
if (before.isSymbolicLink() || !before.isFile())
|
|
284
|
-
throw new KernelStoreSecurityError(
|
|
285
|
-
"kernel store lock is not a regular file",
|
|
286
|
-
);
|
|
534
|
+
throw new KernelStoreSecurityError("kernel store lock is not a regular file");
|
|
287
535
|
let stale = false;
|
|
288
536
|
let fd: number | null = null;
|
|
289
537
|
try {
|
|
@@ -300,11 +548,7 @@ function clearStaleLock(lockPath: string): boolean {
|
|
|
300
548
|
}
|
|
301
549
|
if (!stale) return false;
|
|
302
550
|
const after = lstatSync(lockPath);
|
|
303
|
-
if (
|
|
304
|
-
after.isSymbolicLink() ||
|
|
305
|
-
after.dev !== before.dev ||
|
|
306
|
-
after.ino !== before.ino
|
|
307
|
-
)
|
|
551
|
+
if (after.isSymbolicLink() || after.dev !== before.dev || after.ino !== before.ino)
|
|
308
552
|
throw new KernelStoreSecurityError(
|
|
309
553
|
"kernel store lock identity changed during recovery",
|
|
310
554
|
);
|
|
@@ -312,6 +556,7 @@ function clearStaleLock(lockPath: string): boolean {
|
|
|
312
556
|
return true;
|
|
313
557
|
}
|
|
314
558
|
|
|
559
|
+
/** Bounded exclusive file lock for tracked-evidence writes. */
|
|
315
560
|
function withExclusiveLock<T>(lockPath: string, operation: () => T): T {
|
|
316
561
|
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
317
562
|
let fd: number | null = null;
|
|
@@ -319,10 +564,7 @@ function withExclusiveLock<T>(lockPath: string, operation: () => T): T {
|
|
|
319
564
|
try {
|
|
320
565
|
fd = openSync(
|
|
321
566
|
lockPath,
|
|
322
|
-
constants.O_WRONLY |
|
|
323
|
-
constants.O_CREAT |
|
|
324
|
-
constants.O_EXCL |
|
|
325
|
-
noFollow,
|
|
567
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
|
|
326
568
|
0o600,
|
|
327
569
|
);
|
|
328
570
|
break;
|
|
@@ -344,7 +586,7 @@ function withExclusiveLock<T>(lockPath: string, operation: () => T): T {
|
|
|
344
586
|
try {
|
|
345
587
|
writeFileSync(
|
|
346
588
|
fd,
|
|
347
|
-
`${JSON.stringify({ pid: process.pid, started_at:
|
|
589
|
+
`${JSON.stringify({ pid: process.pid, started_at: nowIso() })}\n`,
|
|
348
590
|
"utf8",
|
|
349
591
|
);
|
|
350
592
|
fsyncSync(fd);
|
|
@@ -387,14 +629,10 @@ function atomicCasWrite(
|
|
|
387
629
|
throw new KernelStoreConflictError(
|
|
388
630
|
`CAS mismatch for ${relativePath}: expected ${expectedRevision}, got ${actualRevision}`,
|
|
389
631
|
);
|
|
390
|
-
const tempPath = `${candidate.path}.${
|
|
632
|
+
const tempPath = `${candidate.path}.${process.pid}.tmp`;
|
|
391
633
|
let fd: number | null = null;
|
|
392
634
|
try {
|
|
393
|
-
fd = openSync(
|
|
394
|
-
tempPath,
|
|
395
|
-
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
396
|
-
0o600,
|
|
397
|
-
);
|
|
635
|
+
fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
398
636
|
writeFileSync(fd, content, "utf8");
|
|
399
637
|
fsyncSync(fd);
|
|
400
638
|
closeSync(fd);
|
|
@@ -410,93 +648,7 @@ function atomicCasWrite(
|
|
|
410
648
|
});
|
|
411
649
|
}
|
|
412
650
|
|
|
413
|
-
|
|
414
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
|
|
415
|
-
throw new KernelStoreSecurityError("task_id is not a safe file identity");
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const JOURNAL_LOCK_NAME = ".journal.lock";
|
|
419
|
-
const JOURNAL_READ_LIMIT = 64 * 1024 * 1024;
|
|
420
|
-
const TRANSACTION_PATH = ".imm/tasks/.workspace-transaction.json";
|
|
421
|
-
const V1_TRANSACTION_RETIRED = "workspace_transaction/v1 is retired after v4 storage retirement; use TaskRecord v3 + workspace_transaction/v2";
|
|
422
|
-
|
|
423
|
-
let afterTaskTransactionWriteForTest: (() => void) | null = null;
|
|
424
|
-
|
|
425
|
-
/** Test-only seam for a failure after the first file in a two-file transaction. */
|
|
426
|
-
export function setAfterTaskTransactionWriteForTest(
|
|
427
|
-
hook: (() => void) | null,
|
|
428
|
-
): void {
|
|
429
|
-
afterTaskTransactionWriteForTest = hook;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function runAfterTaskTransactionWriteHook(): void {
|
|
433
|
-
const hook = afterTaskTransactionWriteForTest;
|
|
434
|
-
afterTaskTransactionWriteForTest = null;
|
|
435
|
-
hook?.();
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
let terminalSettlementStepHookForTest: ((stepIndex: number) => void) | null = null;
|
|
439
|
-
|
|
440
|
-
/** Test-only seam: fail deterministically after each terminal settlement step. */
|
|
441
|
-
export function setTerminalSettlementStepHookForTest(
|
|
442
|
-
hook: ((stepIndex: number) => void) | null,
|
|
443
|
-
): void {
|
|
444
|
-
terminalSettlementStepHookForTest = hook;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function runTerminalSettlementStepHook(stepIndex: number): void {
|
|
448
|
-
const hook = terminalSettlementStepHookForTest;
|
|
449
|
-
if (!hook) return;
|
|
450
|
-
hook(stepIndex);
|
|
451
|
-
// A hook that did not interrupt keeps observing later steps; a thrown
|
|
452
|
-
// interruption propagates before this line and is consumed by the caller
|
|
453
|
-
// clearing the hook.
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
function parseWorkspaceContent(content: string): WorkspaceState {
|
|
458
|
-
const raw = JSON.parse(content) as Record<string, unknown>;
|
|
459
|
-
const unknown = Object.keys(raw).filter(
|
|
460
|
-
(key) => !["contract", "current_working"].includes(key),
|
|
461
|
-
);
|
|
462
|
-
if (unknown.length > 0)
|
|
463
|
-
throw new KernelStoreSecurityError(
|
|
464
|
-
`workspace has unknown field: ${unknown[0]}`,
|
|
465
|
-
);
|
|
466
|
-
if (raw.contract !== "assurance_kernel/workspace/v1")
|
|
467
|
-
throw new KernelStoreSecurityError("workspace contract is invalid");
|
|
468
|
-
if (
|
|
469
|
-
raw.current_working !== null &&
|
|
470
|
-
(typeof raw.current_working !== "string" || !raw.current_working.trim())
|
|
471
|
-
)
|
|
472
|
-
throw new KernelStoreSecurityError("workspace current_working is invalid");
|
|
473
|
-
if (typeof raw.current_working === "string") validateTaskId(raw.current_working);
|
|
474
|
-
return raw as unknown as WorkspaceState;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function serializeWorkspace(state: WorkspaceState): string {
|
|
478
|
-
return `${JSON.stringify(state, null, 2)}\n`;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export { serializeWorkspace };
|
|
482
|
-
|
|
483
|
-
export function readWorkspaceStateRaw(root: string): {
|
|
484
|
-
revision: string;
|
|
485
|
-
state: WorkspaceState;
|
|
486
|
-
} {
|
|
487
|
-
const relativePath = stateWorkspacePath();
|
|
488
|
-
if (currentRevision(root, relativePath) === MISSING_REVISION)
|
|
489
|
-
return {
|
|
490
|
-
revision: MISSING_REVISION,
|
|
491
|
-
state: {
|
|
492
|
-
contract: "assurance_kernel/workspace/v1",
|
|
493
|
-
current_working: null,
|
|
494
|
-
},
|
|
495
|
-
};
|
|
496
|
-
const content = readSecureProjectFile(root, relativePath);
|
|
497
|
-
return { revision: revisionFor(content), state: parseWorkspaceContent(content) };
|
|
498
|
-
}
|
|
499
|
-
|
|
651
|
+
/** Converge a tracked evidence file to exact bytes: same bytes are idempotent. */
|
|
500
652
|
function convergeFile(
|
|
501
653
|
root: string,
|
|
502
654
|
relativePath: string,
|
|
@@ -510,91 +662,13 @@ function convergeFile(
|
|
|
510
662
|
throw new KernelStoreConflictError(
|
|
511
663
|
`transaction conflict for ${relativePath}: expected ${expectedRevision} or ${nextRevision}, got ${actualRevision}`,
|
|
512
664
|
);
|
|
513
|
-
return atomicCasWrite(
|
|
514
|
-
root,
|
|
515
|
-
relativePath,
|
|
516
|
-
nextContent,
|
|
517
|
-
expectedRevision,
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function appendJournalLineLocked(root: string, entry: JournalEntry): void {
|
|
522
|
-
const directory = ensureSecureDirectory(root, dirname(JOURNAL_RELATIVE));
|
|
523
|
-
const path = resolve(directory, basename(JOURNAL_RELATIVE));
|
|
524
|
-
assertNoSymlinkSegments(canonicalRoot(root), path);
|
|
525
|
-
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
526
|
-
const fd = openSync(
|
|
527
|
-
path,
|
|
528
|
-
constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | noFollow,
|
|
529
|
-
0o600,
|
|
530
|
-
);
|
|
531
|
-
try {
|
|
532
|
-
writeFileSync(fd, `${JSON.stringify(entry)}\n`, "utf8");
|
|
533
|
-
fsyncSync(fd);
|
|
534
|
-
} finally {
|
|
535
|
-
closeSync(fd);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function withJournalLock<T>(root: string, operation: () => T): T {
|
|
540
|
-
const directory = ensureSecureDirectory(root, dirname(JOURNAL_RELATIVE));
|
|
541
|
-
return withExclusiveLock(resolve(directory, JOURNAL_LOCK_NAME), operation);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
export function appendJournalEntry(root: string, entry: JournalEntry): void {
|
|
545
|
-
withJournalLock(root, () => appendJournalLineLocked(root, entry));
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
export function appendObservationJournalEntry(
|
|
549
|
-
root: string,
|
|
550
|
-
entry: JournalEntry & { observation: V3AuthorityObservation },
|
|
551
|
-
): "appended" | "duplicate" {
|
|
552
|
-
return withJournalLock(root, () => {
|
|
553
|
-
let existing = "";
|
|
554
|
-
try {
|
|
555
|
-
existing = readSecureProjectFile(root, JOURNAL_RELATIVE);
|
|
556
|
-
} catch (error) {
|
|
557
|
-
if (!(error instanceof Error) || !error.message.startsWith("source_missing:"))
|
|
558
|
-
throw error;
|
|
559
|
-
}
|
|
560
|
-
if (Buffer.byteLength(existing, "utf8") > JOURNAL_READ_LIMIT)
|
|
561
|
-
throw new KernelStoreSecurityError("kernel journal exceeds the observation read limit");
|
|
562
|
-
for (const [index, line] of existing.split("\n").entries()) {
|
|
563
|
-
if (!line.trim()) continue;
|
|
564
|
-
let parsed: Record<string, unknown>;
|
|
565
|
-
try {
|
|
566
|
-
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
567
|
-
} catch {
|
|
568
|
-
throw new KernelStoreSecurityError(
|
|
569
|
-
`kernel journal line ${index + 1} is not valid JSON`,
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
const observation = parsed.observation as
|
|
573
|
-
| Record<string, unknown>
|
|
574
|
-
| undefined;
|
|
575
|
-
if (observation?.commit_id !== entry.observation.commit_id) continue;
|
|
576
|
-
if (observation.observation_id === entry.observation.observation_id)
|
|
577
|
-
return "duplicate";
|
|
578
|
-
throw new KernelStoreConflictError(
|
|
579
|
-
`observation commit identity conflict: ${entry.observation.commit_id}`,
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
appendJournalLineLocked(root, entry);
|
|
583
|
-
return "appended";
|
|
584
|
-
});
|
|
665
|
+
return atomicCasWrite(root, relativePath, nextContent, expectedRevision);
|
|
585
666
|
}
|
|
586
667
|
|
|
587
668
|
// ---------------------------------------------------------------------------
|
|
588
|
-
//
|
|
589
|
-
// Uses the existing exclusive store lock and rejects the retired v1 marker.
|
|
669
|
+
// Artifact relocation (documentation artifacts only; S2 removes the relocation).
|
|
590
670
|
// ---------------------------------------------------------------------------
|
|
591
671
|
|
|
592
|
-
const TRANSACTION_PATH_V2 = stateTransactionPath("workspace-transaction-v2.json");
|
|
593
|
-
const ENROLLMENT_MARKER_PATH = stateTransactionPath("enrollment-marker.json");
|
|
594
|
-
const DRAIN_MARKER_PATH = stateTransactionPath("drain-transaction.json");
|
|
595
|
-
const TERMINAL_MARKER_PATH = stateTransactionPath("terminal-transaction.json");
|
|
596
|
-
const AUTHORITY_REPAIR_MARKER_PATH = stateTransactionPath("authority-repair-transaction.json");
|
|
597
|
-
|
|
598
672
|
export interface ArtifactRelocationV1 {
|
|
599
673
|
from_path: string;
|
|
600
674
|
to_path: string;
|
|
@@ -613,89 +687,6 @@ interface WorkspaceTransactionV2 {
|
|
|
613
687
|
|
|
614
688
|
export type { WorkspaceTransactionV2 };
|
|
615
689
|
|
|
616
|
-
export function revisionForContent(content: string): string {
|
|
617
|
-
return revisionFor(content);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function parseWorkspaceTransactionV2(raw: Record<string, unknown>): WorkspaceTransactionV2 {
|
|
621
|
-
const allowed = [
|
|
622
|
-
"contract",
|
|
623
|
-
"task_id",
|
|
624
|
-
"expected_record_hash",
|
|
625
|
-
"next_record_content",
|
|
626
|
-
"expected_workspace_hash",
|
|
627
|
-
"next_workspace_content",
|
|
628
|
-
"artifact_relocations",
|
|
629
|
-
];
|
|
630
|
-
const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
|
|
631
|
-
if (unknown.length > 0)
|
|
632
|
-
throw new KernelStoreSecurityError(
|
|
633
|
-
`workspace transaction v2 has unknown field: ${unknown[0]}`,
|
|
634
|
-
);
|
|
635
|
-
if (raw.contract !== "assurance_kernel/workspace_transaction/v2")
|
|
636
|
-
throw new KernelStoreSecurityError(
|
|
637
|
-
"workspace transaction v2 contract is invalid",
|
|
638
|
-
);
|
|
639
|
-
for (const field of allowed.slice(1, 6)) {
|
|
640
|
-
if (typeof raw[field] !== "string" || !String(raw[field]).trim())
|
|
641
|
-
throw new KernelStoreSecurityError(
|
|
642
|
-
`workspace transaction v2 ${field} is invalid`,
|
|
643
|
-
);
|
|
644
|
-
}
|
|
645
|
-
const relocationRaw = raw.artifact_relocations;
|
|
646
|
-
if (relocationRaw !== undefined && !Array.isArray(relocationRaw))
|
|
647
|
-
throw new KernelStoreSecurityError("workspace transaction v2 artifact_relocations is invalid");
|
|
648
|
-
const artifactRelocations = (relocationRaw ?? []).map((item, index) => {
|
|
649
|
-
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
650
|
-
throw new KernelStoreSecurityError(`artifact relocation ${index} is invalid`);
|
|
651
|
-
const value = item as Record<string, unknown>;
|
|
652
|
-
const unknownFields = Object.keys(value).filter((key) => !["from_path", "to_path", "content_hash"].includes(key));
|
|
653
|
-
if (unknownFields.length > 0)
|
|
654
|
-
throw new KernelStoreSecurityError(`artifact relocation has unknown field: ${unknownFields[0]}`);
|
|
655
|
-
for (const field of ["from_path", "to_path", "content_hash"])
|
|
656
|
-
if (typeof value[field] !== "string" || !String(value[field]).trim())
|
|
657
|
-
throw new KernelStoreSecurityError(`artifact relocation ${field} is invalid`);
|
|
658
|
-
const relocation = value as unknown as ArtifactRelocationV1;
|
|
659
|
-
assertArtifactRelocation(relocation);
|
|
660
|
-
return relocation;
|
|
661
|
-
});
|
|
662
|
-
const transaction = {
|
|
663
|
-
...(raw as unknown as WorkspaceTransactionV2),
|
|
664
|
-
...(artifactRelocations.length > 0 ? { artifact_relocations: artifactRelocations } : {}),
|
|
665
|
-
};
|
|
666
|
-
validateTaskId(transaction.task_id);
|
|
667
|
-
const record = parseTaskRecord(
|
|
668
|
-
JSON.parse(transaction.next_record_content),
|
|
669
|
-
);
|
|
670
|
-
if (record.task_id !== transaction.task_id)
|
|
671
|
-
throw new KernelStoreSecurityError(
|
|
672
|
-
"workspace transaction v2 task identity is inconsistent",
|
|
673
|
-
);
|
|
674
|
-
parseWorkspaceContent(transaction.next_workspace_content);
|
|
675
|
-
return transaction;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
function readPendingTransactionV2(root: string): WorkspaceTransactionV2 | null {
|
|
679
|
-
if (currentRevision(root, TRANSACTION_PATH_V2) === MISSING_REVISION) return null;
|
|
680
|
-
const raw = JSON.parse(
|
|
681
|
-
readSecureProjectFile(root, TRANSACTION_PATH_V2),
|
|
682
|
-
) as Record<string, unknown>;
|
|
683
|
-
return parseWorkspaceTransactionV2(raw);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function removeTransactionMarkerV2(root: string): void {
|
|
687
|
-
const candidate = safeCandidate(root, TRANSACTION_PATH_V2);
|
|
688
|
-
assertNoSymlinkSegments(candidate.root, candidate.path);
|
|
689
|
-
const stat = pathStatOrNull(candidate.path);
|
|
690
|
-
if (!stat) return;
|
|
691
|
-
if (!stat.isFile())
|
|
692
|
-
throw new KernelStoreSecurityError(
|
|
693
|
-
"workspace transaction v2 marker is not a regular file",
|
|
694
|
-
);
|
|
695
|
-
rmSync(candidate.path);
|
|
696
|
-
fsyncDirectory(dirname(candidate.path));
|
|
697
|
-
}
|
|
698
|
-
|
|
699
690
|
function archiveArtifactPath(path: string): string | null {
|
|
700
691
|
const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
|
|
701
692
|
return matched ? `docs/${matched[1]}/archive/${matched[2]}` : null;
|
|
@@ -705,8 +696,8 @@ function assertArtifactRelocation(relocation: ArtifactRelocationV1): void {
|
|
|
705
696
|
if (!/^sha256:[a-f0-9]{64}$/.test(relocation.content_hash))
|
|
706
697
|
throw new KernelStoreSecurityError("artifact relocation content_hash is invalid");
|
|
707
698
|
if (
|
|
708
|
-
archiveArtifactPath(relocation.from_path) !== relocation.to_path
|
|
709
|
-
|
|
699
|
+
archiveArtifactPath(relocation.from_path) !== relocation.to_path &&
|
|
700
|
+
archiveArtifactPath(relocation.to_path) !== relocation.from_path
|
|
710
701
|
)
|
|
711
702
|
throw new KernelStoreSecurityError("artifact relocation paths must be one active/archive pair");
|
|
712
703
|
}
|
|
@@ -730,118 +721,258 @@ function convergeArtifactRelocation(root: string, relocation: ArtifactRelocation
|
|
|
730
721
|
if (dirname(from.path) !== dirname(to.path)) fsyncDirectory(dirname(to.path));
|
|
731
722
|
}
|
|
732
723
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
invokeTestHook: boolean,
|
|
737
|
-
): StoredTaskMutationV3 {
|
|
738
|
-
for (const relocation of transaction.artifact_relocations ?? [])
|
|
739
|
-
convergeArtifactRelocation(root, relocation);
|
|
740
|
-
const taskPath = stateTaskRecordPath(transaction.task_id);
|
|
741
|
-
const taskRevision = convergeFile(
|
|
742
|
-
root,
|
|
743
|
-
taskPath,
|
|
744
|
-
transaction.expected_record_hash,
|
|
745
|
-
transaction.next_record_content,
|
|
746
|
-
);
|
|
747
|
-
if (invokeTestHook) runAfterTaskTransactionWriteHook();
|
|
748
|
-
const workspaceRevision = convergeFile(
|
|
749
|
-
root,
|
|
750
|
-
stateWorkspacePath(),
|
|
751
|
-
transaction.expected_workspace_hash,
|
|
752
|
-
transaction.next_workspace_content,
|
|
753
|
-
);
|
|
754
|
-
const record = parseTaskRecord(
|
|
755
|
-
JSON.parse(transaction.next_record_content),
|
|
756
|
-
);
|
|
757
|
-
const workspace = parseWorkspaceContent(transaction.next_workspace_content);
|
|
758
|
-
removeTransactionMarkerV2(root);
|
|
759
|
-
return {
|
|
760
|
-
revision: taskRevision,
|
|
761
|
-
record,
|
|
762
|
-
workspace: { revision: workspaceRevision, state: workspace },
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function recoverPendingTransactionV2Locked(root: string): void {
|
|
767
|
-
const transaction = readPendingTransactionV2(root);
|
|
768
|
-
if (transaction) completeTransactionV2Locked(root, transaction, false);
|
|
769
|
-
}
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// Test seams.
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
770
727
|
|
|
771
728
|
/**
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
* v1 transaction; the operator must resolve it with the prior runtime first.
|
|
729
|
+
* Test-only seam. Runs after the authority writes of the current transaction
|
|
730
|
+
* and immediately before COMMIT, so a thrown fault must roll the whole
|
|
731
|
+
* transaction back with no partial authority.
|
|
776
732
|
*/
|
|
777
|
-
function
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
/** Reject any retired v1 marker before considering v2 recovery. */
|
|
783
|
-
function recoverAnyPendingTransactionLocked(root: string): void {
|
|
784
|
-
const hasV1 = currentRevision(root, TRANSACTION_PATH) !== MISSING_REVISION;
|
|
785
|
-
const hasV2 = currentRevision(root, TRANSACTION_PATH_V2) !== MISSING_REVISION;
|
|
786
|
-
const hasEnrollment =
|
|
787
|
-
currentRevision(root, ENROLLMENT_MARKER_PATH) !== MISSING_REVISION;
|
|
788
|
-
const hasDrain = currentRevision(root, DRAIN_MARKER_PATH) !== MISSING_REVISION;
|
|
789
|
-
const hasTerminal = currentRevision(root, TERMINAL_MARKER_PATH) !== MISSING_REVISION;
|
|
790
|
-
const hasAuthorityRepair =
|
|
791
|
-
currentRevision(root, AUTHORITY_REPAIR_MARKER_PATH) !== MISSING_REVISION;
|
|
792
|
-
if (hasV1)
|
|
793
|
-
throw new KernelStoreSecurityError(V1_TRANSACTION_RETIRED);
|
|
794
|
-
const markers = [hasV2, hasEnrollment, hasDrain, hasTerminal, hasAuthorityRepair].filter(Boolean).length;
|
|
795
|
-
if (markers > 1)
|
|
796
|
-
throw new KernelStoreSecurityError(
|
|
797
|
-
"simultaneous workspace transaction markers are forbidden",
|
|
798
|
-
);
|
|
799
|
-
if (hasV2) recoverPendingTransactionV2Locked(root);
|
|
800
|
-
if (hasEnrollment) recoverPendingEnrollmentLocked(root);
|
|
801
|
-
if (hasDrain) recoverPendingDrainLocked(root);
|
|
802
|
-
if (hasTerminal) recoverPendingTerminalLocked(root, false);
|
|
803
|
-
if (hasAuthorityRepair) recoverPendingAuthorityRepairLocked(root);
|
|
733
|
+
export function setAfterTaskTransactionWriteForTest(
|
|
734
|
+
hook: (() => void) | null,
|
|
735
|
+
): void {
|
|
736
|
+
setStoreFaultForTest(hook);
|
|
804
737
|
}
|
|
805
738
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const relativePath = stateTaskRecordPath(taskId);
|
|
812
|
-
if (currentRevision(root, relativePath) === MISSING_REVISION)
|
|
813
|
-
return { revision: MISSING_REVISION, record: null };
|
|
814
|
-
const content = readSecureProjectFile(root, relativePath);
|
|
815
|
-
const raw = JSON.parse(content) as { contract?: unknown };
|
|
816
|
-
if (raw.contract === "assurance_kernel/task_record/v2")
|
|
817
|
-
throw new KernelStoreSecurityError(
|
|
818
|
-
"TaskRecord v2 is not supported in the state layout; v2 records belong to the historical audit layout",
|
|
819
|
-
);
|
|
820
|
-
const record = parseTaskRecord(raw);
|
|
821
|
-
if (record.task_id !== taskId)
|
|
822
|
-
throw new KernelStoreSecurityError("task record v3 identity is inconsistent");
|
|
823
|
-
return { revision: revisionFor(content), record };
|
|
739
|
+
let auditExportFaultForTest: (() => void) | null = null;
|
|
740
|
+
|
|
741
|
+
/** Test-only seam: fail the terminal audit export after settlement commits. */
|
|
742
|
+
export function setAuditExportFaultForTest(hook: (() => void) | null): void {
|
|
743
|
+
auditExportFaultForTest = hook;
|
|
824
744
|
}
|
|
825
745
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
746
|
+
function runAuditExportFault(): void {
|
|
747
|
+
const hook = auditExportFaultForTest;
|
|
748
|
+
auditExportFaultForTest = null;
|
|
749
|
+
hook?.();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
// Workspace and TaskRecord reads.
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
|
|
756
|
+
function parseWorkspaceContent(content: string): WorkspaceState {
|
|
757
|
+
const raw = JSON.parse(content) as Record<string, unknown>;
|
|
758
|
+
const unknown = Object.keys(raw).filter(
|
|
759
|
+
(key) => !["contract", "current_working"].includes(key),
|
|
760
|
+
);
|
|
761
|
+
if (unknown.length > 0)
|
|
762
|
+
throw new KernelStoreSecurityError(`workspace has unknown field: ${unknown[0]}`);
|
|
763
|
+
if (raw.contract !== "assurance_kernel/workspace/v1")
|
|
764
|
+
throw new KernelStoreSecurityError("workspace contract is invalid");
|
|
765
|
+
if (
|
|
766
|
+
raw.current_working !== null &&
|
|
767
|
+
(typeof raw.current_working !== "string" || !raw.current_working.trim())
|
|
768
|
+
)
|
|
769
|
+
throw new KernelStoreSecurityError("workspace current_working is invalid");
|
|
770
|
+
if (typeof raw.current_working === "string") validateTaskId(raw.current_working);
|
|
771
|
+
return raw as unknown as WorkspaceState;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function serializeWorkspace(state: WorkspaceState): string {
|
|
775
|
+
return `${JSON.stringify(state, null, 2)}\n`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export { serializeWorkspace };
|
|
779
|
+
|
|
780
|
+
function workspaceStateFromRow(db: DatabaseSync, runId: string | null): WorkspaceState {
|
|
781
|
+
if (!runId) return { contract: "assurance_kernel/workspace/v1", current_working: null };
|
|
782
|
+
const run = readRunRowById(db, runId);
|
|
783
|
+
return {
|
|
784
|
+
contract: "assurance_kernel/workspace/v1",
|
|
785
|
+
current_working: run && run.state === "active" ? run.task_id : null,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The run this worktree currently holds for a task, read straight from the
|
|
791
|
+
* store. Capability validation needs only this identity, so it never pays for
|
|
792
|
+
* a full authority projection on the mutation path.
|
|
793
|
+
*/
|
|
794
|
+
/**
|
|
795
|
+
* The committed result of a terminal operation, if this worktree already
|
|
796
|
+
* committed it. Settlement clears the active run, so a lost response can only
|
|
797
|
+
* be answered from the durable operation row — and only with the committed
|
|
798
|
+
* result, never by re-running the operation.
|
|
799
|
+
*/
|
|
800
|
+
export function readCommittedTerminalResult(
|
|
801
|
+
root: string,
|
|
802
|
+
taskId: string,
|
|
803
|
+
eventId: string,
|
|
804
|
+
requestDigest?: string,
|
|
805
|
+
): { record: TaskRecord; workspace: WorkspaceState } | null {
|
|
806
|
+
validateTaskId(taskId);
|
|
807
|
+
const read = withKernelRead(root, (db) => {
|
|
808
|
+
const row = readOperationRow(db, terminalOperationId(taskId, eventId));
|
|
809
|
+
if (!row) return null;
|
|
810
|
+
const parsed = JSON.parse(row.result_json) as CommittedOperationResult;
|
|
811
|
+
if ((parsed.request_digest ?? null) !== (requestDigest ?? null))
|
|
812
|
+
throw new KernelStoreConflictError(
|
|
813
|
+
`terminal operation for ${taskId} was committed for a different request; resubmit the exact request that settled it`,
|
|
814
|
+
);
|
|
815
|
+
return decodeOperationResult(row.result_json);
|
|
816
|
+
});
|
|
817
|
+
return read ?? null;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The committed result of an enrollment, if this worktree already committed
|
|
822
|
+
* it. A lost response cannot pass the first-execution checks (the capability
|
|
823
|
+
* is consumed and the record exists), so the durable operation is the only
|
|
824
|
+
* correct answer — and only for the exact request that enrolled it.
|
|
825
|
+
*/
|
|
826
|
+
export function readCommittedEnrollmentResult(
|
|
835
827
|
root: string,
|
|
836
828
|
taskId: string,
|
|
829
|
+
eventId: string,
|
|
830
|
+
requestDigest?: string,
|
|
831
|
+
): { record: TaskRecord; workspace: WorkspaceState; claim: BackendClaim } | null {
|
|
832
|
+
validateTaskId(taskId);
|
|
833
|
+
const read = withKernelRead(root, (db) => {
|
|
834
|
+
const row = readOperationRow(db, enrollmentOperationId(taskId, eventId));
|
|
835
|
+
if (!row) return null;
|
|
836
|
+
const parsed = JSON.parse(row.result_json) as CommittedOperationResult;
|
|
837
|
+
if ((parsed.request_digest ?? null) !== (requestDigest ?? null))
|
|
838
|
+
throw new KernelStoreConflictError(
|
|
839
|
+
`enrollment operation for ${taskId} was committed for a different request; resubmit the exact request that enrolled it`,
|
|
840
|
+
);
|
|
841
|
+
const decoded = decodeOperationResult(row.result_json);
|
|
842
|
+
if (typeof parsed.claim_json !== "string")
|
|
843
|
+
throw new KernelStoreConflictError(
|
|
844
|
+
`enrollment operation for ${taskId} has no committed claim`,
|
|
845
|
+
);
|
|
846
|
+
return {
|
|
847
|
+
...decoded,
|
|
848
|
+
claim: parseBackendClaim(JSON.parse(parsed.claim_json) as Record<string, unknown>),
|
|
849
|
+
};
|
|
850
|
+
});
|
|
851
|
+
return read ?? null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* This worktree's run for a task, including terminal runs. Batch commit and
|
|
856
|
+
* recovery read a *settled* child's evidence, so the active-only lookup is not
|
|
857
|
+
* enough, and another worktree's run of the same logical task must never be
|
|
858
|
+
* chosen: the run identity comes from the local store.
|
|
859
|
+
*/
|
|
860
|
+
export function localRunId(root: string, taskId: string): string | null {
|
|
861
|
+
validateTaskId(taskId);
|
|
862
|
+
const row = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
|
|
863
|
+
return row?.run_id ?? null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export function currentRunId(root: string, taskId: string): string | null {
|
|
867
|
+
validateTaskId(taskId);
|
|
868
|
+
const read = withKernelRead(root, (db) => {
|
|
869
|
+
const run = readRunRowByTask(db, taskId);
|
|
870
|
+
return run && run.state === "active" ? run.run_id : null;
|
|
871
|
+
});
|
|
872
|
+
return read ?? null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export function readWorkspaceStateRaw(root: string): {
|
|
876
|
+
revision: string;
|
|
877
|
+
state: WorkspaceState;
|
|
878
|
+
} {
|
|
879
|
+
const read = withKernelRead(root, (db) => {
|
|
880
|
+
const row = readWorkspaceRow(db);
|
|
881
|
+
return {
|
|
882
|
+
// The CAS token is the store's monotonic revision, never a hash of the
|
|
883
|
+
// serialized owner: two idle workspaces with identical content are
|
|
884
|
+
// still different revisions.
|
|
885
|
+
revision: recordRevision(row.revision),
|
|
886
|
+
state: workspaceStateFromRow(db, row.current_run_id),
|
|
887
|
+
};
|
|
888
|
+
});
|
|
889
|
+
if (read) return read;
|
|
890
|
+
return {
|
|
891
|
+
revision: INITIAL_WORKSPACE_REVISION,
|
|
892
|
+
state: { contract: "assurance_kernel/workspace/v1", current_working: null },
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function recordFromRun(run: KernelRunRow): TaskRecord {
|
|
897
|
+
const record = parseTaskRecord(JSON.parse(run.record_json) as Record<string, unknown>);
|
|
898
|
+
if (record.task_id !== run.task_id)
|
|
899
|
+
throw new KernelStoreSecurityError("task record identity is inconsistent with its run");
|
|
900
|
+
return record;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export function readTaskRecordRaw(
|
|
904
|
+
root: string,
|
|
905
|
+
taskId: string,
|
|
906
|
+
): { revision: string; record: TaskRecord | null } {
|
|
907
|
+
validateTaskId(taskId);
|
|
908
|
+
const read = withKernelRead(root, (db) => {
|
|
909
|
+
const run = readRunRowByTask(db, taskId);
|
|
910
|
+
if (!run) return { revision: MISSING_REVISION, record: null };
|
|
911
|
+
// Terminal records stay durable in the store as the run index; the
|
|
912
|
+
// active-record surface mirrors the previous layout, where terminal
|
|
913
|
+
// evidence lives in the immutable audit pair.
|
|
914
|
+
if (run.state !== "active") return { revision: MISSING_REVISION, record: null };
|
|
915
|
+
const record = recordFromRun(run);
|
|
916
|
+
return { revision: canonicalRecordHash(record), record };
|
|
917
|
+
});
|
|
918
|
+
return read ?? { revision: MISSING_REVISION, record: null };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* The committed record straight from the store, for a run of any lifecycle.
|
|
923
|
+
* This is the authority for a settled task; the audit pair is exported
|
|
924
|
+
* evidence and may still be in flight.
|
|
925
|
+
*/
|
|
926
|
+
export function readCommittedRecord(
|
|
927
|
+
root: string,
|
|
928
|
+
taskId: string,
|
|
929
|
+
): { revision: string; record: TaskRecord } | null {
|
|
930
|
+
validateTaskId(taskId);
|
|
931
|
+
const read = withKernelRead(root, (db) => {
|
|
932
|
+
const run = readRunRowByTask(db, taskId);
|
|
933
|
+
if (!run) return null;
|
|
934
|
+
// A settled run always carries the proof its settlement committed; a store
|
|
935
|
+
// missing it is corrupt and must never project as a settled task.
|
|
936
|
+
if (run.state !== "active" && run.terminal_proof_json === null)
|
|
937
|
+
throw new KernelStoreConflictError(
|
|
938
|
+
`task ${taskId} is ${run.state} without a committed terminal proof`,
|
|
939
|
+
);
|
|
940
|
+
const record = recordFromRun(run);
|
|
941
|
+
return { revision: canonicalRecordHash(record), record };
|
|
942
|
+
});
|
|
943
|
+
return read ?? null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Read the immutable terminal audit pair for one task:
|
|
948
|
+
* `.imm/audit/<task-id>/task-record.json` plus `terminal-proof.json`.
|
|
949
|
+
* Both files must exist, be identity-consistent, and the proof's
|
|
950
|
+
* `final_record_hash` must equal the record bytes' revision. Only ENOENT on
|
|
951
|
+
* the whole task directory means absent; a partial pair fails closed.
|
|
952
|
+
* The audit record may be TaskRecord v3/v4 or historical terminal v2.
|
|
953
|
+
*/
|
|
954
|
+
export function readAuditTaskPair(
|
|
955
|
+
root: string,
|
|
956
|
+
taskId: string,
|
|
957
|
+
runId?: string,
|
|
837
958
|
): {
|
|
838
959
|
recordRevision: string;
|
|
839
|
-
record:
|
|
960
|
+
record: TaskRecordV2 | TaskRecordV3 | TaskRecord;
|
|
840
961
|
proof: TaskTombstone;
|
|
841
962
|
} | null {
|
|
842
963
|
validateTaskId(taskId);
|
|
843
|
-
|
|
844
|
-
|
|
964
|
+
let recordPath: string;
|
|
965
|
+
let proofPath: string;
|
|
966
|
+
if (runId !== undefined) {
|
|
967
|
+
// An exact run's own evidence, with no fallback: a run that has not
|
|
968
|
+
// exported yet reads as absent rather than as another run's proof.
|
|
969
|
+
recordPath = auditRunRecordPath(taskId, runId);
|
|
970
|
+
proofPath = auditRunTerminalProofPath(taskId, runId);
|
|
971
|
+
} else {
|
|
972
|
+
const resolved = auditEvidencePaths(root, taskId);
|
|
973
|
+
recordPath = resolved.record;
|
|
974
|
+
proofPath = resolved.proof;
|
|
975
|
+
}
|
|
845
976
|
const recordRevision = currentRevision(root, recordPath);
|
|
846
977
|
const proofRevision = currentRevision(root, proofPath);
|
|
847
978
|
if (recordRevision === MISSING_REVISION && proofRevision === MISSING_REVISION)
|
|
@@ -853,15 +984,11 @@ export function readAuditTaskPair(
|
|
|
853
984
|
JSON.parse(readSecureProjectFile(root, proofPath)) as Record<string, unknown>,
|
|
854
985
|
);
|
|
855
986
|
if (proof.task_id !== taskId)
|
|
856
|
-
throw new KernelStoreSecurityError(
|
|
857
|
-
"terminal audit proof identity is inconsistent",
|
|
858
|
-
);
|
|
987
|
+
throw new KernelStoreSecurityError("terminal audit proof identity is inconsistent");
|
|
859
988
|
if (proof.final_record_hash !== recordRevision)
|
|
860
|
-
throw new KernelStoreSecurityError(
|
|
861
|
-
"terminal audit proof does not match its task record",
|
|
862
|
-
);
|
|
989
|
+
throw new KernelStoreSecurityError("terminal audit proof does not match its task record");
|
|
863
990
|
const raw = JSON.parse(recordContent) as { contract?: unknown };
|
|
864
|
-
let record:
|
|
991
|
+
let record: TaskRecordV2 | TaskRecordV3 | TaskRecord;
|
|
865
992
|
if (raw.contract === "assurance_kernel/task_record/v2") {
|
|
866
993
|
const legacy = parseTaskRecordV2(raw);
|
|
867
994
|
if (
|
|
@@ -873,13 +1000,18 @@ export function readAuditTaskPair(
|
|
|
873
1000
|
);
|
|
874
1001
|
record = legacy;
|
|
875
1002
|
} else {
|
|
876
|
-
|
|
1003
|
+
let current: TaskRecordV3 | TaskRecord;
|
|
1004
|
+
try {
|
|
1005
|
+
current = parseTaskRecord(raw);
|
|
1006
|
+
} catch {
|
|
1007
|
+
current = parseTaskRecordV3(raw);
|
|
1008
|
+
}
|
|
877
1009
|
if (
|
|
878
1010
|
current.task_id !== taskId ||
|
|
879
1011
|
(current.lifecycle !== "done" && current.lifecycle !== "stopped")
|
|
880
1012
|
)
|
|
881
1013
|
throw new KernelStoreSecurityError(
|
|
882
|
-
"audit TaskRecord
|
|
1014
|
+
"audit TaskRecord must be terminal and identity-consistent",
|
|
883
1015
|
);
|
|
884
1016
|
record = current;
|
|
885
1017
|
}
|
|
@@ -890,621 +1022,406 @@ export function readTaskRecord(
|
|
|
890
1022
|
root: string,
|
|
891
1023
|
taskId: string,
|
|
892
1024
|
): { revision: string; record: TaskRecord | null } {
|
|
893
|
-
return
|
|
1025
|
+
return readTaskRecordRaw(root, taskId);
|
|
894
1026
|
}
|
|
895
1027
|
|
|
896
|
-
|
|
897
|
-
export
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
)
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
next_record_content: `${JSON.stringify(nextRecord, null, 2)}\n`,
|
|
911
|
-
expected_workspace_hash: expectedWorkspaceHash,
|
|
912
|
-
next_workspace_content: serializeWorkspace(nextWorkspace),
|
|
913
|
-
...(artifactRelocations.length > 0 ? { artifact_relocations: artifactRelocations } : {}),
|
|
914
|
-
};
|
|
915
|
-
atomicCasWrite(
|
|
1028
|
+
// ---------------------------------------------------------------------------
|
|
1029
|
+
// Audit export: deterministic, idempotent, retryable, never authority.
|
|
1030
|
+
// ---------------------------------------------------------------------------
|
|
1031
|
+
|
|
1032
|
+
function exportTerminalAudit(root: string, run: KernelRunRow): void {
|
|
1033
|
+
runAuditExportFault();
|
|
1034
|
+
if (!run.terminal_proof_json)
|
|
1035
|
+
throw new KernelStoreSecurityError(
|
|
1036
|
+
`terminal run ${run.run_id} has no committed terminal proof`,
|
|
1037
|
+
);
|
|
1038
|
+
// Keyed by run: another worktree's run of the same logical task owns its own
|
|
1039
|
+
// audit directory, so neither export can block the other.
|
|
1040
|
+
convergeFile(root, auditRunRecordPath(run.task_id, run.run_id), MISSING_REVISION, run.record_json);
|
|
1041
|
+
convergeFile(
|
|
916
1042
|
root,
|
|
917
|
-
|
|
918
|
-
`${JSON.stringify(transaction, null, 2)}\n`,
|
|
1043
|
+
auditRunTerminalProofPath(run.task_id, run.run_id),
|
|
919
1044
|
MISSING_REVISION,
|
|
1045
|
+
run.terminal_proof_json,
|
|
920
1046
|
);
|
|
921
|
-
try {
|
|
922
|
-
return completeTransactionV2Locked(root, transaction, true);
|
|
923
|
-
} catch (error) {
|
|
924
|
-
try {
|
|
925
|
-
return completeTransactionV2Locked(root, transaction, false);
|
|
926
|
-
} catch (recoveryError) {
|
|
927
|
-
throw new KernelStoreConflictError(
|
|
928
|
-
`kernel v2 transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}; recovery: ${recoveryError instanceof Error ? recoveryError.message : recoveryError}`,
|
|
929
|
-
);
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
1047
|
}
|
|
933
1048
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
export type KernelAuthorityState =
|
|
944
|
-
| "unowned"
|
|
945
|
-
| "active_owner"
|
|
946
|
-
| "terminal_owner"
|
|
947
|
-
| "repairable_stale_claim"
|
|
948
|
-
| "authority_conflict";
|
|
949
|
-
|
|
950
|
-
export interface KernelAuthorityProjection {
|
|
951
|
-
contract: "assurance_kernel/authority_projection/v1";
|
|
952
|
-
requested_task_id: string;
|
|
953
|
-
state: KernelAuthorityState;
|
|
954
|
-
owner_task_id: string | null;
|
|
955
|
-
owner_lifecycle: TaskLifecycle | null;
|
|
956
|
-
claim_lifecycle_status: BackendClaim["lifecycle_status"] | null;
|
|
957
|
-
diagnostic: string | null;
|
|
958
|
-
revision: string;
|
|
1049
|
+
/** Retry any terminal settlement whose audit export did not complete. */
|
|
1050
|
+
function retryPendingAuditExports(root: string, db: DatabaseSync): void {
|
|
1051
|
+
for (const run of listPendingAuditExports(db)) {
|
|
1052
|
+
exportTerminalAudit(root, run);
|
|
1053
|
+
markAuditExported(db, run.run_id, nowIso());
|
|
1054
|
+
}
|
|
959
1055
|
}
|
|
960
1056
|
|
|
961
1057
|
/**
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
1058
|
+
* Converge document relocations that a committed record write still owes.
|
|
1059
|
+
* Relocation is a file move, so it runs after COMMIT and stays idempotent:
|
|
1060
|
+
* an interruption leaves the committed record authoritative and the move is
|
|
1061
|
+
* retried under the next lock.
|
|
965
1062
|
*/
|
|
966
|
-
function
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
)
|
|
970
|
-
|
|
971
|
-
const claim = readBackendClaim(root);
|
|
972
|
-
const ownerTaskId = claim?.task_id ?? null;
|
|
973
|
-
const inspectedTaskId = ownerTaskId ?? taskId;
|
|
974
|
-
const stateRecord = readTaskRecordRaw(root, inspectedTaskId);
|
|
975
|
-
const auditPair = readAuditTaskPair(root, inspectedTaskId);
|
|
976
|
-
const workspace = readWorkspaceStateRaw(root).state;
|
|
977
|
-
const record = stateRecord.record;
|
|
978
|
-
const auditRecord = auditPair?.record;
|
|
979
|
-
const authorityRecord = record
|
|
980
|
-
? {
|
|
981
|
-
task_id: record.task_id,
|
|
982
|
-
lifecycle: record.lifecycle,
|
|
983
|
-
intent_revision: record.intent_snapshot.revision,
|
|
984
|
-
intent_content_hash: record.intent_ref.content_hash,
|
|
985
|
-
}
|
|
986
|
-
: auditRecord
|
|
987
|
-
? "phase" in auditRecord
|
|
988
|
-
? {
|
|
989
|
-
task_id: auditRecord.task_id,
|
|
990
|
-
lifecycle: auditRecord.phase as "done" | "stopped",
|
|
991
|
-
intent_revision: auditRecord.intent_revision,
|
|
992
|
-
intent_content_hash: auditRecord.intent_ref.content_hash,
|
|
993
|
-
}
|
|
994
|
-
: {
|
|
995
|
-
task_id: auditRecord.task_id,
|
|
996
|
-
lifecycle: auditRecord.lifecycle,
|
|
997
|
-
intent_revision: auditRecord.intent_snapshot.revision,
|
|
998
|
-
intent_content_hash: auditRecord.intent_ref.content_hash,
|
|
999
|
-
}
|
|
1000
|
-
: null;
|
|
1001
|
-
const terminal = auditPair !== null;
|
|
1002
|
-
// review-1: a state record must never coexist with a terminal audit
|
|
1003
|
-
// pair; only the settlement marker may hold them transiently, and a
|
|
1004
|
-
// present marker routes to recovery/conflict before this projection.
|
|
1005
|
-
const duplicateStateAndAudit = Boolean(stateRecord.record && auditPair);
|
|
1006
|
-
const matchingTerminalProof = Boolean(auditPair && workspace.current_working === null);
|
|
1007
|
-
const matchingClaimIdentity = Boolean(
|
|
1008
|
-
claim &&
|
|
1009
|
-
authorityRecord &&
|
|
1010
|
-
claim.task_id === authorityRecord.task_id &&
|
|
1011
|
-
claim.intent_revision === authorityRecord.intent_revision &&
|
|
1012
|
-
claim.intent_content_hash === authorityRecord.intent_content_hash,
|
|
1013
|
-
);
|
|
1014
|
-
const sameOwner = claim ? workspace.current_working === claim.task_id : workspace.current_working === null;
|
|
1015
|
-
const revision = revisionForContent(JSON.stringify({ claim, stateRecord, auditPair, workspace }));
|
|
1016
|
-
|
|
1017
|
-
if (duplicateStateAndAudit)
|
|
1018
|
-
return {
|
|
1019
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1020
|
-
requested_task_id: taskId,
|
|
1021
|
-
state: "authority_conflict",
|
|
1022
|
-
owner_task_id: claim?.task_id ?? null,
|
|
1023
|
-
owner_lifecycle: authorityRecord?.lifecycle ?? null,
|
|
1024
|
-
claim_lifecycle_status: claim?.lifecycle_status ?? null,
|
|
1025
|
-
diagnostic: `simultaneous state record and terminal audit pair for ${inspectedTaskId}; resolve or recover before authority interpretation`,
|
|
1026
|
-
revision,
|
|
1027
|
-
};
|
|
1028
|
-
if (claim && !sameOwner && !matchingTerminalProof)
|
|
1029
|
-
return {
|
|
1030
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1031
|
-
requested_task_id: taskId,
|
|
1032
|
-
state: "authority_conflict",
|
|
1033
|
-
owner_task_id: claim.task_id,
|
|
1034
|
-
owner_lifecycle: authorityRecord?.lifecycle ?? null,
|
|
1035
|
-
claim_lifecycle_status: claim.lifecycle_status,
|
|
1036
|
-
diagnostic: `workspace owner ${workspace.current_working ?? "null"} contradicts claim ${claim.task_id}`,
|
|
1037
|
-
revision,
|
|
1038
|
-
};
|
|
1039
|
-
if (claim && !authorityRecord)
|
|
1040
|
-
return {
|
|
1041
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1042
|
-
requested_task_id: taskId,
|
|
1043
|
-
state: "authority_conflict",
|
|
1044
|
-
owner_task_id: claim.task_id,
|
|
1045
|
-
owner_lifecycle: null,
|
|
1046
|
-
claim_lifecycle_status: claim.lifecycle_status,
|
|
1047
|
-
diagnostic: `claim ${claim.task_id} has no TaskRecord`,
|
|
1048
|
-
revision,
|
|
1049
|
-
};
|
|
1050
|
-
if (claim && matchingTerminalProof && matchingClaimIdentity)
|
|
1051
|
-
return {
|
|
1052
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1053
|
-
requested_task_id: taskId,
|
|
1054
|
-
state: "repairable_stale_claim",
|
|
1055
|
-
owner_task_id: claim.task_id,
|
|
1056
|
-
owner_lifecycle: authorityRecord?.lifecycle ?? null,
|
|
1057
|
-
claim_lifecycle_status: claim.lifecycle_status,
|
|
1058
|
-
diagnostic: null,
|
|
1059
|
-
revision,
|
|
1060
|
-
};
|
|
1061
|
-
if (claim && terminal)
|
|
1062
|
-
return {
|
|
1063
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1064
|
-
requested_task_id: taskId,
|
|
1065
|
-
state: "authority_conflict",
|
|
1066
|
-
owner_task_id: claim.task_id,
|
|
1067
|
-
owner_lifecycle: authorityRecord?.lifecycle ?? null,
|
|
1068
|
-
claim_lifecycle_status: claim.lifecycle_status,
|
|
1069
|
-
diagnostic: `claim ${claim.task_id} has contradictory terminal ownership evidence`,
|
|
1070
|
-
revision,
|
|
1071
|
-
};
|
|
1072
|
-
if (claim)
|
|
1073
|
-
return {
|
|
1074
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1075
|
-
requested_task_id: taskId,
|
|
1076
|
-
state: "active_owner",
|
|
1077
|
-
owner_task_id: claim.task_id,
|
|
1078
|
-
owner_lifecycle: authorityRecord?.lifecycle ?? null,
|
|
1079
|
-
claim_lifecycle_status: claim.lifecycle_status,
|
|
1080
|
-
diagnostic: null,
|
|
1081
|
-
revision,
|
|
1082
|
-
};
|
|
1083
|
-
if (matchingTerminalProof)
|
|
1084
|
-
return {
|
|
1085
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1086
|
-
requested_task_id: taskId,
|
|
1087
|
-
state: "terminal_owner",
|
|
1088
|
-
owner_task_id: inspectedTaskId,
|
|
1089
|
-
owner_lifecycle: authorityRecord?.lifecycle ?? null,
|
|
1090
|
-
claim_lifecycle_status: null,
|
|
1091
|
-
diagnostic: null,
|
|
1092
|
-
revision,
|
|
1093
|
-
};
|
|
1094
|
-
if (authorityRecord || workspace.current_working !== null)
|
|
1095
|
-
return {
|
|
1096
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1097
|
-
requested_task_id: taskId,
|
|
1098
|
-
state: "authority_conflict",
|
|
1099
|
-
owner_task_id: workspace.current_working,
|
|
1100
|
-
owner_lifecycle: authorityRecord?.lifecycle ?? null,
|
|
1101
|
-
claim_lifecycle_status: null,
|
|
1102
|
-
diagnostic: "nonterminal owner state exists without a backend claim",
|
|
1103
|
-
revision,
|
|
1104
|
-
};
|
|
1105
|
-
return {
|
|
1106
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1107
|
-
requested_task_id: taskId,
|
|
1108
|
-
state: "unowned",
|
|
1109
|
-
owner_task_id: null,
|
|
1110
|
-
owner_lifecycle: null,
|
|
1111
|
-
claim_lifecycle_status: null,
|
|
1112
|
-
diagnostic: null,
|
|
1113
|
-
revision,
|
|
1114
|
-
};
|
|
1115
|
-
|
|
1116
|
-
} catch (error) {
|
|
1117
|
-
return {
|
|
1118
|
-
contract: "assurance_kernel/authority_projection/v1",
|
|
1119
|
-
requested_task_id: taskId,
|
|
1120
|
-
state: "authority_conflict",
|
|
1121
|
-
owner_task_id: null,
|
|
1122
|
-
owner_lifecycle: null,
|
|
1123
|
-
claim_lifecycle_status: null,
|
|
1124
|
-
diagnostic: error instanceof Error ? error.message : String(error),
|
|
1125
|
-
revision: "",
|
|
1126
|
-
};
|
|
1063
|
+
function convergePendingRelocations(root: string, db: DatabaseSync): void {
|
|
1064
|
+
for (const run of listPendingRelocations(db)) {
|
|
1065
|
+
const relocations = JSON.parse(run.pending_relocations_json ?? "[]") as ArtifactRelocationV1[];
|
|
1066
|
+
for (const relocation of relocations) convergeArtifactRelocation(root, relocation);
|
|
1067
|
+
setPendingRelocations(db, run.run_id, null);
|
|
1127
1068
|
}
|
|
1128
1069
|
}
|
|
1129
1070
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
): KernelAuthorityProjection {
|
|
1134
|
-
validateTaskId(taskId);
|
|
1135
|
-
return withKernelStoreLock(root, () => projectKernelAuthorityLocked(root, taskId));
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
interface AuthorityRepairMarker {
|
|
1139
|
-
contract: "assurance_kernel/authority_repair_transaction/v1";
|
|
1140
|
-
task_id: string;
|
|
1141
|
-
expected_projection_revision: string;
|
|
1142
|
-
expected_claim_content: string;
|
|
1143
|
-
at: string;
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
function readPendingAuthorityRepairMarker(root: string): AuthorityRepairMarker | null {
|
|
1147
|
-
if (currentRevision(root, AUTHORITY_REPAIR_MARKER_PATH) === MISSING_REVISION) return null;
|
|
1148
|
-
const raw = JSON.parse(
|
|
1149
|
-
readSecureProjectFile(root, AUTHORITY_REPAIR_MARKER_PATH),
|
|
1150
|
-
) as Record<string, unknown>;
|
|
1151
|
-
const allowed = [
|
|
1152
|
-
"contract",
|
|
1153
|
-
"task_id",
|
|
1154
|
-
"expected_projection_revision",
|
|
1155
|
-
"expected_claim_content",
|
|
1156
|
-
"at",
|
|
1157
|
-
];
|
|
1158
|
-
const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
|
|
1159
|
-
if (unknown.length > 0)
|
|
1160
|
-
throw new KernelStoreSecurityError(
|
|
1161
|
-
`authority repair marker has unknown field: ${unknown[0]}`,
|
|
1162
|
-
);
|
|
1163
|
-
if (raw.contract !== "assurance_kernel/authority_repair_transaction/v1")
|
|
1164
|
-
throw new KernelStoreSecurityError("authority repair marker contract is invalid");
|
|
1165
|
-
for (const field of ["task_id", "expected_projection_revision", "expected_claim_content", "at"]) {
|
|
1166
|
-
if (typeof raw[field] !== "string" || !String(raw[field]).trim())
|
|
1167
|
-
throw new KernelStoreSecurityError(`authority repair marker ${field} is invalid`);
|
|
1168
|
-
}
|
|
1169
|
-
const marker = raw as unknown as AuthorityRepairMarker;
|
|
1170
|
-
validateTaskId(marker.task_id);
|
|
1171
|
-
const claim = parseBackendClaim(JSON.parse(marker.expected_claim_content));
|
|
1172
|
-
if (claim.task_id !== marker.task_id)
|
|
1173
|
-
throw new KernelStoreSecurityError("authority repair claim identity is inconsistent");
|
|
1174
|
-
return marker;
|
|
1175
|
-
}
|
|
1071
|
+
// ---------------------------------------------------------------------------
|
|
1072
|
+
// Journal.
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1176
1074
|
|
|
1177
|
-
function
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
function recoverPendingAuthorityRepairLocked(root: string): void {
|
|
1189
|
-
const marker = readPendingAuthorityRepairMarker(root);
|
|
1190
|
-
if (!marker) return;
|
|
1191
|
-
const projection = projectKernelAuthorityLocked(root, marker.task_id);
|
|
1192
|
-
const claimRevision = currentRevision(root, CLAIM_RELATIVE_PATH);
|
|
1193
|
-
if (claimRevision === MISSING_REVISION) {
|
|
1194
|
-
if (
|
|
1195
|
-
projection.state !== "terminal_owner" ||
|
|
1196
|
-
projection.owner_task_id !== marker.task_id
|
|
1197
|
-
)
|
|
1198
|
-
throw new KernelStoreConflictError(
|
|
1199
|
-
"authority repair committed claim removal but terminal proof changed",
|
|
1075
|
+
export function appendJournalEntry(root: string, entry: JournalEntry): void {
|
|
1076
|
+
withKernelStoreLock(root, () => {
|
|
1077
|
+
withKernelTransaction(root, (db) => {
|
|
1078
|
+
appendJournalRow(
|
|
1079
|
+
db,
|
|
1080
|
+
JSON.stringify(entry),
|
|
1081
|
+
entry.task_id,
|
|
1082
|
+
entry.observation?.commit_id ?? null,
|
|
1083
|
+
entry.observation?.observation_id ?? null,
|
|
1200
1084
|
);
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
}
|
|
1204
|
-
if (
|
|
1205
|
-
projection.state !== "repairable_stale_claim" ||
|
|
1206
|
-
projection.owner_task_id !== marker.task_id ||
|
|
1207
|
-
projection.revision !== marker.expected_projection_revision ||
|
|
1208
|
-
claimRevision !== revisionFor(marker.expected_claim_content)
|
|
1209
|
-
)
|
|
1210
|
-
throw new KernelStoreConflictError(
|
|
1211
|
-
"authority repair facts changed after confirmation",
|
|
1212
|
-
);
|
|
1213
|
-
const claimCandidate = safeCandidate(root, CLAIM_RELATIVE_PATH);
|
|
1214
|
-
assertNoSymlinkSegments(claimCandidate.root, claimCandidate.path);
|
|
1215
|
-
rmSync(claimCandidate.path);
|
|
1216
|
-
fsyncDirectory(dirname(claimCandidate.path));
|
|
1217
|
-
removeAuthorityRepairMarker(root);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1218
1087
|
}
|
|
1219
1088
|
|
|
1220
|
-
|
|
1221
|
-
export function repairKernelAuthority(
|
|
1089
|
+
export function appendObservationJournalEntry(
|
|
1222
1090
|
root: string,
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
const projection = projectKernelAuthorityLocked(root, taskId);
|
|
1230
|
-
if (
|
|
1231
|
-
projection.state !== "repairable_stale_claim" ||
|
|
1232
|
-
projection.owner_task_id !== taskId ||
|
|
1233
|
-
projection.revision !== expectedProjectionRevision
|
|
1234
|
-
)
|
|
1235
|
-
throw new KernelStoreConflictError("authority repair requires exact stale terminal proof");
|
|
1236
|
-
const marker: AuthorityRepairMarker = {
|
|
1237
|
-
contract: "assurance_kernel/authority_repair_transaction/v1",
|
|
1238
|
-
task_id: taskId,
|
|
1239
|
-
expected_projection_revision: expectedProjectionRevision,
|
|
1240
|
-
expected_claim_content: readSecureProjectFile(root, CLAIM_RELATIVE_PATH),
|
|
1241
|
-
at,
|
|
1242
|
-
};
|
|
1243
|
-
atomicCasWrite(
|
|
1244
|
-
root,
|
|
1245
|
-
AUTHORITY_REPAIR_MARKER_PATH,
|
|
1246
|
-
`${JSON.stringify(marker, null, 2)}\n`,
|
|
1247
|
-
MISSING_REVISION,
|
|
1248
|
-
);
|
|
1249
|
-
try {
|
|
1250
|
-
recoverPendingAuthorityRepairLocked(root);
|
|
1251
|
-
} catch (error) {
|
|
1091
|
+
entry: JournalEntry & { observation: V3AuthorityObservation },
|
|
1092
|
+
): "appended" | "duplicate" {
|
|
1093
|
+
return withKernelTransaction(root, (db) => {
|
|
1094
|
+
const existing = findJournalObservation(db, entry.observation.commit_id);
|
|
1095
|
+
if (existing) {
|
|
1096
|
+
if (existing.observation_id === entry.observation.observation_id) return "duplicate";
|
|
1252
1097
|
throw new KernelStoreConflictError(
|
|
1253
|
-
`
|
|
1098
|
+
`observation commit identity conflict: ${entry.observation.commit_id}`,
|
|
1254
1099
|
);
|
|
1255
1100
|
}
|
|
1256
|
-
|
|
1101
|
+
appendJournalRow(
|
|
1102
|
+
db,
|
|
1103
|
+
JSON.stringify(entry),
|
|
1104
|
+
entry.task_id,
|
|
1105
|
+
entry.observation.commit_id,
|
|
1106
|
+
entry.observation.observation_id,
|
|
1107
|
+
);
|
|
1108
|
+
return "appended";
|
|
1257
1109
|
});
|
|
1258
1110
|
}
|
|
1259
1111
|
|
|
1260
|
-
// P2B0 enrollment marker. The enrollment transaction embeds the v2
|
|
1261
|
-
// task/workspace transaction plus the backend claim; recovery completes the
|
|
1262
|
-
// v2 convergence then re-writes the claim if it was not yet durable.
|
|
1263
1112
|
// ---------------------------------------------------------------------------
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
contract: "assurance_kernel/enrollment_transaction/v1";
|
|
1267
|
-
task_id: string;
|
|
1268
|
-
transaction: WorkspaceTransactionV2;
|
|
1269
|
-
claim: Record<string, unknown>;
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
function readPendingEnrollmentMarker(root: string): EnrollmentMarker | null {
|
|
1273
|
-
if (currentRevision(root, ENROLLMENT_MARKER_PATH) === MISSING_REVISION) return null;
|
|
1274
|
-
const raw = JSON.parse(
|
|
1275
|
-
readSecureProjectFile(root, ENROLLMENT_MARKER_PATH),
|
|
1276
|
-
) as Record<string, unknown>;
|
|
1277
|
-
const allowed = ["contract", "task_id", "transaction", "claim"];
|
|
1278
|
-
const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
|
|
1279
|
-
if (unknown.length > 0)
|
|
1280
|
-
throw new KernelStoreSecurityError(
|
|
1281
|
-
`enrollment marker has unknown field: ${unknown[0]}`,
|
|
1282
|
-
);
|
|
1283
|
-
if (raw.contract !== "assurance_kernel/enrollment_transaction/v1")
|
|
1284
|
-
throw new KernelStoreSecurityError(
|
|
1285
|
-
"enrollment marker contract is invalid",
|
|
1286
|
-
);
|
|
1287
|
-
if (typeof raw.task_id !== "string" || !raw.task_id.trim())
|
|
1288
|
-
throw new KernelStoreSecurityError(
|
|
1289
|
-
"enrollment marker task_id is invalid",
|
|
1290
|
-
);
|
|
1291
|
-
const transaction = parseWorkspaceTransactionV2(
|
|
1292
|
-
raw.transaction as Record<string, unknown>,
|
|
1293
|
-
);
|
|
1294
|
-
if (transaction.task_id !== raw.task_id)
|
|
1295
|
-
throw new KernelStoreSecurityError(
|
|
1296
|
-
"enrollment marker task identity is inconsistent",
|
|
1297
|
-
);
|
|
1298
|
-
return {
|
|
1299
|
-
contract: "assurance_kernel/enrollment_transaction/v1",
|
|
1300
|
-
task_id: raw.task_id,
|
|
1301
|
-
transaction,
|
|
1302
|
-
claim: raw.claim as Record<string, unknown>,
|
|
1303
|
-
};
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
function removeEnrollmentMarker(root: string): void {
|
|
1307
|
-
const candidate = safeCandidate(root, ENROLLMENT_MARKER_PATH);
|
|
1308
|
-
assertNoSymlinkSegments(candidate.root, candidate.path);
|
|
1309
|
-
const stat = pathStatOrNull(candidate.path);
|
|
1310
|
-
if (!stat) return;
|
|
1311
|
-
if (!stat.isFile())
|
|
1312
|
-
throw new KernelStoreSecurityError(
|
|
1313
|
-
"enrollment marker is not a regular file",
|
|
1314
|
-
);
|
|
1315
|
-
rmSync(candidate.path);
|
|
1316
|
-
fsyncDirectory(dirname(candidate.path));
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/** Recover a pending enrollment marker: complete the embedded v2 transaction, then re-write the claim. */
|
|
1320
|
-
function recoverPendingEnrollmentLocked(root: string): void {
|
|
1321
|
-
const marker = readPendingEnrollmentMarker(root);
|
|
1322
|
-
if (!marker) return;
|
|
1323
|
-
const transaction = marker.transaction;
|
|
1324
|
-
convergeFile(
|
|
1325
|
-
root,
|
|
1326
|
-
stateTaskRecordPath(transaction.task_id),
|
|
1327
|
-
transaction.expected_record_hash,
|
|
1328
|
-
transaction.next_record_content,
|
|
1329
|
-
);
|
|
1330
|
-
convergeFile(
|
|
1331
|
-
root,
|
|
1332
|
-
stateWorkspacePath(),
|
|
1333
|
-
transaction.expected_workspace_hash,
|
|
1334
|
-
transaction.next_workspace_content,
|
|
1335
|
-
);
|
|
1336
|
-
// re-write the backend claim (last step of the enrollment transaction)
|
|
1337
|
-
atomicCasWrite(
|
|
1338
|
-
root,
|
|
1339
|
-
stateClaimPath(),
|
|
1340
|
-
`${JSON.stringify(marker.claim, null, 2)}\n`,
|
|
1341
|
-
MISSING_REVISION,
|
|
1342
|
-
);
|
|
1343
|
-
removeEnrollmentMarker(root);
|
|
1344
|
-
}
|
|
1113
|
+
// Locked transaction boundary.
|
|
1114
|
+
// ---------------------------------------------------------------------------
|
|
1345
1115
|
|
|
1346
1116
|
/**
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1117
|
+
* Run one authority operation under the workspace write transaction. Retired
|
|
1118
|
+
* file-store authority blocks every mutation, and interrupted audit exports are
|
|
1119
|
+
* retried before the operation observes state.
|
|
1349
1120
|
*/
|
|
1350
|
-
|
|
1121
|
+
/**
|
|
1122
|
+
* Run one authority operation under the workspace write transaction for an
|
|
1123
|
+
* explicit task. A leftover derived claim/owner file whose task the store
|
|
1124
|
+
* already superseded is retired here; anything else fails closed.
|
|
1125
|
+
*/
|
|
1126
|
+
export function withKernelStoreLockForTask<T>(
|
|
1351
1127
|
root: string,
|
|
1352
1128
|
taskId: string,
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
)
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
};
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1129
|
+
operation: () => T,
|
|
1130
|
+
): T {
|
|
1131
|
+
validateTaskId(taskId);
|
|
1132
|
+
return withKernelTransaction(root, (db) => {
|
|
1133
|
+
assertNoRetiredFileStore(root, db, taskId);
|
|
1134
|
+
convergePendingRelocations(root, db);
|
|
1135
|
+
retryPendingAuditExports(root, db);
|
|
1136
|
+
return operation();
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Converge committed-but-unapplied store work (pending artifact relocations and
|
|
1142
|
+
* terminal audit exports) before a Host reads anything the record points at.
|
|
1143
|
+
* Both Hosts call this at their mutation entry, so a freeze interrupted between
|
|
1144
|
+
* its commit and its file moves completes instead of failing the next read.
|
|
1145
|
+
*/
|
|
1146
|
+
export function recoverKernelStoreFollowUps(root: string, taskId?: string): void {
|
|
1147
|
+
if (typeof taskId === "string" && taskId.length > 0)
|
|
1148
|
+
return void withKernelStoreLockForTask(root, taskId, () => undefined);
|
|
1149
|
+
return void withKernelStoreLock(root, () => undefined);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
export function withKernelStoreLock<T>(root: string, operation: () => T): T {
|
|
1153
|
+
const result = withKernelTransaction(root, (db) => {
|
|
1154
|
+
assertNoRetiredFileStore(root);
|
|
1155
|
+
return operation();
|
|
1156
|
+
});
|
|
1157
|
+
// Deterministic follow-ups run after the authority transaction committed:
|
|
1158
|
+
// document relocation and audit evidence can never roll back or revive an
|
|
1159
|
+
// already settled run, and an interruption stays retryable here.
|
|
1160
|
+
retryStoreFollowUps(root);
|
|
1161
|
+
return result;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Converge owed document relocations and interrupted audit exports. */
|
|
1165
|
+
export function retryStoreFollowUps(root: string): void {
|
|
1166
|
+
withKernelTransaction(root, (db) => {
|
|
1167
|
+
convergePendingRelocations(root, db);
|
|
1168
|
+
retryPendingAuditExports(root, db);
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// ---------------------------------------------------------------------------
|
|
1173
|
+
// CAS writes.
|
|
1174
|
+
// ---------------------------------------------------------------------------
|
|
1175
|
+
|
|
1176
|
+
function assertWorkspaceExpectation(db: DatabaseSync, expected: string, label: string): number {
|
|
1177
|
+
const row = readWorkspaceRow(db);
|
|
1178
|
+
const currentRevision = recordRevision(row.revision);
|
|
1179
|
+
if (expected === MISSING_REVISION) {
|
|
1180
|
+
// The store itself is created by this transaction: only the pristine
|
|
1181
|
+
// workspace may accept a missing expectation.
|
|
1182
|
+
if (row.revision !== 0)
|
|
1183
|
+
throw new KernelStoreConflictError(
|
|
1184
|
+
`CAS mismatch for ${label}: expected ${expected}, got ${currentRevision}`,
|
|
1185
|
+
);
|
|
1186
|
+
return row.revision;
|
|
1187
|
+
}
|
|
1188
|
+
if (currentRevision !== expected)
|
|
1371
1189
|
throw new KernelStoreConflictError(
|
|
1372
|
-
`
|
|
1190
|
+
`CAS mismatch for ${label}: expected ${expected}, got ${currentRevision}`,
|
|
1373
1191
|
);
|
|
1374
|
-
|
|
1192
|
+
return row.revision;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
interface CommittedOperationResult {
|
|
1196
|
+
record_json: string;
|
|
1197
|
+
workspace_json: string;
|
|
1198
|
+
/**
|
|
1199
|
+
* Digest of the request this operation committed. A lost response replays
|
|
1200
|
+
* only when the retry carries the same request: operation, task and time are
|
|
1201
|
+
* not enough, because a different reason or actor at the same instant is a
|
|
1202
|
+
* different authorization decision. Absent on operations committed before
|
|
1203
|
+
* this field existed; absence never authorizes a different request.
|
|
1204
|
+
*/
|
|
1205
|
+
request_digest?: string;
|
|
1206
|
+
/**
|
|
1207
|
+
* Enrollment's committed claim. Replays must return these bytes, not a
|
|
1208
|
+
* projection of the live run: drain or settlement can change claim_status
|
|
1209
|
+
* after the enrollment committed.
|
|
1210
|
+
*/
|
|
1211
|
+
claim_json?: string;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function decodeOperationResult(
|
|
1215
|
+
resultJson: string,
|
|
1216
|
+
): { record: TaskRecord; workspace: WorkspaceState } {
|
|
1217
|
+
const parsed = JSON.parse(resultJson) as CommittedOperationResult;
|
|
1375
1218
|
return {
|
|
1376
|
-
record: parseTaskRecord(JSON.parse(
|
|
1377
|
-
workspace: parseWorkspaceContent(
|
|
1219
|
+
record: parseTaskRecord(JSON.parse(parsed.record_json) as Record<string, unknown>),
|
|
1220
|
+
workspace: parseWorkspaceContent(parsed.workspace_json),
|
|
1378
1221
|
};
|
|
1379
1222
|
}
|
|
1380
1223
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1224
|
+
function requireActiveRun(db: DatabaseSync, taskId: string): KernelRunRow {
|
|
1225
|
+
const run = readRunRowByTask(db, taskId);
|
|
1226
|
+
if (!run)
|
|
1227
|
+
throw new KernelStoreConflictError(`task ${taskId} has no enrolled run in this worktree`);
|
|
1228
|
+
if (run.state !== "active")
|
|
1229
|
+
throw new KernelStoreConflictError(
|
|
1230
|
+
`task ${taskId} is ${run.state}; only an active run can be mutated`,
|
|
1231
|
+
);
|
|
1232
|
+
if (run.claim_status === null)
|
|
1233
|
+
throw new KernelStoreConflictError(`task ${taskId} has no workspace claim to mutate`);
|
|
1234
|
+
return run;
|
|
1235
|
+
}
|
|
1386
1236
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1237
|
+
/**
|
|
1238
|
+
* Read-only store probe for commands that must not create authority state:
|
|
1239
|
+
* retired file-store authority fails closed, and an existing store is opened
|
|
1240
|
+
* and validated without writing.
|
|
1241
|
+
*/
|
|
1242
|
+
export function probeKernelStore(root: string): void {
|
|
1243
|
+
assertNoRetiredFileStore(root);
|
|
1244
|
+
withKernelRead(root, () => undefined);
|
|
1393
1245
|
}
|
|
1394
1246
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
const
|
|
1416
|
-
const
|
|
1417
|
-
if (
|
|
1418
|
-
throw new
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1247
|
+
/** Commit a TaskRecord result through one revision-checked transaction. */
|
|
1248
|
+
export function commitTaskRecordLocked(
|
|
1249
|
+
root: string,
|
|
1250
|
+
taskId: string,
|
|
1251
|
+
expectedRecordHash: string,
|
|
1252
|
+
nextRecord: TaskRecord,
|
|
1253
|
+
expectedWorkspaceHash: string,
|
|
1254
|
+
nextWorkspace: WorkspaceState,
|
|
1255
|
+
artifactRelocations: ArtifactRelocationV1[] = [],
|
|
1256
|
+
capabilityRunId?: string,
|
|
1257
|
+
): StoredTaskMutationV3 {
|
|
1258
|
+
validateTaskId(taskId);
|
|
1259
|
+
const nextRecordContent = `${JSON.stringify(nextRecord, null, 2)}\n`;
|
|
1260
|
+
const timestamp = nowIso();
|
|
1261
|
+
const committed = withKernelTransaction(root, (db) => {
|
|
1262
|
+
assertNoRetiredFileStore(root);
|
|
1263
|
+
const run = requireActiveRun(db, taskId);
|
|
1264
|
+
const identity = runIdentity(db, run);
|
|
1265
|
+
assertRunBinding(identity, { task_id: taskId, run_id: run.run_id }, "task record commit");
|
|
1266
|
+
assertCapabilityRun(identity, capabilityRunId, "task record commit");
|
|
1267
|
+
const committedRecord = parseTaskRecord(nextRecord as unknown as Record<string, unknown>);
|
|
1268
|
+
const currentRevision = canonicalRecordHash(recordFromRun(run));
|
|
1269
|
+
if (currentRevision !== expectedRecordHash)
|
|
1270
|
+
throw new KernelStoreConflictError(
|
|
1271
|
+
`CAS mismatch for run ${run.run_id}: expected ${expectedRecordHash}, got ${currentRevision}`,
|
|
1272
|
+
);
|
|
1273
|
+
for (const relocation of artifactRelocations) assertArtifactRelocation(relocation);
|
|
1274
|
+
updateRunRecord(db, run.run_id, run.revision, nextRecordContent, timestamp);
|
|
1275
|
+
if (artifactRelocations.length > 0)
|
|
1276
|
+
setPendingRelocations(db, run.run_id, JSON.stringify(artifactRelocations));
|
|
1277
|
+
const workspaceRevision = assertWorkspaceExpectation(db, expectedWorkspaceHash, "workspace");
|
|
1278
|
+
const workspaceRevisionAfter = writeWorkspaceRow(
|
|
1279
|
+
db,
|
|
1280
|
+
workspaceRevision,
|
|
1281
|
+
run.run_id,
|
|
1282
|
+
timestamp,
|
|
1283
|
+
);
|
|
1284
|
+
return {
|
|
1285
|
+
revision: canonicalRecordHash(committedRecord),
|
|
1286
|
+
record: committedRecord,
|
|
1287
|
+
workspace: {
|
|
1288
|
+
revision: recordRevision(workspaceRevisionAfter),
|
|
1289
|
+
state: nextWorkspace,
|
|
1290
|
+
},
|
|
1291
|
+
};
|
|
1425
1292
|
});
|
|
1293
|
+
return committed;
|
|
1426
1294
|
}
|
|
1427
1295
|
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
if (
|
|
1441
|
-
throw new KernelStoreSecurityError(
|
|
1442
|
-
`drain marker has unknown field: ${unknown[0]}`,
|
|
1443
|
-
);
|
|
1444
|
-
if (raw.contract !== "assurance_kernel/drain_transaction/v1")
|
|
1445
|
-
throw new KernelStoreSecurityError("drain marker contract is invalid");
|
|
1446
|
-
if (typeof raw.task_id !== "string" || !raw.task_id.trim())
|
|
1447
|
-
throw new KernelStoreSecurityError("drain marker task_id is invalid");
|
|
1448
|
-
for (const field of ["expected_claim_content", "next_claim_content", "at"]) {
|
|
1449
|
-
if (typeof raw[field] !== "string" || !String(raw[field]).trim())
|
|
1450
|
-
throw new KernelStoreSecurityError(`drain marker ${field} is invalid`);
|
|
1451
|
-
}
|
|
1452
|
-
const marker = raw as unknown as DrainMarker;
|
|
1453
|
-
validateTaskId(marker.task_id);
|
|
1454
|
-
const expected = parseBackendClaim(JSON.parse(marker.expected_claim_content));
|
|
1455
|
-
const next = parseBackendClaim(JSON.parse(marker.next_claim_content));
|
|
1456
|
-
if (expected.task_id !== marker.task_id || next.task_id !== marker.task_id)
|
|
1457
|
-
throw new KernelStoreSecurityError("drain marker claim identity is inconsistent");
|
|
1458
|
-
if (expected.lifecycle_status !== "active" || next.lifecycle_status !== "draining")
|
|
1296
|
+
/**
|
|
1297
|
+
* A native capability names the exact run it was issued for. Two worktrees can
|
|
1298
|
+
* hold the same logical task with identical record, intent and diff content, so
|
|
1299
|
+
* task and content bindings alone cannot keep authority in the worktree that
|
|
1300
|
+
* issued it; the run identity can.
|
|
1301
|
+
*/
|
|
1302
|
+
export function assertCapabilityRun(
|
|
1303
|
+
current: RunIdentity,
|
|
1304
|
+
capabilityRunId: string | undefined,
|
|
1305
|
+
operation: string,
|
|
1306
|
+
): void {
|
|
1307
|
+
if (capabilityRunId === undefined) return;
|
|
1308
|
+
if (capabilityRunId !== current.run_id)
|
|
1459
1309
|
throw new KernelStoreSecurityError(
|
|
1460
|
-
|
|
1310
|
+
`${operation} authority was issued for run ${capabilityRunId} but this worktree holds run ${current.run_id}`,
|
|
1461
1311
|
);
|
|
1462
|
-
return marker;
|
|
1463
1312
|
}
|
|
1464
1313
|
|
|
1465
|
-
function
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
readSecureProjectFile(root, DRAIN_MARKER_PATH),
|
|
1469
|
-
) as Record<string, unknown>;
|
|
1470
|
-
return parseDrainMarker(raw);
|
|
1314
|
+
export function terminalProofRunId(proof: TaskTombstone): string | null {
|
|
1315
|
+
const value = (proof as unknown as { run_id?: unknown }).run_id;
|
|
1316
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1471
1317
|
}
|
|
1472
1318
|
|
|
1473
|
-
function
|
|
1474
|
-
|
|
1475
|
-
assertNoSymlinkSegments(candidate.root, candidate.path);
|
|
1476
|
-
const stat = pathStatOrNull(candidate.path);
|
|
1477
|
-
if (!stat) return;
|
|
1478
|
-
if (!stat.isFile())
|
|
1479
|
-
throw new KernelStoreSecurityError(
|
|
1480
|
-
"drain marker is not a regular file",
|
|
1481
|
-
);
|
|
1482
|
-
rmSync(candidate.path);
|
|
1483
|
-
fsyncDirectory(dirname(candidate.path));
|
|
1319
|
+
function claimBytesFromRun(run: KernelRunRow): string {
|
|
1320
|
+
return serializeBackendClaim(claimFromRunRow(run));
|
|
1484
1321
|
}
|
|
1485
1322
|
|
|
1486
1323
|
/**
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1324
|
+
* Enroll one run: the TaskRecord, the workspace owner and the derived claim
|
|
1325
|
+
* commit together, or nothing does. A replayed call with the same enrollment
|
|
1326
|
+
* event returns the committed result instead of writing again.
|
|
1490
1327
|
*/
|
|
1491
|
-
function
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1328
|
+
export function commitEnrollmentLocked(
|
|
1329
|
+
root: string,
|
|
1330
|
+
taskId: string,
|
|
1331
|
+
transaction: WorkspaceTransactionV2,
|
|
1332
|
+
claim: Record<string, unknown>,
|
|
1333
|
+
requestDigest?: string,
|
|
1334
|
+
): { record: TaskRecord; workspace: WorkspaceState } {
|
|
1335
|
+
validateTaskId(taskId);
|
|
1336
|
+
if (transaction.task_id !== taskId)
|
|
1337
|
+
throw new KernelStoreSecurityError("enrollment transaction task identity is inconsistent");
|
|
1338
|
+
const parsedClaim = parseBackendClaim(claim);
|
|
1339
|
+
if (parsedClaim.task_id !== taskId)
|
|
1340
|
+
throw new KernelStoreSecurityError("enrollment claim task identity is inconsistent");
|
|
1341
|
+
if (parsedClaim.lifecycle_status !== "active")
|
|
1342
|
+
throw new KernelStoreSecurityError("enrollment claim must be active");
|
|
1343
|
+
const nextRecord = parseTaskRecord(
|
|
1344
|
+
JSON.parse(transaction.next_record_content) as Record<string, unknown>,
|
|
1499
1345
|
);
|
|
1500
|
-
|
|
1346
|
+
if (nextRecord.task_id !== taskId)
|
|
1347
|
+
throw new KernelStoreSecurityError("enrollment task record identity is inconsistent");
|
|
1348
|
+
const nextWorkspace = parseWorkspaceContent(transaction.next_workspace_content);
|
|
1349
|
+
if (nextWorkspace.current_working !== taskId)
|
|
1350
|
+
throw new KernelStoreSecurityError("enrollment must claim the workspace for its task");
|
|
1351
|
+
|
|
1352
|
+
const digest =
|
|
1353
|
+
requestDigest ??
|
|
1354
|
+
enrollmentRequestDigest({
|
|
1355
|
+
task_id: parsedClaim.task_id,
|
|
1356
|
+
intent_path: "",
|
|
1357
|
+
intent_revision: parsedClaim.intent_revision,
|
|
1358
|
+
intent_content_hash: parsedClaim.intent_content_hash,
|
|
1359
|
+
preparation_digest: "",
|
|
1360
|
+
enrollment_event_id: parsedClaim.enrollment_event_id,
|
|
1361
|
+
actor_id: "",
|
|
1362
|
+
confirmation_ref: "",
|
|
1363
|
+
nonce: "",
|
|
1364
|
+
});
|
|
1365
|
+
return withKernelTransaction(root, (db) => {
|
|
1366
|
+
assertNoRetiredFileStore(root, db, taskId);
|
|
1367
|
+
const runId = mintRunId();
|
|
1368
|
+
const operationId = enrollmentOperationId(taskId, parsedClaim.enrollment_event_id);
|
|
1369
|
+
const replay = readOperationRow(db, operationId);
|
|
1370
|
+
if (replay) {
|
|
1371
|
+
const parsed = JSON.parse(replay.result_json) as CommittedOperationResult;
|
|
1372
|
+
if ((parsed.request_digest ?? null) !== digest)
|
|
1373
|
+
throw new KernelStoreConflictError(
|
|
1374
|
+
`enrollment operation for ${taskId} was committed for a different request; resubmit the exact request that enrolled it`,
|
|
1375
|
+
);
|
|
1376
|
+
return decodeOperationResult(replay.result_json);
|
|
1377
|
+
}
|
|
1378
|
+
const existing = readRunRowByTask(db, taskId);
|
|
1379
|
+
if (existing)
|
|
1380
|
+
throw new KernelStoreConflictError(
|
|
1381
|
+
`task ${taskId} already has run ${existing.run_id} (${existing.state}); same-task re-enrollment is forbidden`,
|
|
1382
|
+
);
|
|
1383
|
+
const active = readRunRowById(db, activeRunId(db) ?? "");
|
|
1384
|
+
if (active)
|
|
1385
|
+
throw new KernelStoreConflictError(
|
|
1386
|
+
`workspace is already owned by ${active.task_id} (run ${active.run_id})`,
|
|
1387
|
+
);
|
|
1388
|
+
const workspaceRevision = assertWorkspaceExpectation(
|
|
1389
|
+
db,
|
|
1390
|
+
transaction.expected_workspace_hash,
|
|
1391
|
+
"workspace",
|
|
1392
|
+
);
|
|
1393
|
+
const run = insertRunRow(db, {
|
|
1394
|
+
run_id: runId,
|
|
1395
|
+
task_id: taskId,
|
|
1396
|
+
record_json: transaction.next_record_content,
|
|
1397
|
+
intent_revision: parsedClaim.intent_revision,
|
|
1398
|
+
intent_content_hash: parsedClaim.intent_content_hash,
|
|
1399
|
+
enrollment_event_id: parsedClaim.enrollment_event_id,
|
|
1400
|
+
claim_status: "active",
|
|
1401
|
+
created_at: parsedClaim.created_at,
|
|
1402
|
+
updated_at: parsedClaim.updated_at,
|
|
1403
|
+
});
|
|
1404
|
+
writeWorkspaceRow(db, workspaceRevision, run.run_id, parsedClaim.updated_at);
|
|
1405
|
+
insertOperationRow(db, {
|
|
1406
|
+
operation_id: operationId,
|
|
1407
|
+
kind: "enrollment",
|
|
1408
|
+
run_id: run.run_id,
|
|
1409
|
+
result_json: JSON.stringify({
|
|
1410
|
+
record_json: transaction.next_record_content,
|
|
1411
|
+
workspace_json: transaction.next_workspace_content,
|
|
1412
|
+
request_digest: digest,
|
|
1413
|
+
claim_json: JSON.stringify(parsedClaim),
|
|
1414
|
+
} satisfies CommittedOperationResult),
|
|
1415
|
+
committed_at: parsedClaim.updated_at,
|
|
1416
|
+
});
|
|
1417
|
+
return { record: nextRecord, workspace: nextWorkspace };
|
|
1418
|
+
});
|
|
1501
1419
|
}
|
|
1502
1420
|
|
|
1503
1421
|
/**
|
|
1504
1422
|
* Commit the recoverable active -> draining claim transition under the store
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
* user capability exactly once before the marker write.
|
|
1423
|
+
* transaction. The derived claim bytes are the CAS identity, so a divergent
|
|
1424
|
+
* claim fails closed before anything is written.
|
|
1508
1425
|
*/
|
|
1509
1426
|
export function commitDrainLocked(
|
|
1510
1427
|
root: string,
|
|
@@ -1512,233 +1429,82 @@ export function commitDrainLocked(
|
|
|
1512
1429
|
expectedClaimContent: string,
|
|
1513
1430
|
nextClaimContent: string,
|
|
1514
1431
|
at: string,
|
|
1432
|
+
capabilityRunId?: string,
|
|
1515
1433
|
): BackendClaim {
|
|
1516
1434
|
validateTaskId(taskId);
|
|
1517
|
-
const expected = parseBackendClaim(JSON.parse(expectedClaimContent));
|
|
1518
|
-
const next = parseBackendClaim(JSON.parse(nextClaimContent));
|
|
1435
|
+
const expected = parseBackendClaim(JSON.parse(expectedClaimContent) as Record<string, unknown>);
|
|
1436
|
+
const next = parseBackendClaim(JSON.parse(nextClaimContent) as Record<string, unknown>);
|
|
1519
1437
|
if (expected.task_id !== taskId || next.task_id !== taskId)
|
|
1520
1438
|
throw new KernelStoreSecurityError("drain claim identity is inconsistent");
|
|
1521
1439
|
if (expected.lifecycle_status !== "active" || next.lifecycle_status !== "draining")
|
|
1522
|
-
throw new KernelStoreSecurityError(
|
|
1523
|
-
|
|
1524
|
-
);
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
MISSING_REVISION,
|
|
1537
|
-
);
|
|
1538
|
-
try {
|
|
1539
|
-
recoverPendingDrainLocked(root);
|
|
1540
|
-
} catch (error) {
|
|
1541
|
-
throw new KernelStoreConflictError(
|
|
1542
|
-
`drain transaction failed and remains recoverable: ${error instanceof Error ? error.message : error}`,
|
|
1543
|
-
);
|
|
1544
|
-
}
|
|
1545
|
-
return next;
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
function parseTerminalMarker(raw: Record<string, unknown>): TerminalMarker {
|
|
1549
|
-
const allowed = [
|
|
1550
|
-
"contract",
|
|
1551
|
-
"task_id",
|
|
1552
|
-
"expected_state_record_hash",
|
|
1553
|
-
"audit_record_content",
|
|
1554
|
-
"proof_content",
|
|
1555
|
-
"expected_workspace_hash",
|
|
1556
|
-
"next_workspace_content",
|
|
1557
|
-
"artifact_relocations",
|
|
1558
|
-
"expected_claim_sha256",
|
|
1559
|
-
"at",
|
|
1560
|
-
];
|
|
1561
|
-
const unknown = Object.keys(raw).filter((key) => !allowed.includes(key));
|
|
1562
|
-
if (unknown.length > 0)
|
|
1563
|
-
throw new KernelStoreSecurityError(
|
|
1564
|
-
`terminal marker has unknown field: ${unknown[0]}`,
|
|
1565
|
-
);
|
|
1566
|
-
if (raw.contract !== "assurance_kernel/terminal_transaction/v2")
|
|
1567
|
-
throw new KernelStoreSecurityError("terminal marker contract is invalid");
|
|
1568
|
-
if (typeof raw.task_id !== "string" || !raw.task_id.trim())
|
|
1569
|
-
throw new KernelStoreSecurityError("terminal marker task_id is invalid");
|
|
1570
|
-
validateTaskId(raw.task_id);
|
|
1571
|
-
for (const field of ["expected_state_record_hash", "audit_record_content", "proof_content", "expected_workspace_hash", "next_workspace_content", "at"]) {
|
|
1572
|
-
if (typeof raw[field] !== "string" || !String(raw[field]).trim())
|
|
1573
|
-
throw new KernelStoreSecurityError(`terminal marker ${field} is invalid`);
|
|
1574
|
-
}
|
|
1575
|
-
const expectedClaim = raw.expected_claim_sha256;
|
|
1576
|
-
if (typeof expectedClaim !== "string" || !/^sha256:[a-f0-9]{64}$/.test(expectedClaim))
|
|
1577
|
-
throw new KernelStoreSecurityError("terminal marker expected_claim_sha256 is required");
|
|
1578
|
-
const marker = {
|
|
1579
|
-
contract: "assurance_kernel/terminal_transaction/v2" as const,
|
|
1580
|
-
task_id: raw.task_id,
|
|
1581
|
-
expected_state_record_hash: raw.expected_state_record_hash as string,
|
|
1582
|
-
audit_record_content: raw.audit_record_content as string,
|
|
1583
|
-
proof_content: raw.proof_content as string,
|
|
1584
|
-
expected_workspace_hash: raw.expected_workspace_hash as string,
|
|
1585
|
-
next_workspace_content: raw.next_workspace_content as string,
|
|
1586
|
-
artifact_relocations: parseArtifactRelocationsV1(raw.artifact_relocations),
|
|
1587
|
-
expected_claim_sha256: expectedClaim,
|
|
1588
|
-
at: raw.at as string,
|
|
1589
|
-
};
|
|
1590
|
-
const record = parseTaskRecord(JSON.parse(marker.audit_record_content));
|
|
1591
|
-
if (record.task_id !== marker.task_id)
|
|
1592
|
-
throw new KernelStoreSecurityError("terminal marker task identity is inconsistent");
|
|
1593
|
-
if (record.lifecycle !== "done" && record.lifecycle !== "stopped")
|
|
1594
|
-
throw new KernelStoreSecurityError("terminal marker record must be terminal");
|
|
1595
|
-
const proof = parseTaskTombstone(JSON.parse(marker.proof_content) as Record<string, unknown>);
|
|
1596
|
-
if (proof.task_id !== marker.task_id)
|
|
1597
|
-
throw new KernelStoreSecurityError("terminal marker proof identity is inconsistent");
|
|
1598
|
-
if (proof.final_record_hash !== revisionFor(marker.audit_record_content))
|
|
1599
|
-
throw new KernelStoreSecurityError("terminal marker proof does not match the terminal record bytes");
|
|
1600
|
-
if (proof.terminal_lifecycle !== record.lifecycle)
|
|
1601
|
-
throw new KernelStoreSecurityError("terminal marker proof lifecycle contradicts the terminal record");
|
|
1602
|
-
const workspaceState = parseWorkspaceContent(marker.next_workspace_content);
|
|
1603
|
-
if (workspaceState.current_working !== null)
|
|
1604
|
-
throw new KernelStoreSecurityError("terminal marker requires a cleared workspace owner");
|
|
1605
|
-
return marker;
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
function readPendingTerminalMarker(root: string): TerminalMarker | null {
|
|
1609
|
-
if (currentRevision(root, TERMINAL_MARKER_PATH) === MISSING_REVISION) return null;
|
|
1610
|
-
const raw = JSON.parse(
|
|
1611
|
-
readSecureProjectFile(root, TERMINAL_MARKER_PATH),
|
|
1612
|
-
) as Record<string, unknown>;
|
|
1613
|
-
return parseTerminalMarker(raw);
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
function removeTerminalMarker(root: string): void {
|
|
1617
|
-
const candidate = safeCandidate(root, TERMINAL_MARKER_PATH);
|
|
1618
|
-
assertNoSymlinkSegments(candidate.root, candidate.path);
|
|
1619
|
-
const stat = pathStatOrNull(candidate.path);
|
|
1620
|
-
if (!stat) return;
|
|
1621
|
-
if (!stat.isFile())
|
|
1622
|
-
throw new KernelStoreSecurityError(
|
|
1623
|
-
"terminal marker is not a regular file",
|
|
1624
|
-
);
|
|
1625
|
-
rmSync(candidate.path);
|
|
1626
|
-
fsyncDirectory(dirname(candidate.path));
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
/**
|
|
1630
|
-
* Converge terminal state: TaskRecord, workspace owner, active-claim removal,
|
|
1631
|
-
* and task tombstone creation. Each step is idempotent on its committed
|
|
1632
|
-
* result and fails closed on contradictory partial bytes.
|
|
1633
|
-
*/
|
|
1634
|
-
function convergeStateRecordRemoval(root: string, taskId: string, expectedHash: string): void {
|
|
1635
|
-
const relativePath = stateTaskRecordPath(taskId);
|
|
1636
|
-
const actual = currentRevision(root, relativePath);
|
|
1637
|
-
if (actual === MISSING_REVISION) return; // already removed
|
|
1638
|
-
if (actual !== expectedHash)
|
|
1639
|
-
throw new KernelStoreConflictError(
|
|
1640
|
-
`state record changed during terminal settlement: expected ${expectedHash}, got ${actual}`,
|
|
1641
|
-
);
|
|
1642
|
-
const candidate = safeCandidate(root, relativePath);
|
|
1643
|
-
assertNoSymlinkSegments(candidate.root, candidate.path);
|
|
1644
|
-
const stat = pathStatOrNull(candidate.path);
|
|
1645
|
-
if (!stat || !stat.isFile())
|
|
1646
|
-
throw new KernelStoreSecurityError("state task record is not a regular file");
|
|
1647
|
-
rmSync(candidate.path);
|
|
1648
|
-
fsyncDirectory(dirname(candidate.path));
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
/**
|
|
1652
|
-
* Converge terminal state: create the immutable audit record/proof pair,
|
|
1653
|
-
* clear the workspace owner, remove the active claim and the state record,
|
|
1654
|
-
* and remove the marker. Each step is idempotent on its committed result and
|
|
1655
|
-
* fails closed on contradictory partial bytes. The state record is removed
|
|
1656
|
-
* only after the complete audit pair is durable.
|
|
1657
|
-
*/
|
|
1658
|
-
function recoverPendingTerminalLocked(root: string, invokeStepHook = false): void {
|
|
1659
|
-
const marker = readPendingTerminalMarker(root);
|
|
1660
|
-
if (!marker) return;
|
|
1661
|
-
for (const relocation of marker.artifact_relocations ?? [])
|
|
1662
|
-
convergeArtifactRelocation(root, relocation);
|
|
1663
|
-
if (invokeStepHook) runTerminalSettlementStepHook(0);
|
|
1664
|
-
// Audit record: create-once; identical committed bytes are idempotent.
|
|
1665
|
-
convergeFile(
|
|
1666
|
-
root,
|
|
1667
|
-
auditTaskRecordPath(marker.task_id),
|
|
1668
|
-
MISSING_REVISION,
|
|
1669
|
-
marker.audit_record_content,
|
|
1670
|
-
);
|
|
1671
|
-
// Terminal proof: create-once after the record is durable.
|
|
1672
|
-
convergeFile(
|
|
1673
|
-
root,
|
|
1674
|
-
auditTerminalProofPath(marker.task_id),
|
|
1675
|
-
MISSING_REVISION,
|
|
1676
|
-
marker.proof_content,
|
|
1677
|
-
);
|
|
1678
|
-
if (invokeStepHook) runTerminalSettlementStepHook(1);
|
|
1679
|
-
convergeFile(
|
|
1680
|
-
root,
|
|
1681
|
-
stateWorkspacePath(),
|
|
1682
|
-
marker.expected_workspace_hash,
|
|
1683
|
-
marker.next_workspace_content,
|
|
1684
|
-
);
|
|
1685
|
-
if (invokeStepHook) runTerminalSettlementStepHook(2);
|
|
1686
|
-
// Active-claim removal: absence is the committed outcome. A present
|
|
1687
|
-
// claim is removed only after its bytes match the marker's frozen
|
|
1688
|
-
// expected hash; a foreign or changed claim fails closed and keeps the
|
|
1689
|
-
// marker recoverable (review-1).
|
|
1690
|
-
const claimCandidate = safeCandidate(root, stateClaimPath());
|
|
1691
|
-
assertNoSymlinkSegments(claimCandidate.root, claimCandidate.path);
|
|
1692
|
-
const claimStat = pathStatOrNull(claimCandidate.path);
|
|
1693
|
-
if (claimStat) {
|
|
1694
|
-
// review-2: a present claim at replay must byte-match the frozen
|
|
1695
|
-
// claim hash; absence is the already-converged step, a foreign or
|
|
1696
|
-
// changed claim fails closed instead of being destroyed.
|
|
1697
|
-
if (!claimStat.isFile())
|
|
1698
|
-
throw new KernelStoreSecurityError(
|
|
1699
|
-
"backend claim is not a regular file",
|
|
1440
|
+
throw new KernelStoreSecurityError("drain transaction must transition active -> draining");
|
|
1441
|
+
return withKernelTransaction(root, (db) => {
|
|
1442
|
+
assertNoRetiredFileStore(root);
|
|
1443
|
+
const run = readRunRowByTask(db, taskId);
|
|
1444
|
+
if (!run)
|
|
1445
|
+
throw new KernelStoreConflictError(`task ${taskId} has no enrolled run in this worktree`);
|
|
1446
|
+
const operationId = drainOperationId(taskId, next.updated_at);
|
|
1447
|
+
const replay = readOperationRow(db, operationId);
|
|
1448
|
+
if (replay) {
|
|
1449
|
+
// A replay returns the committed claim, never the caller's request:
|
|
1450
|
+
// the same task and timestamp with different content is a conflicting
|
|
1451
|
+
// reuse of one operation identity, not a successful replay.
|
|
1452
|
+
const committed = parseBackendClaim(
|
|
1453
|
+
JSON.parse(replay.result_json) as Record<string, unknown>,
|
|
1700
1454
|
);
|
|
1701
|
-
|
|
1702
|
-
|
|
1455
|
+
if (
|
|
1456
|
+
committed.task_id !== next.task_id ||
|
|
1457
|
+
committed.lifecycle_status !== next.lifecycle_status ||
|
|
1458
|
+
committed.intent_content_hash !== next.intent_content_hash ||
|
|
1459
|
+
committed.enrollment_event_id !== next.enrollment_event_id
|
|
1460
|
+
)
|
|
1461
|
+
throw new KernelStoreConflictError(
|
|
1462
|
+
`drain transaction ${operationId} was already committed with different facts`,
|
|
1463
|
+
);
|
|
1464
|
+
return committed;
|
|
1465
|
+
}
|
|
1466
|
+
const active = requireActiveRun(db, taskId);
|
|
1467
|
+
const identity = runIdentity(db, active);
|
|
1468
|
+
assertRunBinding(identity, { task_id: taskId, run_id: active.run_id }, "drain transaction");
|
|
1469
|
+
assertCapabilityRun(identity, capabilityRunId, "drain transaction");
|
|
1470
|
+
if (claimBytesFromRun(active) !== expectedClaimContent)
|
|
1703
1471
|
throw new KernelStoreConflictError(
|
|
1704
|
-
`
|
|
1472
|
+
`drain transaction claim bytes changed for ${taskId}`,
|
|
1705
1473
|
);
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1474
|
+
updateRunClaim(db, active.run_id, "active", "draining", at);
|
|
1475
|
+
insertOperationRow(db, {
|
|
1476
|
+
operation_id: operationId,
|
|
1477
|
+
kind: "drain",
|
|
1478
|
+
run_id: active.run_id,
|
|
1479
|
+
result_json: JSON.stringify(next),
|
|
1480
|
+
committed_at: at,
|
|
1481
|
+
});
|
|
1482
|
+
return next;
|
|
1483
|
+
});
|
|
1715
1484
|
}
|
|
1485
|
+
|
|
1716
1486
|
/**
|
|
1717
|
-
* Commit
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1487
|
+
* Commit terminal ownership transfer under the store transaction: the terminal
|
|
1488
|
+
* TaskRecord, the cleared workspace owner and the released claim commit
|
|
1489
|
+
* together. Audit evidence is exported afterwards; an interrupted export stays
|
|
1490
|
+
* retryable and never reactivates the run.
|
|
1721
1491
|
*/
|
|
1722
1492
|
export function commitTerminalLocked(
|
|
1723
1493
|
root: string,
|
|
1724
1494
|
taskId: string,
|
|
1725
1495
|
transaction: WorkspaceTransactionV2,
|
|
1726
1496
|
tombstone: TaskTombstone,
|
|
1497
|
+
capabilityRunId?: string,
|
|
1498
|
+
requestDigest?: string,
|
|
1727
1499
|
): { record: TaskRecord; workspace: WorkspaceState } {
|
|
1728
1500
|
validateTaskId(taskId);
|
|
1729
1501
|
if (transaction.task_id !== taskId)
|
|
1730
|
-
throw new KernelStoreSecurityError(
|
|
1731
|
-
"terminal transaction task identity is inconsistent",
|
|
1732
|
-
);
|
|
1502
|
+
throw new KernelStoreSecurityError("terminal transaction task identity is inconsistent");
|
|
1733
1503
|
if (tombstone.task_id !== taskId)
|
|
1734
|
-
throw new KernelStoreSecurityError(
|
|
1735
|
-
"terminal tombstone task identity is inconsistent",
|
|
1736
|
-
);
|
|
1504
|
+
throw new KernelStoreSecurityError("terminal tombstone task identity is inconsistent");
|
|
1737
1505
|
const nextWorkspaceState = parseWorkspaceContent(transaction.next_workspace_content);
|
|
1738
1506
|
if (nextWorkspaceState.current_working !== null)
|
|
1739
|
-
throw new KernelStoreSecurityError(
|
|
1740
|
-
"terminal settlement requires a cleared workspace owner",
|
|
1741
|
-
);
|
|
1507
|
+
throw new KernelStoreSecurityError("terminal settlement requires a cleared workspace owner");
|
|
1742
1508
|
if (tombstone.final_record_hash !== revisionFor(transaction.next_record_content))
|
|
1743
1509
|
throw new KernelStoreSecurityError(
|
|
1744
1510
|
"terminal proof must match the terminal record bytes",
|
|
@@ -1746,50 +1512,332 @@ export function commitTerminalLocked(
|
|
|
1746
1512
|
const terminalRecord = parseTaskRecord(
|
|
1747
1513
|
JSON.parse(transaction.next_record_content) as Record<string, unknown>,
|
|
1748
1514
|
);
|
|
1515
|
+
if (terminalRecord.task_id !== taskId)
|
|
1516
|
+
throw new KernelStoreSecurityError("terminal record identity is inconsistent");
|
|
1749
1517
|
if (tombstone.terminal_lifecycle !== terminalRecord.lifecycle)
|
|
1750
1518
|
throw new KernelStoreSecurityError(
|
|
1751
1519
|
"terminal proof lifecycle contradicts the terminal TaskRecord",
|
|
1752
1520
|
);
|
|
1753
|
-
const
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1521
|
+
const proofBytes = serializeTaskTombstone(tombstone);
|
|
1522
|
+
|
|
1523
|
+
const committed = withKernelTransaction(root, (db) => {
|
|
1524
|
+
assertNoRetiredFileStore(root, db, taskId);
|
|
1525
|
+
const run = readRunRowByTask(db, taskId);
|
|
1526
|
+
if (!run)
|
|
1527
|
+
throw new KernelStoreConflictError(`task ${taskId} has no enrolled run in this worktree`);
|
|
1528
|
+
const operationId = terminalOperationId(taskId, tombstone.terminal_event_id);
|
|
1529
|
+
// A lost response replays the same event identity: the committed
|
|
1530
|
+
// settlement is reused before any state guard runs.
|
|
1531
|
+
const replay = readOperationRow(db, operationId);
|
|
1532
|
+
if (replay) return decodeOperationResult(replay.result_json);
|
|
1533
|
+
if (run.state !== "active")
|
|
1534
|
+
throw new KernelStoreConflictError(
|
|
1535
|
+
`terminal settlement refused: run ${run.run_id} is already ${run.state}`,
|
|
1536
|
+
);
|
|
1537
|
+
if (run.claim_status !== "active" && run.claim_status !== "draining")
|
|
1538
|
+
throw new KernelStoreSecurityError("terminal settlement claim must be active or draining");
|
|
1539
|
+
const identity = runIdentity(db, run);
|
|
1540
|
+
assertRunBinding(identity, { task_id: taskId, run_id: run.run_id }, "terminal settlement");
|
|
1541
|
+
assertCapabilityRun(identity, capabilityRunId, "terminal settlement");
|
|
1542
|
+
for (const relocation of transaction.artifact_relocations ?? [])
|
|
1543
|
+
assertArtifactRelocation(relocation);
|
|
1544
|
+
const workspaceRevision = assertWorkspaceExpectation(
|
|
1545
|
+
db,
|
|
1546
|
+
transaction.expected_workspace_hash,
|
|
1547
|
+
"workspace",
|
|
1758
1548
|
);
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1549
|
+
const lifecycle = terminalRecord.lifecycle === "done" ? "done" : "stopped";
|
|
1550
|
+
updateRunTerminal(
|
|
1551
|
+
db,
|
|
1552
|
+
run.run_id,
|
|
1553
|
+
lifecycle,
|
|
1554
|
+
transaction.next_record_content,
|
|
1555
|
+
proofBytes,
|
|
1556
|
+
tombstone.terminalized_at,
|
|
1762
1557
|
);
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1558
|
+
writeWorkspaceRow(db, workspaceRevision, null, tombstone.terminalized_at);
|
|
1559
|
+
if ((transaction.artifact_relocations ?? []).length > 0)
|
|
1560
|
+
setPendingRelocations(db, run.run_id, JSON.stringify(transaction.artifact_relocations));
|
|
1561
|
+
insertOperationRow(db, {
|
|
1562
|
+
operation_id: operationId,
|
|
1563
|
+
kind: "terminal",
|
|
1564
|
+
run_id: run.run_id,
|
|
1565
|
+
result_json: JSON.stringify({
|
|
1566
|
+
record_json: transaction.next_record_content,
|
|
1567
|
+
workspace_json: transaction.next_workspace_content,
|
|
1568
|
+
...(requestDigest ? { request_digest: requestDigest } : {}),
|
|
1569
|
+
} satisfies CommittedOperationResult),
|
|
1570
|
+
committed_at: tombstone.terminalized_at,
|
|
1571
|
+
});
|
|
1572
|
+
return { record: terminalRecord, workspace: nextWorkspaceState };
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
// Settlement is committed. The relocation and the audit export are
|
|
1576
|
+
// deterministic follow-ups performed after this transaction by
|
|
1577
|
+
// `withKernelStoreLock` (or by the next locked operation); an interruption
|
|
1578
|
+
// stays retryable and can never reactivate the run.
|
|
1579
|
+
return committed;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// ---------------------------------------------------------------------------
|
|
1583
|
+
// Authority projection (owner matrix) derived from the store.
|
|
1584
|
+
// ---------------------------------------------------------------------------
|
|
1585
|
+
|
|
1586
|
+
export type KernelAuthorityState =
|
|
1587
|
+
| "unowned"
|
|
1588
|
+
| "active_owner"
|
|
1589
|
+
| "terminal_owner"
|
|
1590
|
+
| "repairable_stale_claim"
|
|
1591
|
+
| "authority_conflict";
|
|
1592
|
+
|
|
1593
|
+
export interface KernelAuthorityProjection {
|
|
1594
|
+
contract: "assurance_kernel/authority_projection/v1";
|
|
1595
|
+
requested_task_id: string;
|
|
1596
|
+
state: KernelAuthorityState;
|
|
1597
|
+
owner_task_id: string | null;
|
|
1598
|
+
owner_run_id: string | null;
|
|
1599
|
+
owner_lifecycle: TaskLifecycle | null;
|
|
1600
|
+
claim_lifecycle_status: BackendClaim["lifecycle_status"] | null;
|
|
1601
|
+
diagnostic: string | null;
|
|
1602
|
+
revision: string;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
interface AuthorityFacts {
|
|
1606
|
+
workspace_revision: number;
|
|
1607
|
+
current_run_id: string | null;
|
|
1608
|
+
active_run_id: string | null;
|
|
1609
|
+
requested_run_state: TaskLifecycle | null;
|
|
1610
|
+
requested_run_id: string | null;
|
|
1611
|
+
terminal_proof_present: boolean;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function authorityFacts(
|
|
1615
|
+
db: DatabaseSync,
|
|
1616
|
+
root: string,
|
|
1617
|
+
taskId: string,
|
|
1618
|
+
): AuthorityFacts {
|
|
1619
|
+
const workspace = readWorkspaceRow(db);
|
|
1620
|
+
const active = readRunRowById(db, activeRunId(db) ?? "");
|
|
1621
|
+
const requested = readRunRowByTask(db, taskId);
|
|
1622
|
+
return {
|
|
1623
|
+
workspace_revision: workspace.revision,
|
|
1624
|
+
current_run_id: workspace.current_run_id,
|
|
1625
|
+
active_run_id: active && active.state === "active" ? active.run_id : null,
|
|
1626
|
+
requested_run_state: requested ? requested.state : null,
|
|
1627
|
+
requested_run_id: requested ? requested.run_id : null,
|
|
1628
|
+
// Settlement commits the terminal proof with the run. The audit export is
|
|
1629
|
+
// evidence with its own retryable state, so it never gates the projection.
|
|
1630
|
+
terminal_proof_present: requested?.terminal_proof_json !== null && requested !== null,
|
|
1777
1631
|
};
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function projectKernelAuthorityLocked(
|
|
1635
|
+
db: DatabaseSync,
|
|
1636
|
+
root: string,
|
|
1637
|
+
taskId: string,
|
|
1638
|
+
): KernelAuthorityProjection {
|
|
1639
|
+
const projection = (fields: {
|
|
1640
|
+
state: KernelAuthorityState;
|
|
1641
|
+
owner_task_id?: string | null;
|
|
1642
|
+
owner_run_id?: string | null;
|
|
1643
|
+
owner_lifecycle?: TaskLifecycle | null;
|
|
1644
|
+
claim_lifecycle_status?: BackendClaim["lifecycle_status"] | null;
|
|
1645
|
+
diagnostic?: string | null;
|
|
1646
|
+
revision: string;
|
|
1647
|
+
}): KernelAuthorityProjection => ({
|
|
1648
|
+
contract: "assurance_kernel/authority_projection/v1",
|
|
1649
|
+
requested_task_id: taskId,
|
|
1650
|
+
state: fields.state,
|
|
1651
|
+
owner_task_id: fields.owner_task_id ?? null,
|
|
1652
|
+
owner_run_id: fields.owner_run_id ?? null,
|
|
1653
|
+
owner_lifecycle: fields.owner_lifecycle ?? null,
|
|
1654
|
+
claim_lifecycle_status: fields.claim_lifecycle_status ?? null,
|
|
1655
|
+
diagnostic: fields.diagnostic ?? null,
|
|
1656
|
+
revision: fields.revision,
|
|
1657
|
+
});
|
|
1784
1658
|
try {
|
|
1785
|
-
|
|
1659
|
+
const facts = authorityFacts(db, root, taskId);
|
|
1660
|
+
const revision = revisionFor(JSON.stringify(facts));
|
|
1661
|
+
const active = facts.active_run_id ? readRunRowById(db, facts.active_run_id) : null;
|
|
1662
|
+
if (facts.current_run_id && !active)
|
|
1663
|
+
return projection({
|
|
1664
|
+
state: "authority_conflict",
|
|
1665
|
+
owner_task_id: facts.current_run_id,
|
|
1666
|
+
diagnostic: `workspace owner references run ${facts.current_run_id}, which is not active`,
|
|
1667
|
+
revision,
|
|
1668
|
+
});
|
|
1669
|
+
if (active) {
|
|
1670
|
+
if (facts.current_run_id !== active.run_id)
|
|
1671
|
+
return projection({
|
|
1672
|
+
state: "authority_conflict",
|
|
1673
|
+
owner_task_id: active.task_id,
|
|
1674
|
+
owner_run_id: active.run_id,
|
|
1675
|
+
diagnostic: "workspace owner contradicts the active run",
|
|
1676
|
+
revision,
|
|
1677
|
+
});
|
|
1678
|
+
if (active.claim_status === null)
|
|
1679
|
+
return projection({
|
|
1680
|
+
state: "authority_conflict",
|
|
1681
|
+
owner_task_id: active.task_id,
|
|
1682
|
+
owner_run_id: active.run_id,
|
|
1683
|
+
diagnostic: "active run carries no workspace claim",
|
|
1684
|
+
revision,
|
|
1685
|
+
});
|
|
1686
|
+
return projection({
|
|
1687
|
+
state: "active_owner",
|
|
1688
|
+
owner_task_id: active.task_id,
|
|
1689
|
+
owner_run_id: active.run_id,
|
|
1690
|
+
owner_lifecycle: "active",
|
|
1691
|
+
claim_lifecycle_status: active.claim_status,
|
|
1692
|
+
revision,
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
if (facts.requested_run_state && facts.requested_run_state !== "active")
|
|
1696
|
+
return projection({
|
|
1697
|
+
state: "terminal_owner",
|
|
1698
|
+
owner_task_id: taskId,
|
|
1699
|
+
owner_run_id: facts.requested_run_id,
|
|
1700
|
+
owner_lifecycle: facts.requested_run_state,
|
|
1701
|
+
revision,
|
|
1702
|
+
});
|
|
1703
|
+
if (facts.requested_run_state)
|
|
1704
|
+
return projection({
|
|
1705
|
+
state: "authority_conflict",
|
|
1706
|
+
owner_task_id: taskId,
|
|
1707
|
+
owner_run_id: facts.requested_run_id,
|
|
1708
|
+
diagnostic: "nonterminal run exists without a workspace owner",
|
|
1709
|
+
revision,
|
|
1710
|
+
});
|
|
1711
|
+
return projection({ state: "unowned", revision });
|
|
1786
1712
|
} catch (error) {
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1713
|
+
return projection({
|
|
1714
|
+
state: "authority_conflict",
|
|
1715
|
+
diagnostic: error instanceof Error ? error.message : String(error),
|
|
1716
|
+
revision: "",
|
|
1717
|
+
});
|
|
1790
1718
|
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function conflictProjection(taskId: string, diagnostic: string): KernelAuthorityProjection {
|
|
1791
1722
|
return {
|
|
1792
|
-
|
|
1793
|
-
|
|
1723
|
+
contract: "assurance_kernel/authority_projection/v1",
|
|
1724
|
+
requested_task_id: taskId,
|
|
1725
|
+
state: "authority_conflict",
|
|
1726
|
+
owner_task_id: null,
|
|
1727
|
+
owner_run_id: null,
|
|
1728
|
+
owner_lifecycle: null,
|
|
1729
|
+
claim_lifecycle_status: null,
|
|
1730
|
+
diagnostic,
|
|
1731
|
+
revision: "",
|
|
1794
1732
|
};
|
|
1795
1733
|
}
|
|
1734
|
+
|
|
1735
|
+
export function reconcileKernelAuthority(
|
|
1736
|
+
root: string,
|
|
1737
|
+
taskId: string,
|
|
1738
|
+
): KernelAuthorityProjection {
|
|
1739
|
+
validateTaskId(taskId);
|
|
1740
|
+
const projected = withKernelRead(root, (db) => {
|
|
1741
|
+
const conflict = retiredFileStoreConflict(root, db, taskId);
|
|
1742
|
+
if (conflict) return conflictProjection(taskId, conflict);
|
|
1743
|
+
return projectKernelAuthorityLocked(db, root, taskId);
|
|
1744
|
+
});
|
|
1745
|
+
// A store that confirms no run for this task is unowned *in this worktree*,
|
|
1746
|
+
// but committed audit evidence may still describe the task (a fresh clone
|
|
1747
|
+
// that has since recorded one unrelated mutation). Terminal evidence alone
|
|
1748
|
+
// classifies as terminal_owner in both states.
|
|
1749
|
+
if (projected && projected.state !== "unowned") return projected;
|
|
1750
|
+
const legacy = projected ? null : retiredFileStoreDiagnostic(root, null, taskId);
|
|
1751
|
+
if (legacy) return conflictProjection(taskId, legacy);
|
|
1752
|
+
try {
|
|
1753
|
+
const audit = readAuditTaskPair(root, taskId);
|
|
1754
|
+
if (audit)
|
|
1755
|
+
return {
|
|
1756
|
+
contract: "assurance_kernel/authority_projection/v1",
|
|
1757
|
+
requested_task_id: taskId,
|
|
1758
|
+
state: "terminal_owner",
|
|
1759
|
+
owner_task_id: taskId,
|
|
1760
|
+
owner_run_id: null,
|
|
1761
|
+
owner_lifecycle: null,
|
|
1762
|
+
claim_lifecycle_status: null,
|
|
1763
|
+
diagnostic: null,
|
|
1764
|
+
revision: audit.recordRevision,
|
|
1765
|
+
};
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
return {
|
|
1768
|
+
contract: "assurance_kernel/authority_projection/v1",
|
|
1769
|
+
requested_task_id: taskId,
|
|
1770
|
+
state: "authority_conflict",
|
|
1771
|
+
owner_task_id: taskId,
|
|
1772
|
+
owner_run_id: null,
|
|
1773
|
+
owner_lifecycle: null,
|
|
1774
|
+
claim_lifecycle_status: null,
|
|
1775
|
+
diagnostic: error instanceof Error ? error.message : String(error),
|
|
1776
|
+
revision: "",
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
return (
|
|
1780
|
+
projected ?? {
|
|
1781
|
+
contract: "assurance_kernel/authority_projection/v1",
|
|
1782
|
+
requested_task_id: taskId,
|
|
1783
|
+
state: "unowned",
|
|
1784
|
+
owner_task_id: null,
|
|
1785
|
+
owner_run_id: null,
|
|
1786
|
+
owner_lifecycle: null,
|
|
1787
|
+
claim_lifecycle_status: null,
|
|
1788
|
+
diagnostic: null,
|
|
1789
|
+
revision: "",
|
|
1790
|
+
}
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function retiredFileStoreDiagnostic(
|
|
1795
|
+
root: string,
|
|
1796
|
+
db: DatabaseSync | null = null,
|
|
1797
|
+
taskId: string | null = null,
|
|
1798
|
+
): string | null {
|
|
1799
|
+
return retiredFileStoreConflict(root, db, taskId);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* Remove one exactly proven stale terminal claim.
|
|
1804
|
+
*
|
|
1805
|
+
* The SQLite store derives ownership from the single active run, so a claim
|
|
1806
|
+
* that disagrees with the run index cannot exist: the state this operation
|
|
1807
|
+
* repaired is unreachable by construction. The entry point stays fail-closed
|
|
1808
|
+
* and is retained until the coordinated major release retires the tool.
|
|
1809
|
+
*/
|
|
1810
|
+
export function repairKernelAuthority(
|
|
1811
|
+
root: string,
|
|
1812
|
+
taskId: string,
|
|
1813
|
+
expectedProjectionRevision: string,
|
|
1814
|
+
_at = nowIso(),
|
|
1815
|
+
): KernelAuthorityProjection {
|
|
1816
|
+
validateTaskId(taskId);
|
|
1817
|
+
return withKernelTransaction(root, (db) => {
|
|
1818
|
+
assertNoRetiredFileStore(root, db, taskId);
|
|
1819
|
+
retireSupersededRetiredFiles(root, db, taskId);
|
|
1820
|
+
const projection = projectKernelAuthorityLocked(db, root, taskId);
|
|
1821
|
+
if (projection.state === "repairable_stale_claim") {
|
|
1822
|
+
if (
|
|
1823
|
+
projection.owner_task_id !== taskId ||
|
|
1824
|
+
projection.revision !== expectedProjectionRevision
|
|
1825
|
+
)
|
|
1826
|
+
throw new KernelStoreConflictError(
|
|
1827
|
+
"authority repair requires exact stale terminal proof",
|
|
1828
|
+
);
|
|
1829
|
+
return projection;
|
|
1830
|
+
}
|
|
1831
|
+
// The workspace owner is derived from the single active run, so a claim
|
|
1832
|
+
// that contradicts the run index cannot exist. This task's own retired
|
|
1833
|
+
// claim file is a proved duplicate and was just retired; the committed
|
|
1834
|
+
// authority is the answer.
|
|
1835
|
+
if (projection.state === "terminal_owner" || projection.state === "unowned")
|
|
1836
|
+
return projection;
|
|
1837
|
+
throw new KernelStoreConflictError(
|
|
1838
|
+
`authority repair is not available while authority is ${projection.state}`,
|
|
1839
|
+
);
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
export { stateDatabasePath };
|