@abelxiaoxing/cadence 1.1.0 → 1.2.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 +19 -3
- package/agents/implementation-worker.md +17 -11
- package/config/routes.example.json +0 -2
- package/package.json +2 -2
- package/prompts/abel-design.md +77 -17
- package/prompts/abel-diagnose.md +2 -0
- package/prompts/abel-implement.md +23 -11
- package/skills/abel-workflow/SKILL.md +42 -5
- package/src/agent-registry.ts +0 -1
- package/src/candidate-patch.ts +250 -0
- package/src/child-session.ts +27 -24
- package/src/contracts.ts +19 -10
- package/src/control-contracts.ts +15 -62
- package/src/delivery-compiler.ts +47 -17
- package/src/design-control.ts +1053 -0
- package/src/design-journal.ts +1616 -0
- package/src/index.ts +626 -196
- package/src/packet-runtime.ts +6 -6
- package/src/route-policy.ts +45 -17
- package/src/run-state.ts +43 -4
- package/src/run-store.ts +103 -15
- package/src/subagent-activity.ts +1 -1
- package/src/submit-tool.ts +149 -49
- package/src/verification-capability.ts +43 -15
- package/src/worker-broker.ts +0 -1
- package/src/workflow-engine.ts +416 -250
- package/agents/contract-reviewer.md +0 -11
|
@@ -0,0 +1,1616 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import {
|
|
4
|
+
type DesignEvidenceResult,
|
|
5
|
+
validateEvidenceResult,
|
|
6
|
+
} from "./contracts.ts";
|
|
7
|
+
import { canonicalJson } from "./run-state.ts";
|
|
8
|
+
import { prepareStateRoot, type ResolvedStateRoot } from "./state-root.ts";
|
|
9
|
+
|
|
10
|
+
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
11
|
+
const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
|
|
12
|
+
const ZERO_HASH = "0".repeat(64);
|
|
13
|
+
const MAX_PLAN_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const MAX_CONTRACT_BYTES = 256 * 1024;
|
|
15
|
+
|
|
16
|
+
const SCHEMA = `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS design_facts (
|
|
18
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
19
|
+
sequence INTEGER NOT NULL,
|
|
20
|
+
kind TEXT NOT NULL,
|
|
21
|
+
identity TEXT NOT NULL,
|
|
22
|
+
payload_json TEXT NOT NULL,
|
|
23
|
+
canonical_hash TEXT NOT NULL,
|
|
24
|
+
prior_record_hash TEXT NOT NULL,
|
|
25
|
+
record_hash TEXT NOT NULL,
|
|
26
|
+
PRIMARY KEY (run_id, sequence),
|
|
27
|
+
UNIQUE (run_id, kind, identity),
|
|
28
|
+
UNIQUE (run_id, record_hash)
|
|
29
|
+
) STRICT;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS design_operations (
|
|
32
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
33
|
+
operation_id TEXT NOT NULL,
|
|
34
|
+
operation TEXT NOT NULL,
|
|
35
|
+
request_hash TEXT NOT NULL,
|
|
36
|
+
outcome_json TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (run_id, operation_id)
|
|
38
|
+
) STRICT;
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS design_compiled_plans (
|
|
41
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
42
|
+
revision INTEGER NOT NULL,
|
|
43
|
+
fact_sequence INTEGER NOT NULL,
|
|
44
|
+
raw_sha256 TEXT NOT NULL,
|
|
45
|
+
canonical_hash TEXT NOT NULL,
|
|
46
|
+
plan_bytes BLOB NOT NULL,
|
|
47
|
+
PRIMARY KEY (run_id, revision),
|
|
48
|
+
UNIQUE (run_id, fact_sequence),
|
|
49
|
+
FOREIGN KEY (run_id, fact_sequence)
|
|
50
|
+
REFERENCES design_facts(run_id, sequence) ON DELETE CASCADE
|
|
51
|
+
) STRICT;
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS design_finalization_leases (
|
|
54
|
+
run_id TEXT PRIMARY KEY REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
55
|
+
operation_id TEXT NOT NULL,
|
|
56
|
+
token TEXT NOT NULL UNIQUE,
|
|
57
|
+
expires_at INTEGER NOT NULL
|
|
58
|
+
) STRICT;
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
type DecisionCategory = "behavior" | "technical";
|
|
62
|
+
type DesignGate = "gate-a" | "gate-b";
|
|
63
|
+
|
|
64
|
+
interface RunRow {
|
|
65
|
+
run_id: string;
|
|
66
|
+
root_hash: string;
|
|
67
|
+
stage: string;
|
|
68
|
+
change_name: string | null;
|
|
69
|
+
state: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface FactRow {
|
|
73
|
+
sequence: number;
|
|
74
|
+
kind: string;
|
|
75
|
+
identity: string;
|
|
76
|
+
payload_json: string;
|
|
77
|
+
canonical_hash: string;
|
|
78
|
+
prior_record_hash: string;
|
|
79
|
+
record_hash: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface OperationRow {
|
|
83
|
+
operation: string;
|
|
84
|
+
request_hash: string;
|
|
85
|
+
outcome_json: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DesignJournalOptions {
|
|
89
|
+
now?: () => number;
|
|
90
|
+
finalizationLeaseMs?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DesignFinalizationLease {
|
|
94
|
+
runId: string;
|
|
95
|
+
operationId: string;
|
|
96
|
+
token: string;
|
|
97
|
+
expiresAt: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DesignGateProof {
|
|
101
|
+
revision: number;
|
|
102
|
+
contractHash: string;
|
|
103
|
+
recordHash: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface DesignEvidenceProjection {
|
|
107
|
+
packetId: string;
|
|
108
|
+
resultHash: string;
|
|
109
|
+
recordHash: string;
|
|
110
|
+
sequence: number;
|
|
111
|
+
moduleName: string;
|
|
112
|
+
scope: string[];
|
|
113
|
+
filesRead: string[];
|
|
114
|
+
citations: Array<{
|
|
115
|
+
path: string;
|
|
116
|
+
lineStart: number;
|
|
117
|
+
lineEnd: number;
|
|
118
|
+
claimHash: string;
|
|
119
|
+
}>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DesignDecisionProjection {
|
|
123
|
+
decisionId: string;
|
|
124
|
+
category: DecisionCategory;
|
|
125
|
+
revision: number;
|
|
126
|
+
contractHash: string;
|
|
127
|
+
refs: string[];
|
|
128
|
+
recordHash: string;
|
|
129
|
+
sequence: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface DesignPlanProjection {
|
|
133
|
+
revision: number;
|
|
134
|
+
rawSha256: string;
|
|
135
|
+
canonicalHash: string;
|
|
136
|
+
recordHash: string;
|
|
137
|
+
sequence: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DesignGateProjection {
|
|
141
|
+
current: boolean;
|
|
142
|
+
proof?: DesignGateProof;
|
|
143
|
+
approvedSequence?: number;
|
|
144
|
+
staleReason?:
|
|
145
|
+
| "behavior-decision-changed"
|
|
146
|
+
| "technical-decision-changed"
|
|
147
|
+
| "plan-recompiled"
|
|
148
|
+
| "gate-a-reapproved"
|
|
149
|
+
| "gate-a-stale"
|
|
150
|
+
| "plan-missing";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface DesignStatusProjection {
|
|
154
|
+
runId: string;
|
|
155
|
+
change?: string;
|
|
156
|
+
evidence: DesignEvidenceProjection[];
|
|
157
|
+
decisions: DesignDecisionProjection[];
|
|
158
|
+
gates: {
|
|
159
|
+
gateA: DesignGateProjection;
|
|
160
|
+
gateB: DesignGateProjection;
|
|
161
|
+
};
|
|
162
|
+
plan: DesignPlanProjection | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface GateProofVerificationInput {
|
|
166
|
+
change: string;
|
|
167
|
+
gate: DesignGate;
|
|
168
|
+
proof: DesignGateProof;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FinalizedDeliveryVerificationInput {
|
|
172
|
+
change: string;
|
|
173
|
+
deliveryRevision: number;
|
|
174
|
+
receiptHash: string;
|
|
175
|
+
gateA: DesignGateProof;
|
|
176
|
+
gateB: DesignGateProof;
|
|
177
|
+
planCanonicalHash: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type DesignArtifactOperationFacts =
|
|
181
|
+
| {
|
|
182
|
+
runId: string;
|
|
183
|
+
operationId: string;
|
|
184
|
+
operation: "write-artifact";
|
|
185
|
+
path: string;
|
|
186
|
+
bytes: number;
|
|
187
|
+
rawSha256: string;
|
|
188
|
+
}
|
|
189
|
+
| {
|
|
190
|
+
runId: string;
|
|
191
|
+
operationId: string;
|
|
192
|
+
operation: "delete-artifact";
|
|
193
|
+
path: string;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export type DesignArtifactOperationOutcome =
|
|
197
|
+
| {
|
|
198
|
+
operation: "write-artifact";
|
|
199
|
+
runId: string;
|
|
200
|
+
path: string;
|
|
201
|
+
bytes: number;
|
|
202
|
+
rawSha256: string;
|
|
203
|
+
}
|
|
204
|
+
| {
|
|
205
|
+
operation: "delete-artifact";
|
|
206
|
+
runId: string;
|
|
207
|
+
path: string;
|
|
208
|
+
deleted: false;
|
|
209
|
+
}
|
|
210
|
+
| {
|
|
211
|
+
operation: "delete-artifact";
|
|
212
|
+
runId: string;
|
|
213
|
+
path: string;
|
|
214
|
+
deleted: true;
|
|
215
|
+
bytes: number;
|
|
216
|
+
rawSha256: string;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export interface DesignArtifactOperationState {
|
|
220
|
+
state: "pending" | "committed";
|
|
221
|
+
outcome: DesignArtifactOperationOutcome;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sha256(value: Uint8Array | string): string {
|
|
225
|
+
return createHash("sha256").update(value).digest("hex");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function contractHash(domain: string, value: string): string {
|
|
229
|
+
if (
|
|
230
|
+
typeof value !== "string" ||
|
|
231
|
+
value.trim().length === 0 ||
|
|
232
|
+
Buffer.byteLength(value, "utf8") > MAX_CONTRACT_BYTES
|
|
233
|
+
) {
|
|
234
|
+
throw new Error("design-transient-input-invalid");
|
|
235
|
+
}
|
|
236
|
+
return createHash("sha256")
|
|
237
|
+
.update(domain)
|
|
238
|
+
.update("\0")
|
|
239
|
+
.update(value.replace(/\r\n?/gu, "\n").trim())
|
|
240
|
+
.digest("hex");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function requireIdentifier(value: string, label: string): void {
|
|
244
|
+
if (!IDENTIFIER.test(value)) throw new Error(`invalid-${label}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function requireHash(value: string, label: string): void {
|
|
248
|
+
if (!SHA256.test(value)) throw new Error(`invalid-${label}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseRecord(value: string, code: string): Record<string, unknown> {
|
|
252
|
+
const parsed = JSON.parse(value) as unknown;
|
|
253
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
254
|
+
throw new Error(code);
|
|
255
|
+
}
|
|
256
|
+
return parsed as Record<string, unknown>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function hasExactKeys(
|
|
260
|
+
value: Record<string, unknown>,
|
|
261
|
+
keys: readonly string[],
|
|
262
|
+
): boolean {
|
|
263
|
+
const actual = Object.keys(value);
|
|
264
|
+
return (
|
|
265
|
+
actual.length === keys.length && actual.every((key) => keys.includes(key))
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function normalizeRefs(refs: readonly string[]): string[] {
|
|
270
|
+
if (
|
|
271
|
+
!Array.isArray(refs) ||
|
|
272
|
+
refs.length > 128 ||
|
|
273
|
+
refs.some(
|
|
274
|
+
(ref) =>
|
|
275
|
+
typeof ref !== "string" ||
|
|
276
|
+
ref.length === 0 ||
|
|
277
|
+
ref.length > 512 ||
|
|
278
|
+
ref.trim() !== ref ||
|
|
279
|
+
ref.includes("\0") ||
|
|
280
|
+
ref.includes("\n") ||
|
|
281
|
+
ref.includes("\r"),
|
|
282
|
+
)
|
|
283
|
+
) {
|
|
284
|
+
throw new Error("design-decision-refs-invalid");
|
|
285
|
+
}
|
|
286
|
+
return [...new Set(refs)].sort((left, right) => left.localeCompare(right));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export class DesignJournal {
|
|
290
|
+
readonly stateRoot: ResolvedStateRoot;
|
|
291
|
+
readonly #database: DatabaseSync;
|
|
292
|
+
readonly #now: () => number;
|
|
293
|
+
readonly #finalizationLeaseMs: number;
|
|
294
|
+
#closed = false;
|
|
295
|
+
|
|
296
|
+
private constructor(
|
|
297
|
+
stateRoot: ResolvedStateRoot,
|
|
298
|
+
database: DatabaseSync,
|
|
299
|
+
options: DesignJournalOptions,
|
|
300
|
+
) {
|
|
301
|
+
this.stateRoot = stateRoot;
|
|
302
|
+
this.#database = database;
|
|
303
|
+
this.#now = options.now ?? Date.now;
|
|
304
|
+
this.#finalizationLeaseMs = options.finalizationLeaseMs ?? 300_000;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
static open(
|
|
308
|
+
unresolved: ResolvedStateRoot,
|
|
309
|
+
options: DesignJournalOptions = {},
|
|
310
|
+
): DesignJournal {
|
|
311
|
+
if (
|
|
312
|
+
options.finalizationLeaseMs !== undefined &&
|
|
313
|
+
(!Number.isSafeInteger(options.finalizationLeaseMs) ||
|
|
314
|
+
options.finalizationLeaseMs < 1)
|
|
315
|
+
) {
|
|
316
|
+
throw new Error("design-finalization-lease-invalid");
|
|
317
|
+
}
|
|
318
|
+
const stateRoot = prepareStateRoot(unresolved);
|
|
319
|
+
const database = new DatabaseSync(stateRoot.databasePath);
|
|
320
|
+
database.exec("PRAGMA foreign_keys = ON");
|
|
321
|
+
database.exec("PRAGMA journal_mode = WAL");
|
|
322
|
+
database.exec("PRAGMA synchronous = FULL");
|
|
323
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
324
|
+
database.exec(SCHEMA);
|
|
325
|
+
return new DesignJournal(stateRoot, database, options);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#assertOpen(): void {
|
|
329
|
+
if (this.#closed) throw new Error("design-journal-closed");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#transaction<T>(work: () => T): T {
|
|
333
|
+
this.#assertOpen();
|
|
334
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
335
|
+
try {
|
|
336
|
+
const result = work();
|
|
337
|
+
this.#database.exec("COMMIT");
|
|
338
|
+
return result;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
this.#database.exec("ROLLBACK");
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#run(runId: string, mutable: boolean): RunRow {
|
|
346
|
+
const row = this.#database
|
|
347
|
+
.prepare(
|
|
348
|
+
`SELECT run_id, root_hash, stage, change_name, state
|
|
349
|
+
FROM runs WHERE run_id = ?`,
|
|
350
|
+
)
|
|
351
|
+
.get(runId) as RunRow | undefined;
|
|
352
|
+
if (
|
|
353
|
+
!row ||
|
|
354
|
+
row.root_hash !== this.stateRoot.consumerRootHash ||
|
|
355
|
+
row.stage !== "abel-design" ||
|
|
356
|
+
(mutable && ["completed", "discarded", "rejected"].includes(row.state))
|
|
357
|
+
) {
|
|
358
|
+
throw new Error("design-run-invalid");
|
|
359
|
+
}
|
|
360
|
+
return row;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
assertDesignRun(runId: string): void {
|
|
364
|
+
this.#assertOpen();
|
|
365
|
+
this.#run(runId, true);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
operationOutcome(
|
|
369
|
+
runId: string,
|
|
370
|
+
operationId: string,
|
|
371
|
+
operation: string,
|
|
372
|
+
): Record<string, unknown> | undefined {
|
|
373
|
+
this.#assertOpen();
|
|
374
|
+
this.#run(runId, false);
|
|
375
|
+
requireIdentifier(operationId, "design-operation-id");
|
|
376
|
+
const row = this.#database
|
|
377
|
+
.prepare(
|
|
378
|
+
`SELECT operation, outcome_json FROM design_operations
|
|
379
|
+
WHERE run_id = ? AND operation_id = ?`,
|
|
380
|
+
)
|
|
381
|
+
.get(runId, operationId) as
|
|
382
|
+
| Pick<OperationRow, "operation" | "outcome_json">
|
|
383
|
+
| undefined;
|
|
384
|
+
if (!row) return undefined;
|
|
385
|
+
if (row.operation !== operation)
|
|
386
|
+
throw new Error("design-operation-conflict");
|
|
387
|
+
return structuredClone(
|
|
388
|
+
parseRecord(row.outcome_json, "design-operation-outcome-invalid"),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#artifactOperationRequest(
|
|
393
|
+
input: DesignArtifactOperationFacts,
|
|
394
|
+
): Record<string, unknown> {
|
|
395
|
+
requireIdentifier(input.operationId, "design-operation-id");
|
|
396
|
+
if (
|
|
397
|
+
typeof input.path !== "string" ||
|
|
398
|
+
input.path.length === 0 ||
|
|
399
|
+
input.path.length > 512
|
|
400
|
+
) {
|
|
401
|
+
throw new Error("design-artifact-path-invalid");
|
|
402
|
+
}
|
|
403
|
+
if (input.operation === "write-artifact") {
|
|
404
|
+
if (
|
|
405
|
+
!Number.isSafeInteger(input.bytes) ||
|
|
406
|
+
input.bytes < 0 ||
|
|
407
|
+
input.bytes > MAX_PLAN_BYTES
|
|
408
|
+
) {
|
|
409
|
+
throw new Error("design-artifact-content-too-large");
|
|
410
|
+
}
|
|
411
|
+
requireHash(input.rawSha256, "design-artifact-hash");
|
|
412
|
+
return {
|
|
413
|
+
operation: input.operation,
|
|
414
|
+
path: input.path,
|
|
415
|
+
bytes: input.bytes,
|
|
416
|
+
rawSha256: input.rawSha256,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return { operation: input.operation, path: input.path };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#validateArtifactOperationOutcome(
|
|
423
|
+
input: DesignArtifactOperationFacts,
|
|
424
|
+
value: unknown,
|
|
425
|
+
): DesignArtifactOperationOutcome {
|
|
426
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
427
|
+
throw new Error("design-artifact-operation-outcome-invalid");
|
|
428
|
+
}
|
|
429
|
+
const outcome = value as Record<string, unknown>;
|
|
430
|
+
if (
|
|
431
|
+
outcome.operation !== input.operation ||
|
|
432
|
+
outcome.runId !== input.runId ||
|
|
433
|
+
outcome.path !== input.path
|
|
434
|
+
) {
|
|
435
|
+
throw new Error("design-artifact-operation-outcome-invalid");
|
|
436
|
+
}
|
|
437
|
+
if (input.operation === "write-artifact") {
|
|
438
|
+
if (
|
|
439
|
+
!hasExactKeys(outcome, [
|
|
440
|
+
"operation",
|
|
441
|
+
"runId",
|
|
442
|
+
"path",
|
|
443
|
+
"bytes",
|
|
444
|
+
"rawSha256",
|
|
445
|
+
]) ||
|
|
446
|
+
outcome.bytes !== input.bytes ||
|
|
447
|
+
outcome.rawSha256 !== input.rawSha256
|
|
448
|
+
) {
|
|
449
|
+
throw new Error("design-artifact-operation-outcome-invalid");
|
|
450
|
+
}
|
|
451
|
+
return structuredClone(outcome) as DesignArtifactOperationOutcome;
|
|
452
|
+
}
|
|
453
|
+
if (outcome.deleted === false) {
|
|
454
|
+
if (!hasExactKeys(outcome, ["operation", "runId", "path", "deleted"])) {
|
|
455
|
+
throw new Error("design-artifact-operation-outcome-invalid");
|
|
456
|
+
}
|
|
457
|
+
return structuredClone(outcome) as DesignArtifactOperationOutcome;
|
|
458
|
+
}
|
|
459
|
+
if (
|
|
460
|
+
!hasExactKeys(outcome, [
|
|
461
|
+
"operation",
|
|
462
|
+
"runId",
|
|
463
|
+
"path",
|
|
464
|
+
"deleted",
|
|
465
|
+
"bytes",
|
|
466
|
+
"rawSha256",
|
|
467
|
+
]) ||
|
|
468
|
+
outcome.deleted !== true ||
|
|
469
|
+
!Number.isSafeInteger(outcome.bytes) ||
|
|
470
|
+
Number(outcome.bytes) < 0 ||
|
|
471
|
+
Number(outcome.bytes) > MAX_PLAN_BYTES ||
|
|
472
|
+
!SHA256.test(String(outcome.rawSha256))
|
|
473
|
+
) {
|
|
474
|
+
throw new Error("design-artifact-operation-outcome-invalid");
|
|
475
|
+
}
|
|
476
|
+
return structuredClone(outcome) as DesignArtifactOperationOutcome;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
artifactOperationState(
|
|
480
|
+
input: DesignArtifactOperationFacts,
|
|
481
|
+
): DesignArtifactOperationState | undefined {
|
|
482
|
+
this.#assertOpen();
|
|
483
|
+
this.#run(input.runId, false);
|
|
484
|
+
const request = this.#artifactOperationRequest(input);
|
|
485
|
+
const row = this.#database
|
|
486
|
+
.prepare(
|
|
487
|
+
`SELECT operation, request_hash, outcome_json
|
|
488
|
+
FROM design_operations WHERE run_id = ? AND operation_id = ?`,
|
|
489
|
+
)
|
|
490
|
+
.get(input.runId, input.operationId) as OperationRow | undefined;
|
|
491
|
+
if (!row) return undefined;
|
|
492
|
+
if (
|
|
493
|
+
row.operation !== input.operation ||
|
|
494
|
+
row.request_hash !== sha256(canonicalJson(request))
|
|
495
|
+
) {
|
|
496
|
+
throw new Error("design-operation-conflict");
|
|
497
|
+
}
|
|
498
|
+
const stored = parseRecord(
|
|
499
|
+
row.outcome_json,
|
|
500
|
+
"design-artifact-operation-state-invalid",
|
|
501
|
+
);
|
|
502
|
+
if (stored.state !== "pending" && stored.state !== "committed") {
|
|
503
|
+
throw new Error("design-artifact-operation-state-invalid");
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
state: stored.state,
|
|
507
|
+
outcome: this.#validateArtifactOperationOutcome(input, stored.outcome),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
prepareArtifactOperation(
|
|
512
|
+
input: DesignArtifactOperationFacts,
|
|
513
|
+
lease: DesignFinalizationLease,
|
|
514
|
+
outcome: DesignArtifactOperationOutcome,
|
|
515
|
+
): DesignArtifactOperationState {
|
|
516
|
+
if (
|
|
517
|
+
lease.runId !== input.runId ||
|
|
518
|
+
lease.operationId !== input.operationId
|
|
519
|
+
) {
|
|
520
|
+
throw new Error("design-finalization-lease-fenced");
|
|
521
|
+
}
|
|
522
|
+
return this.#transaction(() => {
|
|
523
|
+
this.#run(input.runId, true);
|
|
524
|
+
this.#assertFinalizationLease(lease);
|
|
525
|
+
const existing = this.artifactOperationState(input);
|
|
526
|
+
if (existing) return existing;
|
|
527
|
+
const request = this.#artifactOperationRequest(input);
|
|
528
|
+
const validated = this.#validateArtifactOperationOutcome(input, outcome);
|
|
529
|
+
const state: DesignArtifactOperationState = {
|
|
530
|
+
state: "pending",
|
|
531
|
+
outcome: validated,
|
|
532
|
+
};
|
|
533
|
+
this.#database
|
|
534
|
+
.prepare(
|
|
535
|
+
`INSERT INTO design_operations(
|
|
536
|
+
run_id, operation_id, operation, request_hash, outcome_json
|
|
537
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
538
|
+
)
|
|
539
|
+
.run(
|
|
540
|
+
input.runId,
|
|
541
|
+
input.operationId,
|
|
542
|
+
input.operation,
|
|
543
|
+
sha256(canonicalJson(request)),
|
|
544
|
+
canonicalJson(state),
|
|
545
|
+
);
|
|
546
|
+
return structuredClone(state);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
commitArtifactOperation(
|
|
551
|
+
input: DesignArtifactOperationFacts,
|
|
552
|
+
lease: DesignFinalizationLease,
|
|
553
|
+
): DesignArtifactOperationOutcome {
|
|
554
|
+
if (
|
|
555
|
+
lease.runId !== input.runId ||
|
|
556
|
+
lease.operationId !== input.operationId
|
|
557
|
+
) {
|
|
558
|
+
throw new Error("design-finalization-lease-fenced");
|
|
559
|
+
}
|
|
560
|
+
return this.#transaction(() => {
|
|
561
|
+
this.#run(input.runId, true);
|
|
562
|
+
this.#assertFinalizationLease(lease);
|
|
563
|
+
const existing = this.artifactOperationState(input);
|
|
564
|
+
if (!existing) throw new Error("design-artifact-operation-unprepared");
|
|
565
|
+
if (existing.state === "committed") {
|
|
566
|
+
return structuredClone(existing.outcome);
|
|
567
|
+
}
|
|
568
|
+
this.#database
|
|
569
|
+
.prepare(
|
|
570
|
+
`UPDATE design_operations SET outcome_json = ?
|
|
571
|
+
WHERE run_id = ? AND operation_id = ?`,
|
|
572
|
+
)
|
|
573
|
+
.run(
|
|
574
|
+
canonicalJson({ state: "committed", outcome: existing.outcome }),
|
|
575
|
+
input.runId,
|
|
576
|
+
input.operationId,
|
|
577
|
+
);
|
|
578
|
+
return structuredClone(existing.outcome);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
assertNoPendingArtifactOperations(runId: string): void {
|
|
583
|
+
this.#assertOpen();
|
|
584
|
+
this.#run(runId, true);
|
|
585
|
+
const rows = this.#database
|
|
586
|
+
.prepare(
|
|
587
|
+
`SELECT outcome_json FROM design_operations
|
|
588
|
+
WHERE run_id = ? AND operation IN ('write-artifact', 'delete-artifact')`,
|
|
589
|
+
)
|
|
590
|
+
.all(runId) as unknown as Array<{ outcome_json: string }>;
|
|
591
|
+
for (const row of rows) {
|
|
592
|
+
const state = parseRecord(
|
|
593
|
+
row.outcome_json,
|
|
594
|
+
"design-artifact-operation-state-invalid",
|
|
595
|
+
).state;
|
|
596
|
+
if (state !== "pending" && state !== "committed") {
|
|
597
|
+
throw new Error("design-artifact-operation-state-invalid");
|
|
598
|
+
}
|
|
599
|
+
if (state === "pending") {
|
|
600
|
+
throw new Error("design-artifact-operation-pending");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
latestFinalizationOutcome(
|
|
606
|
+
runId: string,
|
|
607
|
+
): { operationId: string; outcome: Record<string, unknown> } | undefined {
|
|
608
|
+
this.#assertOpen();
|
|
609
|
+
this.#run(runId, false);
|
|
610
|
+
const row = this.#database
|
|
611
|
+
.prepare(
|
|
612
|
+
`SELECT operation_id, outcome_json FROM design_operations
|
|
613
|
+
WHERE run_id = ? AND operation = 'finalize-delivery'
|
|
614
|
+
ORDER BY rowid DESC LIMIT 1`,
|
|
615
|
+
)
|
|
616
|
+
.get(runId) as { operation_id: string; outcome_json: string } | undefined;
|
|
617
|
+
return row
|
|
618
|
+
? {
|
|
619
|
+
operationId: row.operation_id,
|
|
620
|
+
outcome: structuredClone(
|
|
621
|
+
parseRecord(
|
|
622
|
+
row.outcome_json,
|
|
623
|
+
"design-finalization-outcome-invalid",
|
|
624
|
+
),
|
|
625
|
+
),
|
|
626
|
+
}
|
|
627
|
+
: undefined;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
acquireFinalizationLease(input: {
|
|
631
|
+
runId: string;
|
|
632
|
+
operationId: string;
|
|
633
|
+
}): DesignFinalizationLease {
|
|
634
|
+
requireIdentifier(input.operationId, "design-operation-id");
|
|
635
|
+
return this.#transaction(() => {
|
|
636
|
+
this.#run(input.runId, true);
|
|
637
|
+
const now = this.#now();
|
|
638
|
+
const existing = this.#database
|
|
639
|
+
.prepare(
|
|
640
|
+
`SELECT operation_id, token, expires_at
|
|
641
|
+
FROM design_finalization_leases WHERE run_id = ?`,
|
|
642
|
+
)
|
|
643
|
+
.get(input.runId) as
|
|
644
|
+
| { operation_id: string; token: string; expires_at: number }
|
|
645
|
+
| undefined;
|
|
646
|
+
if (existing && existing.expires_at > now) {
|
|
647
|
+
throw new Error("design-finalization-busy");
|
|
648
|
+
}
|
|
649
|
+
if (existing) {
|
|
650
|
+
this.#database
|
|
651
|
+
.prepare("DELETE FROM design_finalization_leases WHERE run_id = ?")
|
|
652
|
+
.run(input.runId);
|
|
653
|
+
}
|
|
654
|
+
const lease: DesignFinalizationLease = {
|
|
655
|
+
runId: input.runId,
|
|
656
|
+
operationId: input.operationId,
|
|
657
|
+
token: randomUUID(),
|
|
658
|
+
expiresAt: now + this.#finalizationLeaseMs,
|
|
659
|
+
};
|
|
660
|
+
this.#database
|
|
661
|
+
.prepare(
|
|
662
|
+
`INSERT INTO design_finalization_leases(
|
|
663
|
+
run_id, operation_id, token, expires_at
|
|
664
|
+
) VALUES (?, ?, ?, ?)`,
|
|
665
|
+
)
|
|
666
|
+
.run(lease.runId, lease.operationId, lease.token, lease.expiresAt);
|
|
667
|
+
return lease;
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
#assertFinalizationLease(lease: DesignFinalizationLease): void {
|
|
672
|
+
const row = this.#database
|
|
673
|
+
.prepare(
|
|
674
|
+
`SELECT operation_id, token, expires_at
|
|
675
|
+
FROM design_finalization_leases WHERE run_id = ?`,
|
|
676
|
+
)
|
|
677
|
+
.get(lease.runId) as
|
|
678
|
+
| { operation_id: string; token: string; expires_at: number }
|
|
679
|
+
| undefined;
|
|
680
|
+
if (
|
|
681
|
+
!row ||
|
|
682
|
+
row.operation_id !== lease.operationId ||
|
|
683
|
+
row.token !== lease.token ||
|
|
684
|
+
row.expires_at !== lease.expiresAt ||
|
|
685
|
+
row.expires_at <= this.#now()
|
|
686
|
+
) {
|
|
687
|
+
throw new Error("design-finalization-lease-fenced");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
assertFinalizationLease(lease: DesignFinalizationLease): void {
|
|
692
|
+
this.#assertOpen();
|
|
693
|
+
this.#assertFinalizationLease(lease);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
releaseFinalizationLease(lease: DesignFinalizationLease): void {
|
|
697
|
+
this.#transaction(() => {
|
|
698
|
+
this.#database
|
|
699
|
+
.prepare(
|
|
700
|
+
`DELETE FROM design_finalization_leases
|
|
701
|
+
WHERE run_id = ? AND operation_id = ? AND token = ?`,
|
|
702
|
+
)
|
|
703
|
+
.run(lease.runId, lease.operationId, lease.token);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
#facts(runId: string): FactRow[] {
|
|
708
|
+
return this.#database
|
|
709
|
+
.prepare(
|
|
710
|
+
`SELECT sequence, kind, identity, payload_json, canonical_hash,
|
|
711
|
+
prior_record_hash, record_hash
|
|
712
|
+
FROM design_facts WHERE run_id = ? ORDER BY sequence`,
|
|
713
|
+
)
|
|
714
|
+
.all(runId) as unknown as FactRow[];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
#appendFact(
|
|
718
|
+
runId: string,
|
|
719
|
+
kind: string,
|
|
720
|
+
identity: string,
|
|
721
|
+
payload: Record<string, unknown>,
|
|
722
|
+
): FactRow {
|
|
723
|
+
const latest = this.#database
|
|
724
|
+
.prepare(
|
|
725
|
+
`SELECT sequence, record_hash FROM design_facts
|
|
726
|
+
WHERE run_id = ? ORDER BY sequence DESC LIMIT 1`,
|
|
727
|
+
)
|
|
728
|
+
.get(runId) as { sequence: number; record_hash: string } | undefined;
|
|
729
|
+
const sequence = (latest?.sequence ?? 0) + 1;
|
|
730
|
+
const canonicalHash = sha256(canonicalJson(payload));
|
|
731
|
+
const priorRecordHash = latest?.record_hash ?? ZERO_HASH;
|
|
732
|
+
const recordHash = sha256(
|
|
733
|
+
canonicalJson({
|
|
734
|
+
runId,
|
|
735
|
+
sequence,
|
|
736
|
+
kind,
|
|
737
|
+
identity,
|
|
738
|
+
canonicalHash,
|
|
739
|
+
priorRecordHash,
|
|
740
|
+
}),
|
|
741
|
+
);
|
|
742
|
+
this.#database
|
|
743
|
+
.prepare(
|
|
744
|
+
`INSERT INTO design_facts(
|
|
745
|
+
run_id, sequence, kind, identity, payload_json, canonical_hash,
|
|
746
|
+
prior_record_hash, record_hash
|
|
747
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
748
|
+
)
|
|
749
|
+
.run(
|
|
750
|
+
runId,
|
|
751
|
+
sequence,
|
|
752
|
+
kind,
|
|
753
|
+
identity,
|
|
754
|
+
canonicalJson(payload),
|
|
755
|
+
canonicalHash,
|
|
756
|
+
priorRecordHash,
|
|
757
|
+
recordHash,
|
|
758
|
+
);
|
|
759
|
+
return {
|
|
760
|
+
sequence,
|
|
761
|
+
kind,
|
|
762
|
+
identity,
|
|
763
|
+
payload_json: canonicalJson(payload),
|
|
764
|
+
canonical_hash: canonicalHash,
|
|
765
|
+
prior_record_hash: priorRecordHash,
|
|
766
|
+
record_hash: recordHash,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
#operationReplay(
|
|
771
|
+
runId: string,
|
|
772
|
+
operationId: string,
|
|
773
|
+
operation: string,
|
|
774
|
+
request: Record<string, unknown>,
|
|
775
|
+
): Record<string, unknown> | undefined {
|
|
776
|
+
requireIdentifier(operationId, "design-operation-id");
|
|
777
|
+
const requestHash = sha256(canonicalJson(request));
|
|
778
|
+
const row = this.#database
|
|
779
|
+
.prepare(
|
|
780
|
+
`SELECT operation, request_hash, outcome_json
|
|
781
|
+
FROM design_operations WHERE run_id = ? AND operation_id = ?`,
|
|
782
|
+
)
|
|
783
|
+
.get(runId, operationId) as OperationRow | undefined;
|
|
784
|
+
if (!row) return undefined;
|
|
785
|
+
if (row.operation !== operation || row.request_hash !== requestHash) {
|
|
786
|
+
throw new Error("design-operation-conflict");
|
|
787
|
+
}
|
|
788
|
+
return parseRecord(row.outcome_json, "design-operation-outcome-invalid");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#recordOperation(
|
|
792
|
+
runId: string,
|
|
793
|
+
operationId: string,
|
|
794
|
+
operation: string,
|
|
795
|
+
request: Record<string, unknown>,
|
|
796
|
+
outcome: Record<string, unknown>,
|
|
797
|
+
): void {
|
|
798
|
+
this.#database
|
|
799
|
+
.prepare(
|
|
800
|
+
`INSERT INTO design_operations(
|
|
801
|
+
run_id, operation_id, operation, request_hash, outcome_json
|
|
802
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
803
|
+
)
|
|
804
|
+
.run(
|
|
805
|
+
runId,
|
|
806
|
+
operationId,
|
|
807
|
+
operation,
|
|
808
|
+
sha256(canonicalJson(request)),
|
|
809
|
+
canonicalJson(outcome),
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
recordEvidence(input: {
|
|
814
|
+
runId: string;
|
|
815
|
+
evidence: DesignEvidenceResult;
|
|
816
|
+
}): DesignEvidenceProjection {
|
|
817
|
+
return this.#transaction(() => {
|
|
818
|
+
this.#run(input.runId, true);
|
|
819
|
+
const validation = validateEvidenceResult(input.evidence);
|
|
820
|
+
if (!validation.ok || input.evidence.role !== "design-explorer") {
|
|
821
|
+
throw new Error("design-evidence-invalid");
|
|
822
|
+
}
|
|
823
|
+
const resultHash = sha256(canonicalJson(input.evidence));
|
|
824
|
+
const existing = this.#database
|
|
825
|
+
.prepare(
|
|
826
|
+
`SELECT sequence, payload_json, record_hash
|
|
827
|
+
FROM design_facts
|
|
828
|
+
WHERE run_id = ? AND kind = 'evidence' AND identity = ?`,
|
|
829
|
+
)
|
|
830
|
+
.get(input.runId, input.evidence.packet_id) as
|
|
831
|
+
| { sequence: number; payload_json: string; record_hash: string }
|
|
832
|
+
| undefined;
|
|
833
|
+
if (existing) {
|
|
834
|
+
const payload = parseRecord(
|
|
835
|
+
existing.payload_json,
|
|
836
|
+
"design-evidence-record-invalid",
|
|
837
|
+
);
|
|
838
|
+
if (payload.resultHash !== resultHash) {
|
|
839
|
+
throw new Error("design-evidence-conflict");
|
|
840
|
+
}
|
|
841
|
+
return this.#evidenceProjection(existing, payload);
|
|
842
|
+
}
|
|
843
|
+
const payload = {
|
|
844
|
+
packetId: input.evidence.packet_id,
|
|
845
|
+
resultHash,
|
|
846
|
+
moduleName: input.evidence.module_name,
|
|
847
|
+
scope: [...new Set(input.evidence.scope)].sort(),
|
|
848
|
+
filesRead: [...new Set(input.evidence.files_read)].sort(),
|
|
849
|
+
citations: input.evidence.evidence
|
|
850
|
+
.map((citation) => ({
|
|
851
|
+
path: citation.path,
|
|
852
|
+
lineStart: citation.line_start,
|
|
853
|
+
lineEnd: citation.line_end,
|
|
854
|
+
claimHash: sha256(citation.claim),
|
|
855
|
+
}))
|
|
856
|
+
.sort(
|
|
857
|
+
(left, right) =>
|
|
858
|
+
left.path.localeCompare(right.path) ||
|
|
859
|
+
left.lineStart - right.lineStart ||
|
|
860
|
+
left.lineEnd - right.lineEnd ||
|
|
861
|
+
left.claimHash.localeCompare(right.claimHash),
|
|
862
|
+
),
|
|
863
|
+
};
|
|
864
|
+
const fact = this.#appendFact(
|
|
865
|
+
input.runId,
|
|
866
|
+
"evidence",
|
|
867
|
+
input.evidence.packet_id,
|
|
868
|
+
payload,
|
|
869
|
+
);
|
|
870
|
+
return this.#evidenceProjection(fact, payload);
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
#evidenceProjection(
|
|
875
|
+
row: Pick<FactRow, "sequence" | "record_hash">,
|
|
876
|
+
payload: Record<string, unknown>,
|
|
877
|
+
): DesignEvidenceProjection {
|
|
878
|
+
return {
|
|
879
|
+
packetId: String(payload.packetId),
|
|
880
|
+
resultHash: String(payload.resultHash),
|
|
881
|
+
recordHash: row.record_hash,
|
|
882
|
+
sequence: row.sequence,
|
|
883
|
+
moduleName: String(payload.moduleName),
|
|
884
|
+
scope: structuredClone(payload.scope as string[]),
|
|
885
|
+
filesRead: structuredClone(payload.filesRead as string[]),
|
|
886
|
+
citations: structuredClone(
|
|
887
|
+
payload.citations as DesignEvidenceProjection["citations"],
|
|
888
|
+
),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
recordDecision(input: {
|
|
893
|
+
runId: string;
|
|
894
|
+
operationId: string;
|
|
895
|
+
decisionId: string;
|
|
896
|
+
category: DecisionCategory;
|
|
897
|
+
contract: string;
|
|
898
|
+
refs: string[];
|
|
899
|
+
}): Record<string, unknown> {
|
|
900
|
+
requireIdentifier(input.decisionId, "design-decision-id");
|
|
901
|
+
const decisionContractHash = contractHash(
|
|
902
|
+
"abel-design-decision-v1",
|
|
903
|
+
input.contract,
|
|
904
|
+
);
|
|
905
|
+
if (input.category !== "behavior" && input.category !== "technical") {
|
|
906
|
+
throw new Error("design-decision-category-invalid");
|
|
907
|
+
}
|
|
908
|
+
const refs = normalizeRefs(input.refs);
|
|
909
|
+
const request = {
|
|
910
|
+
operation: "record-decision",
|
|
911
|
+
decisionId: input.decisionId,
|
|
912
|
+
category: input.category,
|
|
913
|
+
contractHash: decisionContractHash,
|
|
914
|
+
refs,
|
|
915
|
+
};
|
|
916
|
+
return this.#transaction(() => {
|
|
917
|
+
this.#run(input.runId, true);
|
|
918
|
+
const replay = this.#operationReplay(
|
|
919
|
+
input.runId,
|
|
920
|
+
input.operationId,
|
|
921
|
+
"record-decision",
|
|
922
|
+
request,
|
|
923
|
+
);
|
|
924
|
+
if (replay) return structuredClone(replay);
|
|
925
|
+
const prior = this.#latestDecision(input.runId, input.decisionId);
|
|
926
|
+
if (prior && prior.category !== input.category) {
|
|
927
|
+
throw new Error("design-decision-category-conflict");
|
|
928
|
+
}
|
|
929
|
+
let decision: DesignDecisionProjection;
|
|
930
|
+
if (
|
|
931
|
+
prior &&
|
|
932
|
+
prior.contractHash === decisionContractHash &&
|
|
933
|
+
canonicalJson(prior.refs) === canonicalJson(refs)
|
|
934
|
+
) {
|
|
935
|
+
decision = prior;
|
|
936
|
+
} else {
|
|
937
|
+
const revision = (prior?.revision ?? 0) + 1;
|
|
938
|
+
const payload = {
|
|
939
|
+
decisionId: input.decisionId,
|
|
940
|
+
category: input.category,
|
|
941
|
+
revision,
|
|
942
|
+
contractHash: decisionContractHash,
|
|
943
|
+
refs,
|
|
944
|
+
};
|
|
945
|
+
const fact = this.#appendFact(
|
|
946
|
+
input.runId,
|
|
947
|
+
"decision",
|
|
948
|
+
`${input.decisionId}:${revision}`,
|
|
949
|
+
payload,
|
|
950
|
+
);
|
|
951
|
+
decision = this.#decisionProjection(fact, payload);
|
|
952
|
+
}
|
|
953
|
+
const outcome = {
|
|
954
|
+
operation: "record-decision",
|
|
955
|
+
runId: input.runId,
|
|
956
|
+
decision,
|
|
957
|
+
gates: this.#gateStatus(input.runId),
|
|
958
|
+
};
|
|
959
|
+
this.#recordOperation(
|
|
960
|
+
input.runId,
|
|
961
|
+
input.operationId,
|
|
962
|
+
"record-decision",
|
|
963
|
+
request,
|
|
964
|
+
outcome,
|
|
965
|
+
);
|
|
966
|
+
return structuredClone(outcome);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
#decisionProjection(
|
|
971
|
+
fact: Pick<FactRow, "sequence" | "record_hash">,
|
|
972
|
+
payload: Record<string, unknown>,
|
|
973
|
+
): DesignDecisionProjection {
|
|
974
|
+
return {
|
|
975
|
+
decisionId: String(payload.decisionId),
|
|
976
|
+
category: payload.category as DecisionCategory,
|
|
977
|
+
revision: Number(payload.revision),
|
|
978
|
+
contractHash: String(payload.contractHash),
|
|
979
|
+
refs: structuredClone(payload.refs as string[]),
|
|
980
|
+
recordHash: fact.record_hash,
|
|
981
|
+
sequence: fact.sequence,
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
#latestDecision(
|
|
986
|
+
runId: string,
|
|
987
|
+
decisionId: string,
|
|
988
|
+
): DesignDecisionProjection | undefined {
|
|
989
|
+
const rows = this.#facts(runId).filter((row) => row.kind === "decision");
|
|
990
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
991
|
+
const row = rows[index];
|
|
992
|
+
const payload = parseRecord(
|
|
993
|
+
row.payload_json,
|
|
994
|
+
"design-decision-record-invalid",
|
|
995
|
+
);
|
|
996
|
+
if (payload.decisionId === decisionId) {
|
|
997
|
+
return this.#decisionProjection(row, payload);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
approveGate(
|
|
1004
|
+
input:
|
|
1005
|
+
| {
|
|
1006
|
+
runId: string;
|
|
1007
|
+
operationId: string;
|
|
1008
|
+
gate: "gate-a";
|
|
1009
|
+
contract: string;
|
|
1010
|
+
}
|
|
1011
|
+
| {
|
|
1012
|
+
runId: string;
|
|
1013
|
+
operationId: string;
|
|
1014
|
+
gate: "gate-b";
|
|
1015
|
+
},
|
|
1016
|
+
): {
|
|
1017
|
+
operation: "approve-gate";
|
|
1018
|
+
runId: string;
|
|
1019
|
+
gate: DesignGate;
|
|
1020
|
+
proof: DesignGateProof;
|
|
1021
|
+
} {
|
|
1022
|
+
if (input.gate !== "gate-a" && input.gate !== "gate-b") {
|
|
1023
|
+
throw new Error("design-gate-invalid");
|
|
1024
|
+
}
|
|
1025
|
+
const gateAContractHash =
|
|
1026
|
+
input.gate === "gate-a"
|
|
1027
|
+
? contractHash("abel-design-gate-a-v1", input.contract)
|
|
1028
|
+
: undefined;
|
|
1029
|
+
return this.#transaction(() => {
|
|
1030
|
+
this.#run(input.runId, true);
|
|
1031
|
+
const request = {
|
|
1032
|
+
operation: "approve-gate",
|
|
1033
|
+
gate: input.gate,
|
|
1034
|
+
...(gateAContractHash ? { contractHash: gateAContractHash } : {}),
|
|
1035
|
+
};
|
|
1036
|
+
const replay = this.#operationReplay(
|
|
1037
|
+
input.runId,
|
|
1038
|
+
input.operationId,
|
|
1039
|
+
"approve-gate",
|
|
1040
|
+
request,
|
|
1041
|
+
);
|
|
1042
|
+
if (replay)
|
|
1043
|
+
return structuredClone(replay) as ReturnType<
|
|
1044
|
+
DesignJournal["approveGate"]
|
|
1045
|
+
>;
|
|
1046
|
+
let approvedContractHash = gateAContractHash;
|
|
1047
|
+
if (input.gate === "gate-b") {
|
|
1048
|
+
const current = this.#gateStatus(input.runId);
|
|
1049
|
+
if (!current.gateA.current) throw new Error("design-gate-a-required");
|
|
1050
|
+
const plan = this.#currentPlanProjection(input.runId);
|
|
1051
|
+
if (!plan) throw new Error("design-plan-required");
|
|
1052
|
+
const latestDecisionSequence = this.#facts(input.runId)
|
|
1053
|
+
.filter((fact) => fact.kind === "decision")
|
|
1054
|
+
.at(-1)?.sequence;
|
|
1055
|
+
if (
|
|
1056
|
+
plan.sequence <= (current.gateA.approvedSequence ?? 0) ||
|
|
1057
|
+
(latestDecisionSequence !== undefined &&
|
|
1058
|
+
plan.sequence <= latestDecisionSequence)
|
|
1059
|
+
) {
|
|
1060
|
+
throw new Error("design-plan-stale");
|
|
1061
|
+
}
|
|
1062
|
+
approvedContractHash = plan.canonicalHash;
|
|
1063
|
+
}
|
|
1064
|
+
if (!approvedContractHash)
|
|
1065
|
+
throw new Error("design-transient-input-invalid");
|
|
1066
|
+
const existing = this.#latestApproval(input.runId, input.gate);
|
|
1067
|
+
const currentGate = this.#gateStatus(input.runId)[
|
|
1068
|
+
input.gate === "gate-a" ? "gateA" : "gateB"
|
|
1069
|
+
];
|
|
1070
|
+
let proof: DesignGateProof;
|
|
1071
|
+
if (
|
|
1072
|
+
existing &&
|
|
1073
|
+
currentGate.current &&
|
|
1074
|
+
existing.proof.contractHash === approvedContractHash
|
|
1075
|
+
) {
|
|
1076
|
+
proof = existing.proof;
|
|
1077
|
+
} else {
|
|
1078
|
+
const revision = (existing?.proof.revision ?? 0) + 1;
|
|
1079
|
+
const payload = {
|
|
1080
|
+
gate: input.gate,
|
|
1081
|
+
revision,
|
|
1082
|
+
contractHash: approvedContractHash,
|
|
1083
|
+
};
|
|
1084
|
+
const fact = this.#appendFact(
|
|
1085
|
+
input.runId,
|
|
1086
|
+
"approval",
|
|
1087
|
+
`${input.gate}:${revision}`,
|
|
1088
|
+
payload,
|
|
1089
|
+
);
|
|
1090
|
+
proof = {
|
|
1091
|
+
revision,
|
|
1092
|
+
contractHash: approvedContractHash,
|
|
1093
|
+
recordHash: fact.record_hash,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
const outcome = {
|
|
1097
|
+
operation: "approve-gate" as const,
|
|
1098
|
+
runId: input.runId,
|
|
1099
|
+
gate: input.gate,
|
|
1100
|
+
proof,
|
|
1101
|
+
};
|
|
1102
|
+
this.#recordOperation(
|
|
1103
|
+
input.runId,
|
|
1104
|
+
input.operationId,
|
|
1105
|
+
"approve-gate",
|
|
1106
|
+
request,
|
|
1107
|
+
outcome,
|
|
1108
|
+
);
|
|
1109
|
+
return structuredClone(outcome);
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
#latestApproval(
|
|
1114
|
+
runId: string,
|
|
1115
|
+
gate: DesignGate,
|
|
1116
|
+
): { sequence: number; proof: DesignGateProof } | undefined {
|
|
1117
|
+
const rows = this.#facts(runId).filter((row) => row.kind === "approval");
|
|
1118
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
1119
|
+
const row = rows[index];
|
|
1120
|
+
const payload = parseRecord(
|
|
1121
|
+
row.payload_json,
|
|
1122
|
+
"design-approval-record-invalid",
|
|
1123
|
+
);
|
|
1124
|
+
if (payload.gate === gate) {
|
|
1125
|
+
return {
|
|
1126
|
+
sequence: row.sequence,
|
|
1127
|
+
proof: {
|
|
1128
|
+
revision: Number(payload.revision),
|
|
1129
|
+
contractHash: String(payload.contractHash),
|
|
1130
|
+
recordHash: row.record_hash,
|
|
1131
|
+
},
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return undefined;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
recordCompiledPlan(input: {
|
|
1139
|
+
runId: string;
|
|
1140
|
+
operationId: string;
|
|
1141
|
+
bytes: Uint8Array;
|
|
1142
|
+
rawSha256: string;
|
|
1143
|
+
canonicalHash: string;
|
|
1144
|
+
lease?: DesignFinalizationLease;
|
|
1145
|
+
}): Record<string, unknown> {
|
|
1146
|
+
requireHash(input.rawSha256, "design-plan-raw-hash");
|
|
1147
|
+
requireHash(input.canonicalHash, "design-plan-canonical-hash");
|
|
1148
|
+
if (
|
|
1149
|
+
!(input.bytes instanceof Uint8Array) ||
|
|
1150
|
+
input.bytes.byteLength < 1 ||
|
|
1151
|
+
input.bytes.byteLength > MAX_PLAN_BYTES ||
|
|
1152
|
+
sha256(input.bytes) !== input.rawSha256
|
|
1153
|
+
) {
|
|
1154
|
+
throw new Error("design-plan-bytes-invalid");
|
|
1155
|
+
}
|
|
1156
|
+
const request = {
|
|
1157
|
+
operation: "compile-plan",
|
|
1158
|
+
rawSha256: input.rawSha256,
|
|
1159
|
+
canonicalHash: input.canonicalHash,
|
|
1160
|
+
};
|
|
1161
|
+
return this.#transaction(() => {
|
|
1162
|
+
this.#run(input.runId, true);
|
|
1163
|
+
const replay = this.#operationReplay(
|
|
1164
|
+
input.runId,
|
|
1165
|
+
input.operationId,
|
|
1166
|
+
"compile-plan",
|
|
1167
|
+
request,
|
|
1168
|
+
);
|
|
1169
|
+
if (replay) return structuredClone(replay);
|
|
1170
|
+
if (input.lease) {
|
|
1171
|
+
if (
|
|
1172
|
+
input.lease.runId !== input.runId ||
|
|
1173
|
+
input.lease.operationId !== input.operationId
|
|
1174
|
+
) {
|
|
1175
|
+
throw new Error("design-finalization-lease-fenced");
|
|
1176
|
+
}
|
|
1177
|
+
this.#assertFinalizationLease(input.lease);
|
|
1178
|
+
}
|
|
1179
|
+
if (!this.#gateStatus(input.runId).gateA.current) {
|
|
1180
|
+
throw new Error("design-gate-a-required");
|
|
1181
|
+
}
|
|
1182
|
+
const prior = this.#currentPlanProjection(input.runId);
|
|
1183
|
+
const revision = (prior?.revision ?? 0) + 1;
|
|
1184
|
+
const payload = {
|
|
1185
|
+
revision,
|
|
1186
|
+
rawSha256: input.rawSha256,
|
|
1187
|
+
canonicalHash: input.canonicalHash,
|
|
1188
|
+
};
|
|
1189
|
+
const fact = this.#appendFact(
|
|
1190
|
+
input.runId,
|
|
1191
|
+
"plan",
|
|
1192
|
+
String(revision),
|
|
1193
|
+
payload,
|
|
1194
|
+
);
|
|
1195
|
+
this.#database
|
|
1196
|
+
.prepare(
|
|
1197
|
+
`INSERT INTO design_compiled_plans(
|
|
1198
|
+
run_id, revision, fact_sequence, raw_sha256, canonical_hash,
|
|
1199
|
+
plan_bytes
|
|
1200
|
+
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1201
|
+
)
|
|
1202
|
+
.run(
|
|
1203
|
+
input.runId,
|
|
1204
|
+
revision,
|
|
1205
|
+
fact.sequence,
|
|
1206
|
+
input.rawSha256,
|
|
1207
|
+
input.canonicalHash,
|
|
1208
|
+
Buffer.from(input.bytes),
|
|
1209
|
+
);
|
|
1210
|
+
const plan: DesignPlanProjection = {
|
|
1211
|
+
revision,
|
|
1212
|
+
rawSha256: input.rawSha256,
|
|
1213
|
+
canonicalHash: input.canonicalHash,
|
|
1214
|
+
recordHash: fact.record_hash,
|
|
1215
|
+
sequence: fact.sequence,
|
|
1216
|
+
};
|
|
1217
|
+
const outcome = {
|
|
1218
|
+
operation: "compile-plan",
|
|
1219
|
+
runId: input.runId,
|
|
1220
|
+
plan,
|
|
1221
|
+
gates: this.#gateStatus(input.runId),
|
|
1222
|
+
};
|
|
1223
|
+
this.#recordOperation(
|
|
1224
|
+
input.runId,
|
|
1225
|
+
input.operationId,
|
|
1226
|
+
"compile-plan",
|
|
1227
|
+
request,
|
|
1228
|
+
outcome,
|
|
1229
|
+
);
|
|
1230
|
+
return structuredClone(outcome);
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
#currentPlanProjection(runId: string): DesignPlanProjection | undefined {
|
|
1235
|
+
const row = this.#database
|
|
1236
|
+
.prepare(
|
|
1237
|
+
`SELECT plans.revision, plans.raw_sha256, plans.canonical_hash,
|
|
1238
|
+
facts.sequence, facts.record_hash
|
|
1239
|
+
FROM design_compiled_plans AS plans
|
|
1240
|
+
JOIN design_facts AS facts
|
|
1241
|
+
ON facts.run_id = plans.run_id
|
|
1242
|
+
AND facts.sequence = plans.fact_sequence
|
|
1243
|
+
WHERE plans.run_id = ? ORDER BY plans.revision DESC LIMIT 1`,
|
|
1244
|
+
)
|
|
1245
|
+
.get(runId) as
|
|
1246
|
+
| {
|
|
1247
|
+
revision: number;
|
|
1248
|
+
raw_sha256: string;
|
|
1249
|
+
canonical_hash: string;
|
|
1250
|
+
sequence: number;
|
|
1251
|
+
record_hash: string;
|
|
1252
|
+
}
|
|
1253
|
+
| undefined;
|
|
1254
|
+
return row
|
|
1255
|
+
? {
|
|
1256
|
+
revision: row.revision,
|
|
1257
|
+
rawSha256: row.raw_sha256,
|
|
1258
|
+
canonicalHash: row.canonical_hash,
|
|
1259
|
+
recordHash: row.record_hash,
|
|
1260
|
+
sequence: row.sequence,
|
|
1261
|
+
}
|
|
1262
|
+
: undefined;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
currentCompiledPlan(
|
|
1266
|
+
runId: string,
|
|
1267
|
+
): { projection: DesignPlanProjection; bytes: Uint8Array } | undefined {
|
|
1268
|
+
this.#assertOpen();
|
|
1269
|
+
this.#run(runId, false);
|
|
1270
|
+
const row = this.#database
|
|
1271
|
+
.prepare(
|
|
1272
|
+
`SELECT plan_bytes FROM design_compiled_plans
|
|
1273
|
+
WHERE run_id = ? ORDER BY revision DESC LIMIT 1`,
|
|
1274
|
+
)
|
|
1275
|
+
.get(runId) as { plan_bytes: Uint8Array } | undefined;
|
|
1276
|
+
const projection = this.#currentPlanProjection(runId);
|
|
1277
|
+
if (!row || !projection) return undefined;
|
|
1278
|
+
return { projection, bytes: Uint8Array.from(row.plan_bytes) };
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
verifyGateProof(input: GateProofVerificationInput): boolean {
|
|
1282
|
+
this.#assertOpen();
|
|
1283
|
+
if (
|
|
1284
|
+
typeof input.change !== "string" ||
|
|
1285
|
+
input.change.length === 0 ||
|
|
1286
|
+
(input.gate !== "gate-a" && input.gate !== "gate-b") ||
|
|
1287
|
+
!Number.isSafeInteger(input.proof.revision) ||
|
|
1288
|
+
input.proof.revision < 1 ||
|
|
1289
|
+
!SHA256.test(input.proof.contractHash) ||
|
|
1290
|
+
!SHA256.test(input.proof.recordHash)
|
|
1291
|
+
) {
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
const row = this.#database
|
|
1295
|
+
.prepare(
|
|
1296
|
+
`SELECT facts.run_id, facts.payload_json
|
|
1297
|
+
FROM design_facts AS facts
|
|
1298
|
+
JOIN runs ON runs.run_id = facts.run_id
|
|
1299
|
+
WHERE runs.root_hash = ? AND runs.stage = 'abel-design'
|
|
1300
|
+
AND runs.change_name = ? AND facts.kind = 'approval'
|
|
1301
|
+
AND facts.record_hash = ?`,
|
|
1302
|
+
)
|
|
1303
|
+
.get(
|
|
1304
|
+
this.stateRoot.consumerRootHash,
|
|
1305
|
+
input.change,
|
|
1306
|
+
input.proof.recordHash,
|
|
1307
|
+
) as { run_id: string; payload_json: string } | undefined;
|
|
1308
|
+
if (!row) return false;
|
|
1309
|
+
const payload = parseRecord(
|
|
1310
|
+
row.payload_json,
|
|
1311
|
+
"design-approval-record-invalid",
|
|
1312
|
+
);
|
|
1313
|
+
if (
|
|
1314
|
+
payload.gate !== input.gate ||
|
|
1315
|
+
payload.revision !== input.proof.revision ||
|
|
1316
|
+
payload.contractHash !== input.proof.contractHash
|
|
1317
|
+
) {
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
const status = this.#gateStatus(row.run_id)[
|
|
1321
|
+
input.gate === "gate-a" ? "gateA" : "gateB"
|
|
1322
|
+
];
|
|
1323
|
+
return (
|
|
1324
|
+
status.current &&
|
|
1325
|
+
status.proof?.recordHash === input.proof.recordHash &&
|
|
1326
|
+
status.proof.revision === input.proof.revision &&
|
|
1327
|
+
status.proof.contractHash === input.proof.contractHash
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
verifyFinalizedDelivery(input: FinalizedDeliveryVerificationInput): boolean {
|
|
1332
|
+
this.#assertOpen();
|
|
1333
|
+
if (
|
|
1334
|
+
typeof input.change !== "string" ||
|
|
1335
|
+
input.change.length === 0 ||
|
|
1336
|
+
!Number.isSafeInteger(input.deliveryRevision) ||
|
|
1337
|
+
input.deliveryRevision < 1 ||
|
|
1338
|
+
!SHA256.test(input.receiptHash) ||
|
|
1339
|
+
!SHA256.test(input.planCanonicalHash)
|
|
1340
|
+
) {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
const rows = this.#database
|
|
1344
|
+
.prepare(
|
|
1345
|
+
`SELECT facts.payload_json
|
|
1346
|
+
FROM design_facts AS facts
|
|
1347
|
+
JOIN runs ON runs.run_id = facts.run_id
|
|
1348
|
+
WHERE runs.root_hash = ? AND runs.stage = 'abel-design'
|
|
1349
|
+
AND runs.change_name = ? AND facts.kind = 'finalization'`,
|
|
1350
|
+
)
|
|
1351
|
+
.all(this.stateRoot.consumerRootHash, input.change) as unknown as Array<{
|
|
1352
|
+
payload_json: string;
|
|
1353
|
+
}>;
|
|
1354
|
+
return rows.some((row) => {
|
|
1355
|
+
const payload = parseRecord(
|
|
1356
|
+
row.payload_json,
|
|
1357
|
+
"design-finalization-record-invalid",
|
|
1358
|
+
);
|
|
1359
|
+
return (
|
|
1360
|
+
payload.deliveryRevision === input.deliveryRevision &&
|
|
1361
|
+
payload.receiptHash === input.receiptHash &&
|
|
1362
|
+
payload.planCanonicalHash === input.planCanonicalHash &&
|
|
1363
|
+
canonicalJson(payload.gateA) === canonicalJson(input.gateA) &&
|
|
1364
|
+
canonicalJson(payload.gateB) === canonicalJson(input.gateB)
|
|
1365
|
+
);
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
nextDeliveryRevision(runId: string): number {
|
|
1370
|
+
this.#assertOpen();
|
|
1371
|
+
const run = this.#run(runId, true);
|
|
1372
|
+
if (!run.change_name) throw new Error("design-change-required");
|
|
1373
|
+
const rows = this.#database
|
|
1374
|
+
.prepare(
|
|
1375
|
+
`SELECT facts.payload_json
|
|
1376
|
+
FROM design_facts AS facts
|
|
1377
|
+
JOIN runs ON runs.run_id = facts.run_id
|
|
1378
|
+
WHERE runs.root_hash = ? AND runs.stage = 'abel-design'
|
|
1379
|
+
AND runs.change_name = ? AND facts.kind = 'finalization'`,
|
|
1380
|
+
)
|
|
1381
|
+
.all(
|
|
1382
|
+
this.stateRoot.consumerRootHash,
|
|
1383
|
+
run.change_name,
|
|
1384
|
+
) as unknown as Array<{
|
|
1385
|
+
payload_json: string;
|
|
1386
|
+
}>;
|
|
1387
|
+
const maximum = rows.reduce((current, row) => {
|
|
1388
|
+
const payload = parseRecord(
|
|
1389
|
+
row.payload_json,
|
|
1390
|
+
"design-finalization-record-invalid",
|
|
1391
|
+
);
|
|
1392
|
+
const revision = Number(payload.deliveryRevision);
|
|
1393
|
+
if (!Number.isSafeInteger(revision) || revision < 1) {
|
|
1394
|
+
throw new Error("design-finalization-record-invalid");
|
|
1395
|
+
}
|
|
1396
|
+
return Math.max(current, revision);
|
|
1397
|
+
}, 0);
|
|
1398
|
+
return maximum + 1;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
recordFinalization(input: {
|
|
1402
|
+
runId: string;
|
|
1403
|
+
operationId: string;
|
|
1404
|
+
lease: DesignFinalizationLease;
|
|
1405
|
+
deliveryRevision: number;
|
|
1406
|
+
receiptHash: string;
|
|
1407
|
+
gateA: DesignGateProof;
|
|
1408
|
+
gateB: DesignGateProof;
|
|
1409
|
+
planCanonicalHash: string;
|
|
1410
|
+
}): Record<string, unknown> {
|
|
1411
|
+
if (
|
|
1412
|
+
input.lease.runId !== input.runId ||
|
|
1413
|
+
input.lease.operationId !== input.operationId
|
|
1414
|
+
) {
|
|
1415
|
+
throw new Error("design-finalization-lease-fenced");
|
|
1416
|
+
}
|
|
1417
|
+
if (
|
|
1418
|
+
!Number.isSafeInteger(input.deliveryRevision) ||
|
|
1419
|
+
input.deliveryRevision < 1
|
|
1420
|
+
) {
|
|
1421
|
+
throw new Error("design-delivery-revision-invalid");
|
|
1422
|
+
}
|
|
1423
|
+
for (const [label, value] of [
|
|
1424
|
+
["design-receipt-hash", input.receiptHash],
|
|
1425
|
+
["design-plan-canonical-hash", input.planCanonicalHash],
|
|
1426
|
+
["design-gate-a-contract-hash", input.gateA.contractHash],
|
|
1427
|
+
["design-gate-a-record-hash", input.gateA.recordHash],
|
|
1428
|
+
["design-gate-b-contract-hash", input.gateB.contractHash],
|
|
1429
|
+
["design-gate-b-record-hash", input.gateB.recordHash],
|
|
1430
|
+
] as const) {
|
|
1431
|
+
requireHash(value, label);
|
|
1432
|
+
}
|
|
1433
|
+
const request = {
|
|
1434
|
+
operation: "finalize-delivery",
|
|
1435
|
+
deliveryRevision: input.deliveryRevision,
|
|
1436
|
+
receiptHash: input.receiptHash,
|
|
1437
|
+
gateA: input.gateA,
|
|
1438
|
+
gateB: input.gateB,
|
|
1439
|
+
planCanonicalHash: input.planCanonicalHash,
|
|
1440
|
+
};
|
|
1441
|
+
return this.#transaction(() => {
|
|
1442
|
+
this.#run(input.runId, false);
|
|
1443
|
+
const replay = this.#operationReplay(
|
|
1444
|
+
input.runId,
|
|
1445
|
+
input.operationId,
|
|
1446
|
+
"finalize-delivery",
|
|
1447
|
+
request,
|
|
1448
|
+
);
|
|
1449
|
+
if (replay) return structuredClone(replay);
|
|
1450
|
+
this.#run(input.runId, true);
|
|
1451
|
+
this.#assertFinalizationLease(input.lease);
|
|
1452
|
+
const gates = this.#gateStatus(input.runId);
|
|
1453
|
+
const plan = this.#currentPlanProjection(input.runId);
|
|
1454
|
+
if (
|
|
1455
|
+
!gates.gateA.current ||
|
|
1456
|
+
!gates.gateB.current ||
|
|
1457
|
+
canonicalJson(gates.gateA.proof) !== canonicalJson(input.gateA) ||
|
|
1458
|
+
canonicalJson(gates.gateB.proof) !== canonicalJson(input.gateB) ||
|
|
1459
|
+
!plan ||
|
|
1460
|
+
plan.canonicalHash !== input.planCanonicalHash ||
|
|
1461
|
+
input.gateB.contractHash !== input.planCanonicalHash
|
|
1462
|
+
) {
|
|
1463
|
+
throw new Error("design-finalization-proof-stale");
|
|
1464
|
+
}
|
|
1465
|
+
const expectedRevision = this.nextDeliveryRevision(input.runId);
|
|
1466
|
+
if (input.deliveryRevision !== expectedRevision) {
|
|
1467
|
+
throw new Error("design-delivery-revision-conflict");
|
|
1468
|
+
}
|
|
1469
|
+
const payload = {
|
|
1470
|
+
deliveryRevision: input.deliveryRevision,
|
|
1471
|
+
receiptHash: input.receiptHash,
|
|
1472
|
+
gateA: input.gateA,
|
|
1473
|
+
gateB: input.gateB,
|
|
1474
|
+
planCanonicalHash: input.planCanonicalHash,
|
|
1475
|
+
};
|
|
1476
|
+
this.#appendFact(
|
|
1477
|
+
input.runId,
|
|
1478
|
+
"finalization",
|
|
1479
|
+
String(input.deliveryRevision),
|
|
1480
|
+
payload,
|
|
1481
|
+
);
|
|
1482
|
+
const outcome = {
|
|
1483
|
+
operation: "finalize-delivery",
|
|
1484
|
+
runId: input.runId,
|
|
1485
|
+
state: "completed",
|
|
1486
|
+
completed: true,
|
|
1487
|
+
deliveryRevision: input.deliveryRevision,
|
|
1488
|
+
receiptHash: input.receiptHash,
|
|
1489
|
+
};
|
|
1490
|
+
this.#recordOperation(
|
|
1491
|
+
input.runId,
|
|
1492
|
+
input.operationId,
|
|
1493
|
+
"finalize-delivery",
|
|
1494
|
+
request,
|
|
1495
|
+
outcome,
|
|
1496
|
+
);
|
|
1497
|
+
return structuredClone(outcome);
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
#gateStatus(runId: string): DesignStatusProjection["gates"] {
|
|
1502
|
+
const facts = this.#facts(runId);
|
|
1503
|
+
const latestBehavior = facts
|
|
1504
|
+
.filter((fact) => {
|
|
1505
|
+
if (fact.kind !== "decision") return false;
|
|
1506
|
+
return (
|
|
1507
|
+
parseRecord(fact.payload_json, "design-decision-record-invalid")
|
|
1508
|
+
.category === "behavior"
|
|
1509
|
+
);
|
|
1510
|
+
})
|
|
1511
|
+
.at(-1)?.sequence;
|
|
1512
|
+
const latestTechnical = facts
|
|
1513
|
+
.filter((fact) => {
|
|
1514
|
+
if (fact.kind !== "decision") return false;
|
|
1515
|
+
return (
|
|
1516
|
+
parseRecord(fact.payload_json, "design-decision-record-invalid")
|
|
1517
|
+
.category === "technical"
|
|
1518
|
+
);
|
|
1519
|
+
})
|
|
1520
|
+
.at(-1)?.sequence;
|
|
1521
|
+
const plan = this.#currentPlanProjection(runId);
|
|
1522
|
+
const gateAApproval = this.#latestApproval(runId, "gate-a");
|
|
1523
|
+
const gateBApproval = this.#latestApproval(runId, "gate-b");
|
|
1524
|
+
const gateAChanged =
|
|
1525
|
+
gateAApproval !== undefined &&
|
|
1526
|
+
latestBehavior !== undefined &&
|
|
1527
|
+
latestBehavior > gateAApproval.sequence;
|
|
1528
|
+
const gateA: DesignGateProjection = gateAApproval
|
|
1529
|
+
? {
|
|
1530
|
+
current: !gateAChanged,
|
|
1531
|
+
proof: gateAApproval.proof,
|
|
1532
|
+
approvedSequence: gateAApproval.sequence,
|
|
1533
|
+
...(gateAChanged
|
|
1534
|
+
? { staleReason: "behavior-decision-changed" as const }
|
|
1535
|
+
: {}),
|
|
1536
|
+
}
|
|
1537
|
+
: { current: false };
|
|
1538
|
+
|
|
1539
|
+
let staleReason: DesignGateProjection["staleReason"];
|
|
1540
|
+
if (gateBApproval) {
|
|
1541
|
+
if (
|
|
1542
|
+
latestBehavior !== undefined &&
|
|
1543
|
+
latestBehavior > gateBApproval.sequence
|
|
1544
|
+
) {
|
|
1545
|
+
staleReason = "behavior-decision-changed";
|
|
1546
|
+
} else if (
|
|
1547
|
+
latestTechnical !== undefined &&
|
|
1548
|
+
latestTechnical > gateBApproval.sequence
|
|
1549
|
+
) {
|
|
1550
|
+
staleReason = "technical-decision-changed";
|
|
1551
|
+
} else if (
|
|
1552
|
+
gateAApproval !== undefined &&
|
|
1553
|
+
gateAApproval.sequence > gateBApproval.sequence
|
|
1554
|
+
) {
|
|
1555
|
+
staleReason = "gate-a-reapproved";
|
|
1556
|
+
} else if (plan && plan.sequence > gateBApproval.sequence) {
|
|
1557
|
+
staleReason = "plan-recompiled";
|
|
1558
|
+
} else if (!plan) {
|
|
1559
|
+
staleReason = "plan-missing";
|
|
1560
|
+
} else if (!gateA.current) {
|
|
1561
|
+
staleReason = "gate-a-stale";
|
|
1562
|
+
} else if (plan.canonicalHash !== gateBApproval.proof.contractHash) {
|
|
1563
|
+
staleReason = "plan-recompiled";
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
const gateB: DesignGateProjection = gateBApproval
|
|
1567
|
+
? {
|
|
1568
|
+
current: staleReason === undefined,
|
|
1569
|
+
proof: gateBApproval.proof,
|
|
1570
|
+
approvedSequence: gateBApproval.sequence,
|
|
1571
|
+
...(staleReason ? { staleReason } : {}),
|
|
1572
|
+
}
|
|
1573
|
+
: { current: false };
|
|
1574
|
+
return { gateA, gateB };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
status(runId: string): DesignStatusProjection {
|
|
1578
|
+
this.#assertOpen();
|
|
1579
|
+
const run = this.#run(runId, false);
|
|
1580
|
+
const facts = this.#facts(runId);
|
|
1581
|
+
const evidence = facts
|
|
1582
|
+
.filter((fact) => fact.kind === "evidence")
|
|
1583
|
+
.map((fact) =>
|
|
1584
|
+
this.#evidenceProjection(
|
|
1585
|
+
fact,
|
|
1586
|
+
parseRecord(fact.payload_json, "design-evidence-record-invalid"),
|
|
1587
|
+
),
|
|
1588
|
+
);
|
|
1589
|
+
const latestDecisions = new Map<string, DesignDecisionProjection>();
|
|
1590
|
+
for (const fact of facts.filter(
|
|
1591
|
+
(candidate) => candidate.kind === "decision",
|
|
1592
|
+
)) {
|
|
1593
|
+
const decision = this.#decisionProjection(
|
|
1594
|
+
fact,
|
|
1595
|
+
parseRecord(fact.payload_json, "design-decision-record-invalid"),
|
|
1596
|
+
);
|
|
1597
|
+
latestDecisions.set(decision.decisionId, decision);
|
|
1598
|
+
}
|
|
1599
|
+
return {
|
|
1600
|
+
runId,
|
|
1601
|
+
...(run.change_name ? { change: run.change_name } : {}),
|
|
1602
|
+
evidence,
|
|
1603
|
+
decisions: [...latestDecisions.values()].sort((left, right) =>
|
|
1604
|
+
left.decisionId.localeCompare(right.decisionId),
|
|
1605
|
+
),
|
|
1606
|
+
gates: this.#gateStatus(runId),
|
|
1607
|
+
plan: this.#currentPlanProjection(runId) ?? null,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
close(): void {
|
|
1612
|
+
if (this.#closed) return;
|
|
1613
|
+
this.#database.close();
|
|
1614
|
+
this.#closed = true;
|
|
1615
|
+
}
|
|
1616
|
+
}
|