@abelxiaoxing/cadence 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,846 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+ import { isValidRelativePath } from "./contracts.ts";
12
+ import {
13
+ assessDeliveryTraceability,
14
+ compileGateAReceipt,
15
+ compileImplementPlan,
16
+ compileReadyReceipt,
17
+ type DeliveryArtifactBinding,
18
+ type GateApprovalProof,
19
+ parseGateAReceipt,
20
+ parseImplementPlan,
21
+ parseReadyReceipt,
22
+ } from "./delivery-compiler.ts";
23
+ import {
24
+ type DesignArtifactOperationFacts,
25
+ type DesignArtifactOperationOutcome,
26
+ DesignJournal,
27
+ type DesignStatusProjection,
28
+ } from "./design-journal.ts";
29
+ import { RunStore } from "./run-store.ts";
30
+ import { observeSafePath } from "./safe-path.ts";
31
+ import type { ResolvedStateRoot } from "./state-root.ts";
32
+
33
+ const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
34
+ const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
35
+ const SHA256 = /^[a-f0-9]{64}$/u;
36
+ const ARTIFACT_SEGMENT = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/iu;
37
+ const MAX_DESIGN_FILE_BYTES = 16 * 1024 * 1024;
38
+
39
+ export interface DesignOpenSpecInspection {
40
+ change: string;
41
+ schema: string;
42
+ planningComplete: boolean;
43
+ strictValid: boolean;
44
+ artifactPaths: string[];
45
+ }
46
+
47
+ export type DesignControlRequest =
48
+ | {
49
+ operation: "record-decision";
50
+ runId: string;
51
+ operationId: string;
52
+ decisionId: string;
53
+ category: "behavior" | "technical";
54
+ contractHash: string;
55
+ refs: string[];
56
+ }
57
+ | {
58
+ operation: "approve-gate";
59
+ runId: string;
60
+ operationId: string;
61
+ gate: "gate-a" | "gate-b";
62
+ contractHash: string;
63
+ }
64
+ | {
65
+ operation: "compile-plan";
66
+ runId: string;
67
+ operationId: string;
68
+ }
69
+ | {
70
+ operation: "finalize-delivery";
71
+ runId: string;
72
+ operationId: string;
73
+ }
74
+ | {
75
+ operation: "write-artifact";
76
+ runId: string;
77
+ operationId: string;
78
+ path: string;
79
+ content: string;
80
+ }
81
+ | {
82
+ operation: "delete-artifact";
83
+ runId: string;
84
+ operationId: string;
85
+ path: string;
86
+ };
87
+
88
+ export interface DesignControllerOptions {
89
+ consumerRoot: string;
90
+ stateRoot: ResolvedStateRoot;
91
+ now?: () => number;
92
+ finalizationLeaseMs?: number;
93
+ inspectOpenSpec: (
94
+ consumerRoot: string,
95
+ change: string,
96
+ ) => Promise<DesignOpenSpecInspection>;
97
+ }
98
+
99
+ export class DesignFinalizationError extends Error {
100
+ readonly diagnostics: readonly string[];
101
+
102
+ constructor(diagnostics: readonly string[]) {
103
+ const normalized = [...new Set(diagnostics)].sort();
104
+ super("design-finalization-invalid");
105
+ this.name = "DesignFinalizationError";
106
+ this.diagnostics = Object.freeze(normalized);
107
+ }
108
+ }
109
+
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return value !== null && typeof value === "object" && !Array.isArray(value);
112
+ }
113
+
114
+ function exactKeys(
115
+ value: Record<string, unknown>,
116
+ keys: readonly string[],
117
+ ): boolean {
118
+ const actual = Object.keys(value);
119
+ return (
120
+ actual.length === keys.length && actual.every((key) => keys.includes(key))
121
+ );
122
+ }
123
+
124
+ export function validateDesignControlRequest(
125
+ value: unknown,
126
+ ):
127
+ | { ok: true; value: DesignControlRequest }
128
+ | { ok: false; code: "invalid-design-control-request" } {
129
+ if (!isRecord(value))
130
+ return { ok: false, code: "invalid-design-control-request" };
131
+ const commonValid =
132
+ typeof value.runId === "string" &&
133
+ IDENTIFIER.test(value.runId) &&
134
+ typeof value.operationId === "string" &&
135
+ IDENTIFIER.test(value.operationId);
136
+ if (!commonValid)
137
+ return { ok: false, code: "invalid-design-control-request" };
138
+ if (value.operation === "write-artifact") {
139
+ return exactKeys(value, [
140
+ "operation",
141
+ "runId",
142
+ "operationId",
143
+ "path",
144
+ "content",
145
+ ]) &&
146
+ typeof value.path === "string" &&
147
+ typeof value.content === "string"
148
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
149
+ : { ok: false, code: "invalid-design-control-request" };
150
+ }
151
+ if (value.operation === "delete-artifact") {
152
+ return exactKeys(value, ["operation", "runId", "operationId", "path"]) &&
153
+ typeof value.path === "string"
154
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
155
+ : { ok: false, code: "invalid-design-control-request" };
156
+ }
157
+ if (
158
+ value.operation === "compile-plan" ||
159
+ value.operation === "finalize-delivery"
160
+ ) {
161
+ return exactKeys(value, ["operation", "runId", "operationId"])
162
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
163
+ : { ok: false, code: "invalid-design-control-request" };
164
+ }
165
+ if (value.operation === "approve-gate") {
166
+ return exactKeys(value, [
167
+ "operation",
168
+ "runId",
169
+ "operationId",
170
+ "gate",
171
+ "contractHash",
172
+ ]) &&
173
+ (value.gate === "gate-a" || value.gate === "gate-b") &&
174
+ typeof value.contractHash === "string" &&
175
+ SHA256.test(value.contractHash)
176
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
177
+ : { ok: false, code: "invalid-design-control-request" };
178
+ }
179
+ if (value.operation !== "record-decision") {
180
+ return { ok: false, code: "invalid-design-control-request" };
181
+ }
182
+ return exactKeys(value, [
183
+ "operation",
184
+ "runId",
185
+ "operationId",
186
+ "decisionId",
187
+ "category",
188
+ "contractHash",
189
+ "refs",
190
+ ]) &&
191
+ typeof value.decisionId === "string" &&
192
+ IDENTIFIER.test(value.decisionId) &&
193
+ (value.category === "behavior" || value.category === "technical") &&
194
+ typeof value.contractHash === "string" &&
195
+ SHA256.test(value.contractHash) &&
196
+ Array.isArray(value.refs) &&
197
+ value.refs.every((ref) => typeof ref === "string")
198
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
199
+ : { ok: false, code: "invalid-design-control-request" };
200
+ }
201
+
202
+ function safeRelative(change: string, name: string): string {
203
+ if (!CHANGE_NAME.test(change)) throw new Error("design-change-invalid");
204
+ return `openspec/changes/${change}/${name}`;
205
+ }
206
+
207
+ function isAllowedDesignArtifactPath(relative: string): boolean {
208
+ if (!isValidRelativePath(relative)) return false;
209
+ if (
210
+ [
211
+ ".openspec.yaml",
212
+ "proposal.md",
213
+ "design.md",
214
+ "tasks.md",
215
+ "plan-draft.json",
216
+ ].includes(relative)
217
+ ) {
218
+ return true;
219
+ }
220
+ const segments = relative.split("/");
221
+ return (
222
+ segments.length >= 3 &&
223
+ segments[0] === "specs" &&
224
+ segments.at(-1) === "spec.md" &&
225
+ segments.slice(1, -1).every((segment) => ARTIFACT_SEGMENT.test(segment))
226
+ );
227
+ }
228
+
229
+ function designArtifactRelative(change: string, relative: string): string {
230
+ if (!isAllowedDesignArtifactPath(relative)) {
231
+ throw new Error("design-artifact-path-invalid");
232
+ }
233
+ return safeRelative(change, relative);
234
+ }
235
+
236
+ function ensureSafeDirectory(root: string, relative: string): void {
237
+ const segments = relative.split("/");
238
+ for (let index = 0; index < segments.length; index += 1) {
239
+ const current = segments.slice(0, index + 1).join("/");
240
+ const observation = observeSafePath(root, current);
241
+ if (observation.kind === "directory") continue;
242
+ if (observation.kind !== "absent") {
243
+ throw new Error("design-artifact-path-unsafe");
244
+ }
245
+ const parent = index === 0 ? "." : segments.slice(0, index).join("/");
246
+ if (observeSafePath(root, parent).kind !== "directory") {
247
+ throw new Error("design-artifact-path-unsafe");
248
+ }
249
+ try {
250
+ mkdirSync(path.join(root, ...current.split("/")), { mode: 0o755 });
251
+ } catch {
252
+ if (observeSafePath(root, current).kind !== "directory") {
253
+ throw new Error("design-artifact-path-unsafe");
254
+ }
255
+ }
256
+ if (observeSafePath(root, current).kind !== "directory") {
257
+ throw new Error("design-artifact-path-unsafe");
258
+ }
259
+ }
260
+ }
261
+
262
+ function boundedArtifactBytes(root: string, relative: string): Buffer {
263
+ if (observeSafePath(root, relative).kind !== "file") {
264
+ throw new Error("design-artifact-path-unsafe");
265
+ }
266
+ const absolute = path.join(root, ...relative.split("/"));
267
+ const size = lstatSync(absolute).size;
268
+ if (size > MAX_DESIGN_FILE_BYTES) {
269
+ throw new Error("design-artifact-content-too-large");
270
+ }
271
+ return readFileSync(absolute);
272
+ }
273
+
274
+ function readSafeFile(
275
+ root: string,
276
+ relative: string,
277
+ maximum = MAX_DESIGN_FILE_BYTES,
278
+ ): Buffer {
279
+ if (observeSafePath(root, relative).kind !== "file") {
280
+ throw new Error("design-file-unavailable");
281
+ }
282
+ const absolute = path.join(root, ...relative.split("/"));
283
+ const size = lstatSync(absolute).size;
284
+ if (size < 1 || size > maximum) throw new Error("design-file-size-invalid");
285
+ return readFileSync(absolute);
286
+ }
287
+
288
+ function removeSafeFile(root: string, relative: string): void {
289
+ const observation = observeSafePath(root, relative);
290
+ if (observation.kind === "absent") return;
291
+ if (observation.kind !== "file") throw new Error("design-file-unsafe");
292
+ unlinkSync(path.join(root, ...relative.split("/")));
293
+ }
294
+
295
+ function removeSafeFileWithHash(
296
+ root: string,
297
+ relative: string,
298
+ expectedHash: string,
299
+ ): void {
300
+ const observation = observeSafePath(root, relative);
301
+ if (observation.kind === "absent") return;
302
+ if (observation.kind !== "file") throw new Error("design-file-unsafe");
303
+ const bytes = readSafeFile(root, relative, 4 * 1024 * 1024);
304
+ if (createHash("sha256").update(bytes).digest("hex") !== expectedHash) return;
305
+ unlinkSync(path.join(root, ...relative.split("/")));
306
+ }
307
+
308
+ function atomicWrite(root: string, relative: string, bytes: Uint8Array): void {
309
+ const parent = path.posix.dirname(relative);
310
+ if (observeSafePath(root, parent).kind !== "directory") {
311
+ throw new Error("design-file-parent-unsafe");
312
+ }
313
+ const targetObservation = observeSafePath(root, relative);
314
+ if (
315
+ targetObservation.kind !== "file" &&
316
+ targetObservation.kind !== "absent"
317
+ ) {
318
+ throw new Error("design-file-unsafe");
319
+ }
320
+ const temporary = `${parent}/.abel-${path.posix.basename(relative)}-${randomUUID()}.tmp`;
321
+ if (observeSafePath(root, temporary).kind !== "absent") {
322
+ throw new Error("design-temporary-file-conflict");
323
+ }
324
+ const temporaryAbsolute = path.join(root, ...temporary.split("/"));
325
+ try {
326
+ writeFileSync(temporaryAbsolute, bytes, { flag: "wx", mode: 0o600 });
327
+ renameSync(temporaryAbsolute, path.join(root, ...relative.split("/")));
328
+ } catch (error) {
329
+ try {
330
+ if (observeSafePath(root, temporary).kind === "file") {
331
+ unlinkSync(temporaryAbsolute);
332
+ }
333
+ } catch {
334
+ // The original safe failure remains authoritative.
335
+ }
336
+ throw error;
337
+ }
338
+ }
339
+
340
+ function proof(status: DesignStatusProjection, gate: "gateA" | "gateB") {
341
+ const projection = status.gates[gate];
342
+ if (!projection.current || !projection.proof) {
343
+ throw new DesignFinalizationError([
344
+ gate === "gateA" ? "design-gate-a-stale" : "design-gate-b-stale",
345
+ ]);
346
+ }
347
+ return structuredClone(projection.proof) as GateApprovalProof;
348
+ }
349
+
350
+ export class DesignController {
351
+ readonly consumerRoot: string;
352
+ readonly #journal: DesignJournal;
353
+ readonly #runs: RunStore;
354
+ readonly #inspectOpenSpec: DesignControllerOptions["inspectOpenSpec"];
355
+ #closed = false;
356
+
357
+ private constructor(options: DesignControllerOptions) {
358
+ this.consumerRoot = path.resolve(options.consumerRoot);
359
+ this.#runs = RunStore.open(options.stateRoot);
360
+ this.#journal = DesignJournal.open(options.stateRoot, {
361
+ ...(options.now ? { now: options.now } : {}),
362
+ ...(options.finalizationLeaseMs !== undefined
363
+ ? { finalizationLeaseMs: options.finalizationLeaseMs }
364
+ : {}),
365
+ });
366
+ this.#inspectOpenSpec = options.inspectOpenSpec;
367
+ }
368
+
369
+ static open(options: DesignControllerOptions): DesignController {
370
+ return new DesignController(options);
371
+ }
372
+
373
+ #assertOpen(): void {
374
+ if (this.#closed) throw new Error("design-controller-closed");
375
+ }
376
+
377
+ status(runId: string): DesignStatusProjection {
378
+ this.#assertOpen();
379
+ return this.#journal.status(runId);
380
+ }
381
+
382
+ assertDesignRun(runId: string): void {
383
+ this.#assertOpen();
384
+ this.#journal.assertDesignRun(runId);
385
+ }
386
+
387
+ recordEvidence(input: Parameters<DesignJournal["recordEvidence"]>[0]) {
388
+ this.#assertOpen();
389
+ return this.#journal.recordEvidence(input);
390
+ }
391
+
392
+ verifyGateProof(input: Parameters<DesignJournal["verifyGateProof"]>[0]) {
393
+ this.#assertOpen();
394
+ return this.#journal.verifyGateProof(input);
395
+ }
396
+
397
+ verifyFinalizedDelivery(
398
+ input: Parameters<DesignJournal["verifyFinalizedDelivery"]>[0],
399
+ ) {
400
+ this.#assertOpen();
401
+ return this.#journal.verifyFinalizedDelivery(input);
402
+ }
403
+
404
+ async execute(value: unknown): Promise<Record<string, unknown>> {
405
+ this.#assertOpen();
406
+ const validation = validateDesignControlRequest(value);
407
+ if (!validation.ok) throw new Error(validation.code);
408
+ const request = validation.value;
409
+ switch (request.operation) {
410
+ case "record-decision":
411
+ return this.#journal.recordDecision(request);
412
+ case "approve-gate":
413
+ return this.#journal.approveGate(request);
414
+ case "compile-plan":
415
+ return this.#compilePlan(request);
416
+ case "finalize-delivery":
417
+ return this.#finalizeDelivery(request);
418
+ case "write-artifact":
419
+ return this.#writeArtifact(request);
420
+ case "delete-artifact":
421
+ return this.#deleteArtifact(request);
422
+ }
423
+ }
424
+
425
+ #artifactRun(runId: string): { change: string; gateACurrent: boolean } {
426
+ const status = this.#journal.status(runId);
427
+ if (!status.change) throw new Error("design-change-required");
428
+ return {
429
+ change: status.change,
430
+ gateACurrent: status.gates.gateA.current,
431
+ };
432
+ }
433
+
434
+ #writeArtifact(
435
+ request: Extract<DesignControlRequest, { operation: "write-artifact" }>,
436
+ ): DesignArtifactOperationOutcome {
437
+ const artifactRun = this.#artifactRun(request.runId);
438
+ const change = artifactRun.change;
439
+ const relative = designArtifactRelative(change, request.path);
440
+ const bytes = Buffer.from(request.content, "utf8");
441
+ if (bytes.toString("utf8") !== request.content) {
442
+ throw new Error("design-artifact-content-invalid");
443
+ }
444
+ if (bytes.length > MAX_DESIGN_FILE_BYTES) {
445
+ throw new Error("design-artifact-content-too-large");
446
+ }
447
+ const facts: DesignArtifactOperationFacts = {
448
+ runId: request.runId,
449
+ operationId: request.operationId,
450
+ operation: request.operation,
451
+ path: request.path,
452
+ bytes: bytes.length,
453
+ rawSha256: createHash("sha256").update(bytes).digest("hex"),
454
+ };
455
+ const replay = this.#journal.artifactOperationState(facts);
456
+ if (replay?.state === "committed") return replay.outcome;
457
+ if (!replay && !artifactRun.gateACurrent) {
458
+ throw new Error("design-gate-a-required");
459
+ }
460
+ const lease = this.#journal.acquireFinalizationLease({
461
+ runId: request.runId,
462
+ operationId: request.operationId,
463
+ });
464
+ try {
465
+ const outcome: DesignArtifactOperationOutcome = {
466
+ operation: request.operation,
467
+ runId: request.runId,
468
+ path: request.path,
469
+ bytes: facts.bytes,
470
+ rawSha256: facts.rawSha256,
471
+ };
472
+ ensureSafeDirectory(this.consumerRoot, path.posix.dirname(relative));
473
+ const target = observeSafePath(this.consumerRoot, relative);
474
+ if (target.kind !== "file" && target.kind !== "absent") {
475
+ throw new Error("design-artifact-path-unsafe");
476
+ }
477
+ const prepared = this.#journal.prepareArtifactOperation(
478
+ facts,
479
+ lease,
480
+ outcome,
481
+ );
482
+ if (prepared.state === "committed") return prepared.outcome;
483
+ atomicWrite(this.consumerRoot, relative, bytes);
484
+ return this.#journal.commitArtifactOperation(facts, lease);
485
+ } finally {
486
+ this.#journal.releaseFinalizationLease(lease);
487
+ }
488
+ }
489
+
490
+ #deleteArtifact(
491
+ request: Extract<DesignControlRequest, { operation: "delete-artifact" }>,
492
+ ): DesignArtifactOperationOutcome {
493
+ const artifactRun = this.#artifactRun(request.runId);
494
+ const change = artifactRun.change;
495
+ const relative = designArtifactRelative(change, request.path);
496
+ const facts: DesignArtifactOperationFacts = {
497
+ runId: request.runId,
498
+ operationId: request.operationId,
499
+ operation: request.operation,
500
+ path: request.path,
501
+ };
502
+ const replay = this.#journal.artifactOperationState(facts);
503
+ if (replay?.state === "committed") return replay.outcome;
504
+ if (!replay && !artifactRun.gateACurrent) {
505
+ throw new Error("design-gate-a-required");
506
+ }
507
+ const lease = this.#journal.acquireFinalizationLease({
508
+ runId: request.runId,
509
+ operationId: request.operationId,
510
+ });
511
+ try {
512
+ let outcome = replay?.outcome;
513
+ if (!outcome) {
514
+ const observation = observeSafePath(this.consumerRoot, relative);
515
+ if (observation.kind === "absent") {
516
+ outcome = {
517
+ operation: request.operation,
518
+ runId: request.runId,
519
+ path: request.path,
520
+ deleted: false,
521
+ };
522
+ } else if (observation.kind === "file") {
523
+ const bytes = boundedArtifactBytes(this.consumerRoot, relative);
524
+ outcome = {
525
+ operation: request.operation,
526
+ runId: request.runId,
527
+ path: request.path,
528
+ deleted: true,
529
+ bytes: bytes.length,
530
+ rawSha256: createHash("sha256").update(bytes).digest("hex"),
531
+ };
532
+ } else {
533
+ throw new Error("design-artifact-path-unsafe");
534
+ }
535
+ }
536
+ const prepared = this.#journal.prepareArtifactOperation(
537
+ facts,
538
+ lease,
539
+ outcome,
540
+ );
541
+ if (prepared.state === "committed") return prepared.outcome;
542
+ const intended = prepared.outcome;
543
+ if (intended.operation !== "delete-artifact") {
544
+ throw new Error("design-artifact-operation-outcome-invalid");
545
+ }
546
+ if (intended.deleted) {
547
+ const observation = observeSafePath(this.consumerRoot, relative);
548
+ if (observation.kind === "file") {
549
+ const current = boundedArtifactBytes(this.consumerRoot, relative);
550
+ if (
551
+ current.length !== intended.bytes ||
552
+ createHash("sha256").update(current).digest("hex") !==
553
+ intended.rawSha256
554
+ ) {
555
+ throw new Error("design-artifact-currentness-invalid");
556
+ }
557
+ unlinkSync(path.join(this.consumerRoot, ...relative.split("/")));
558
+ } else if (observation.kind !== "absent") {
559
+ throw new Error("design-artifact-path-unsafe");
560
+ }
561
+ }
562
+ return this.#journal.commitArtifactOperation(facts, lease);
563
+ } finally {
564
+ this.#journal.releaseFinalizationLease(lease);
565
+ }
566
+ }
567
+
568
+ #compilePlan(
569
+ request: Extract<DesignControlRequest, { operation: "compile-plan" }>,
570
+ ) {
571
+ const replay = this.#journal.operationOutcome(
572
+ request.runId,
573
+ request.operationId,
574
+ request.operation,
575
+ );
576
+ if (replay) return replay;
577
+ const lease = this.#journal.acquireFinalizationLease({
578
+ runId: request.runId,
579
+ operationId: request.operationId,
580
+ });
581
+ try {
582
+ this.#journal.assertNoPendingArtifactOperations(request.runId);
583
+ const status = this.#journal.status(request.runId);
584
+ if (!status.change) throw new Error("design-change-required");
585
+ if (!status.gates.gateA.current)
586
+ throw new Error("design-gate-a-required");
587
+ const readyPath = safeRelative(status.change, "ready.yaml");
588
+ const gateAPath = safeRelative(status.change, "gate-a.yaml");
589
+ removeSafeFile(this.consumerRoot, readyPath);
590
+ removeSafeFile(this.consumerRoot, gateAPath);
591
+ const draftPath = safeRelative(status.change, "plan-draft.json");
592
+ const draftBytes = readSafeFile(this.consumerRoot, draftPath);
593
+ let draft: unknown;
594
+ try {
595
+ draft = JSON.parse(draftBytes.toString("utf8"));
596
+ } catch {
597
+ throw new Error("design-plan-draft-invalid");
598
+ }
599
+ const compiled = compileImplementPlan(draft as never, {
600
+ consumerRoot: this.consumerRoot,
601
+ });
602
+ if (compiled.plan.changeId !== status.change) {
603
+ throw new Error("design-plan-change-mismatch");
604
+ }
605
+ this.#journal.assertFinalizationLease(lease);
606
+ atomicWrite(
607
+ this.consumerRoot,
608
+ safeRelative(status.change, "implement-plan.json"),
609
+ compiled.bytes,
610
+ );
611
+ return this.#journal.recordCompiledPlan({
612
+ runId: request.runId,
613
+ operationId: request.operationId,
614
+ bytes: compiled.bytes,
615
+ rawSha256: compiled.rawSha256,
616
+ canonicalHash: compiled.planHash,
617
+ lease,
618
+ });
619
+ } finally {
620
+ this.#journal.releaseFinalizationLease(lease);
621
+ }
622
+ }
623
+
624
+ async #finalizeDelivery(
625
+ request: Extract<DesignControlRequest, { operation: "finalize-delivery" }>,
626
+ ) {
627
+ const replay = this.#journal.operationOutcome(
628
+ request.runId,
629
+ request.operationId,
630
+ request.operation,
631
+ );
632
+ if (replay) {
633
+ this.#completeFinalizedRun(request.runId, request.operationId);
634
+ return replay;
635
+ }
636
+ const committed = this.#journal.latestFinalizationOutcome(request.runId);
637
+ if (committed) {
638
+ this.#completeFinalizedRun(request.runId, committed.operationId);
639
+ return committed.outcome;
640
+ }
641
+ const status = this.#journal.status(request.runId);
642
+ if (!status.change) throw new Error("design-change-required");
643
+ const gateA = proof(status, "gateA");
644
+ const gateB = proof(status, "gateB");
645
+ const storedPlan = this.#journal.currentCompiledPlan(request.runId);
646
+ if (!storedPlan) throw new DesignFinalizationError(["design-plan-missing"]);
647
+ const change = status.change;
648
+ const readyPath = safeRelative(change, "ready.yaml");
649
+ const gateAPath = safeRelative(change, "gate-a.yaml");
650
+ const lease = this.#journal.acquireFinalizationLease({
651
+ runId: request.runId,
652
+ operationId: request.operationId,
653
+ });
654
+ let readyInstalled = false;
655
+ let readyHash: string | undefined;
656
+ let finalizationCommitted = false;
657
+ try {
658
+ this.#journal.assertNoPendingArtifactOperations(request.runId);
659
+ removeSafeFile(this.consumerRoot, readyPath);
660
+ const diagnostics = new Set<string>();
661
+ let inspection: DesignOpenSpecInspection | undefined;
662
+ try {
663
+ inspection = await this.#inspectOpenSpec(this.consumerRoot, change);
664
+ } catch {
665
+ diagnostics.add("design-openspec-unavailable");
666
+ }
667
+ if (inspection) {
668
+ if (inspection.change !== change)
669
+ diagnostics.add("design-change-mismatch");
670
+ if (!inspection.strictValid)
671
+ diagnostics.add("design-openspec-strict-invalid");
672
+ if (!inspection.planningComplete)
673
+ diagnostics.add("design-openspec-incomplete");
674
+ if (!inspection.schema)
675
+ diagnostics.add("design-openspec-schema-invalid");
676
+ }
677
+ const installedPlanBytes = readSafeFile(
678
+ this.consumerRoot,
679
+ safeRelative(change, "implement-plan.json"),
680
+ );
681
+ if (
682
+ !Buffer.from(installedPlanBytes).equals(Buffer.from(storedPlan.bytes))
683
+ ) {
684
+ diagnostics.add("design-plan-binding-invalid");
685
+ }
686
+ let compiled: ReturnType<typeof compileImplementPlan> | undefined;
687
+ try {
688
+ const plan = parseImplementPlan(installedPlanBytes);
689
+ compiled = compileImplementPlan(plan, {
690
+ consumerRoot: this.consumerRoot,
691
+ });
692
+ if (
693
+ compiled.planHash !== storedPlan.projection.canonicalHash ||
694
+ compiled.rawSha256 !== storedPlan.projection.rawSha256
695
+ ) {
696
+ diagnostics.add("design-plan-binding-invalid");
697
+ }
698
+ } catch {
699
+ diagnostics.add("design-plan-invalid");
700
+ }
701
+ const artifacts: DeliveryArtifactBinding[] = [];
702
+ const artifactBytes = new Map<string, Buffer>();
703
+ for (const relative of inspection?.artifactPaths ?? []) {
704
+ if (
705
+ [
706
+ "gate-a.yaml",
707
+ "ready.yaml",
708
+ "implement-plan.json",
709
+ "plan-draft.json",
710
+ ].includes(relative)
711
+ ) {
712
+ diagnostics.add(`design-artifact-reserved:${relative}`);
713
+ continue;
714
+ }
715
+ try {
716
+ const bytes = readSafeFile(
717
+ this.consumerRoot,
718
+ safeRelative(change, relative),
719
+ );
720
+ artifactBytes.set(relative, bytes);
721
+ artifacts.push({
722
+ path: relative,
723
+ rawSha256: createHash("sha256").update(bytes).digest("hex"),
724
+ });
725
+ } catch {
726
+ diagnostics.add(`design-artifact-unavailable:${relative}`);
727
+ }
728
+ }
729
+ let traceability:
730
+ | ReturnType<typeof assessDeliveryTraceability>
731
+ | undefined;
732
+ if (compiled) {
733
+ const tasks = artifactBytes.get("tasks.md");
734
+ const specs = [...artifactBytes]
735
+ .filter(
736
+ ([relative]) =>
737
+ relative.startsWith("specs/") && relative.endsWith("/spec.md"),
738
+ )
739
+ .map(([relative, bytes]) => ({
740
+ path: relative,
741
+ text: bytes.toString("utf8"),
742
+ }));
743
+ if (!tasks || specs.length === 0) {
744
+ diagnostics.add("design-traceability-input-unavailable");
745
+ } else {
746
+ traceability = assessDeliveryTraceability({
747
+ tasksMarkdown: tasks.toString("utf8"),
748
+ specs,
749
+ plan: compiled.plan,
750
+ });
751
+ if (!traceability.ok) {
752
+ for (const diagnostic of traceability.diagnostics)
753
+ diagnostics.add(diagnostic);
754
+ }
755
+ }
756
+ }
757
+ if (
758
+ !inspection ||
759
+ !compiled ||
760
+ !traceability?.ok ||
761
+ diagnostics.size > 0
762
+ ) {
763
+ throw new DesignFinalizationError([...diagnostics]);
764
+ }
765
+ const gateAReceipt = compileGateAReceipt({
766
+ change,
767
+ schema: inspection.schema,
768
+ approval: gateA,
769
+ artifacts,
770
+ });
771
+ const deliveryRevision = this.#journal.nextDeliveryRevision(
772
+ request.runId,
773
+ );
774
+ const readyReceipt = compileReadyReceipt({
775
+ change,
776
+ schema: inspection.schema,
777
+ deliveryRevision,
778
+ gateA: { rawSha256: gateAReceipt.rawSha256 },
779
+ gateB,
780
+ artifacts,
781
+ compiledPlan: compiled,
782
+ traceability: traceability.value,
783
+ });
784
+ readyHash = readyReceipt.rawSha256;
785
+ this.#journal.assertFinalizationLease(lease);
786
+ atomicWrite(this.consumerRoot, gateAPath, gateAReceipt.bytes);
787
+ atomicWrite(this.consumerRoot, readyPath, readyReceipt.bytes);
788
+ readyInstalled = true;
789
+ const installedGateA = readSafeFile(this.consumerRoot, gateAPath);
790
+ const installedReady = readSafeFile(this.consumerRoot, readyPath);
791
+ parseGateAReceipt(installedGateA);
792
+ parseReadyReceipt(installedReady);
793
+ if (
794
+ !Buffer.from(installedGateA).equals(Buffer.from(gateAReceipt.bytes)) ||
795
+ !Buffer.from(installedReady).equals(Buffer.from(readyReceipt.bytes))
796
+ ) {
797
+ throw new Error("design-installed-delivery-mismatch");
798
+ }
799
+ removeSafeFile(
800
+ this.consumerRoot,
801
+ safeRelative(change, "plan-draft.json"),
802
+ );
803
+ const outcome = this.#journal.recordFinalization({
804
+ runId: request.runId,
805
+ operationId: request.operationId,
806
+ lease,
807
+ deliveryRevision,
808
+ receiptHash: readyReceipt.rawSha256,
809
+ gateA,
810
+ gateB,
811
+ planCanonicalHash: compiled.planHash,
812
+ });
813
+ finalizationCommitted = true;
814
+ this.#completeFinalizedRun(request.runId, request.operationId);
815
+ return outcome;
816
+ } catch (error) {
817
+ if (readyInstalled && readyHash && !finalizationCommitted) {
818
+ try {
819
+ removeSafeFileWithHash(this.consumerRoot, readyPath, readyHash);
820
+ } catch {
821
+ // The original finalization failure remains authoritative.
822
+ }
823
+ }
824
+ throw error;
825
+ } finally {
826
+ this.#journal.releaseFinalizationLease(lease);
827
+ }
828
+ }
829
+
830
+ #completeFinalizedRun(runId: string, operationId: string): void {
831
+ const currentRun = this.#runs.status(runId);
832
+ if (currentRun.state === "completed") return;
833
+ this.#runs.transition({
834
+ runId,
835
+ to: "completed",
836
+ operationId: `${operationId}:design-completed`,
837
+ });
838
+ }
839
+
840
+ close(): void {
841
+ if (this.#closed) return;
842
+ this.#journal.close();
843
+ this.#runs.close();
844
+ this.#closed = true;
845
+ }
846
+ }