@abelxiaoxing/cadence 1.1.1 → 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.
@@ -11,7 +11,7 @@ export const CONTROL_COMMANDS = [
11
11
  "discard",
12
12
  ] as const;
13
13
 
14
- export const CONTROL_STAGES = ["abel-design", "abel-implement"] as const;
14
+ export const CONTROL_STAGES = ["abel-implement"] as const;
15
15
 
16
16
  export type ControlCommandName = (typeof CONTROL_COMMANDS)[number];
17
17
  export type ControlStage = (typeof CONTROL_STAGES)[number];
@@ -23,52 +23,37 @@ const SHA256 = /^[a-f0-9]{64}$/u;
23
23
  interface ControlBase {
24
24
  command: ControlCommandName;
25
25
  stage: ControlStage;
26
- }
27
-
28
- interface NamedControlBase extends ControlBase {
29
26
  change: string;
30
27
  }
31
28
 
32
- export interface NamedStartControlCommand extends NamedControlBase {
29
+ export interface StartControlCommand extends ControlBase {
33
30
  command: "start";
34
31
  operationId: string;
35
- provisionalKey?: string;
36
32
  }
37
33
 
38
- export interface ProvisionalDesignStartControlCommand extends ControlBase {
39
- command: "start";
40
- stage: "abel-design";
41
- provisionalKey: string;
42
- operationId: string;
43
- }
44
-
45
- export type StartControlCommand =
46
- | NamedStartControlCommand
47
- | ProvisionalDesignStartControlCommand;
48
-
49
- export interface StatusControlCommand extends NamedControlBase {
34
+ export interface StatusControlCommand extends ControlBase {
50
35
  command: "status";
51
36
  }
52
37
 
53
- export interface ResumeControlCommand extends NamedControlBase {
38
+ export interface ResumeControlCommand extends ControlBase {
54
39
  command: "resume";
55
40
  operationId: string;
56
41
  deliveryRevision?: number;
57
42
  receiptHash?: string;
58
43
  }
59
44
 
60
- export interface RebindControlCommand extends NamedControlBase {
45
+ export interface RebindControlCommand extends ControlBase {
61
46
  command: "rebind";
62
47
  operationId: string;
63
48
  routeId: string;
64
49
  }
65
50
 
66
- export interface CancelControlCommand extends NamedControlBase {
51
+ export interface CancelControlCommand extends ControlBase {
67
52
  command: "cancel";
68
53
  operationId: string;
69
54
  }
70
55
 
71
- export interface DiscardControlCommand extends NamedControlBase {
56
+ export interface DiscardControlCommand extends ControlBase {
72
57
  command: "discard";
73
58
  operationId: string;
74
59
  }
@@ -125,11 +110,10 @@ export function validateControlCommand(
125
110
  return { ok: false, code: "invalid-control-command" };
126
111
  }
127
112
 
128
- const common = ["command", "stage"] as const;
129
- const named = [...common, "change"] as const;
113
+ const common = ["command", "stage", "change"] as const;
130
114
  switch (value.command) {
131
115
  case "status":
132
- if (!hasExactKeys(value, named) || !validChange(value.change)) break;
116
+ if (!hasExactKeys(value, common) || !validChange(value.change)) break;
133
117
  return {
134
118
  ok: true,
135
119
  value: structuredClone(value) as unknown as StatusControlCommand,
@@ -137,7 +121,7 @@ export function validateControlCommand(
137
121
  case "cancel":
138
122
  case "discard":
139
123
  if (
140
- hasExactKeys(value, [...named, "operationId"]) &&
124
+ hasExactKeys(value, [...common, "operationId"]) &&
141
125
  validChange(value.change) &&
142
126
  validOperationId(value.operationId)
143
127
  ) {
@@ -149,37 +133,19 @@ export function validateControlCommand(
149
133
  break;
150
134
  case "start":
151
135
  if (
152
- hasExactKeys(value, [...named, "operationId"], ["provisionalKey"]) &&
136
+ hasExactKeys(value, [...common, "operationId"]) &&
153
137
  validChange(value.change) &&
154
- validOperationId(value.operationId) &&
155
- (value.provisionalKey === undefined ||
156
- (value.stage === "abel-design" &&
157
- typeof value.provisionalKey === "string" &&
158
- SHA256.test(value.provisionalKey)))
159
- ) {
160
- return {
161
- ok: true,
162
- value: structuredClone(value) as unknown as NamedStartControlCommand,
163
- };
164
- }
165
- if (
166
- hasExactKeys(value, [...common, "provisionalKey", "operationId"]) &&
167
- value.stage === "abel-design" &&
168
- typeof value.provisionalKey === "string" &&
169
- SHA256.test(value.provisionalKey) &&
170
138
  validOperationId(value.operationId)
171
139
  ) {
172
140
  return {
173
141
  ok: true,
174
- value: structuredClone(
175
- value,
176
- ) as unknown as ProvisionalDesignStartControlCommand,
142
+ value: structuredClone(value) as unknown as StartControlCommand,
177
143
  };
178
144
  }
179
145
  break;
180
146
  case "rebind":
181
147
  if (
182
- hasExactKeys(value, [...named, "operationId", "routeId"]) &&
148
+ hasExactKeys(value, [...common, "operationId", "routeId"]) &&
183
149
  validChange(value.change) &&
184
150
  validOperationId(value.operationId) &&
185
151
  typeof value.routeId === "string" &&
@@ -195,7 +161,7 @@ export function validateControlCommand(
195
161
  if (
196
162
  !hasExactKeys(
197
163
  value,
198
- [...named, "operationId"],
164
+ [...common, "operationId"],
199
165
  ["deliveryRevision", "receiptHash"],
200
166
  ) ||
201
167
  !validChange(value.change) ||
@@ -32,9 +32,10 @@ import type { ResolvedStateRoot } from "./state-root.ts";
32
32
 
33
33
  const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
34
34
  const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
35
- const SHA256 = /^[a-f0-9]{64}$/u;
36
35
  const ARTIFACT_SEGMENT = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/iu;
37
36
  const MAX_DESIGN_FILE_BYTES = 16 * 1024 * 1024;
37
+ const MAX_DESIGN_REQUIREMENT_BYTES = 64 * 1024;
38
+ const MAX_DESIGN_CONTRACT_BYTES = 256 * 1024;
38
39
 
39
40
  export interface DesignOpenSpecInspection {
40
41
  change: string;
@@ -45,21 +46,47 @@ export interface DesignOpenSpecInspection {
45
46
  }
46
47
 
47
48
  export type DesignControlRequest =
49
+ | {
50
+ operation: "start";
51
+ operationId: string;
52
+ requirement: string;
53
+ }
54
+ | {
55
+ operation: "start";
56
+ operationId: string;
57
+ change: string;
58
+ }
59
+ | {
60
+ operation: "status";
61
+ runId: string;
62
+ }
63
+ | {
64
+ operation: "bind-change";
65
+ runId: string;
66
+ operationId: string;
67
+ change: string;
68
+ }
48
69
  | {
49
70
  operation: "record-decision";
50
71
  runId: string;
51
72
  operationId: string;
52
73
  decisionId: string;
53
74
  category: "behavior" | "technical";
54
- contractHash: string;
75
+ contract: string;
55
76
  refs: string[];
56
77
  }
57
78
  | {
58
79
  operation: "approve-gate";
59
80
  runId: string;
60
81
  operationId: string;
61
- gate: "gate-a" | "gate-b";
62
- contractHash: string;
82
+ gate: "gate-a";
83
+ contract: string;
84
+ }
85
+ | {
86
+ operation: "approve-gate";
87
+ runId: string;
88
+ operationId: string;
89
+ gate: "gate-b";
63
90
  }
64
91
  | {
65
92
  operation: "compile-plan";
@@ -121,6 +148,25 @@ function exactKeys(
121
148
  );
122
149
  }
123
150
 
151
+ function normalizedTransientText(value: string): string {
152
+ return value.replace(/\r\n?/gu, "\n").trim();
153
+ }
154
+
155
+ function transientHash(domain: string, value: string): string {
156
+ return createHash("sha256")
157
+ .update(domain)
158
+ .update("\0")
159
+ .update(normalizedTransientText(value))
160
+ .digest("hex");
161
+ }
162
+
163
+ function lifecycleOperationId(kind: string, runId: string): string {
164
+ return `design-${kind}-${createHash("sha256")
165
+ .update(runId)
166
+ .digest("hex")
167
+ .slice(0, 32)}`;
168
+ }
169
+
124
170
  export function validateDesignControlRequest(
125
171
  value: unknown,
126
172
  ):
@@ -128,6 +174,37 @@ export function validateDesignControlRequest(
128
174
  | { ok: false; code: "invalid-design-control-request" } {
129
175
  if (!isRecord(value))
130
176
  return { ok: false, code: "invalid-design-control-request" };
177
+ if (value.operation === "start") {
178
+ const operationValid =
179
+ typeof value.operationId === "string" &&
180
+ IDENTIFIER.test(value.operationId);
181
+ if (!operationValid)
182
+ return { ok: false, code: "invalid-design-control-request" };
183
+ if (
184
+ exactKeys(value, ["operation", "operationId", "requirement"]) &&
185
+ typeof value.requirement === "string" &&
186
+ value.requirement.trim().length > 0 &&
187
+ Buffer.byteLength(value.requirement, "utf8") <=
188
+ MAX_DESIGN_REQUIREMENT_BYTES
189
+ ) {
190
+ return {
191
+ ok: true,
192
+ value: structuredClone(value) as DesignControlRequest,
193
+ };
194
+ }
195
+ return exactKeys(value, ["operation", "operationId", "change"]) &&
196
+ typeof value.change === "string" &&
197
+ CHANGE_NAME.test(value.change)
198
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
199
+ : { ok: false, code: "invalid-design-control-request" };
200
+ }
201
+ if (value.operation === "status") {
202
+ return exactKeys(value, ["operation", "runId"]) &&
203
+ typeof value.runId === "string" &&
204
+ IDENTIFIER.test(value.runId)
205
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
206
+ : { ok: false, code: "invalid-design-control-request" };
207
+ }
131
208
  const commonValid =
132
209
  typeof value.runId === "string" &&
133
210
  IDENTIFIER.test(value.runId) &&
@@ -135,6 +212,13 @@ export function validateDesignControlRequest(
135
212
  IDENTIFIER.test(value.operationId);
136
213
  if (!commonValid)
137
214
  return { ok: false, code: "invalid-design-control-request" };
215
+ if (value.operation === "bind-change") {
216
+ return exactKeys(value, ["operation", "runId", "operationId", "change"]) &&
217
+ typeof value.change === "string" &&
218
+ CHANGE_NAME.test(value.change)
219
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
220
+ : { ok: false, code: "invalid-design-control-request" };
221
+ }
138
222
  if (value.operation === "write-artifact") {
139
223
  return exactKeys(value, [
140
224
  "operation",
@@ -163,16 +247,22 @@ export function validateDesignControlRequest(
163
247
  : { ok: false, code: "invalid-design-control-request" };
164
248
  }
165
249
  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)
250
+ if (value.gate === "gate-b") {
251
+ return exactKeys(value, ["operation", "runId", "operationId", "gate"])
252
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
253
+ : { ok: false, code: "invalid-design-control-request" };
254
+ }
255
+ return value.gate === "gate-a" &&
256
+ exactKeys(value, [
257
+ "operation",
258
+ "runId",
259
+ "operationId",
260
+ "gate",
261
+ "contract",
262
+ ]) &&
263
+ typeof value.contract === "string" &&
264
+ value.contract.trim().length > 0 &&
265
+ Buffer.byteLength(value.contract, "utf8") <= MAX_DESIGN_CONTRACT_BYTES
176
266
  ? { ok: true, value: structuredClone(value) as DesignControlRequest }
177
267
  : { ok: false, code: "invalid-design-control-request" };
178
268
  }
@@ -185,14 +275,15 @@ export function validateDesignControlRequest(
185
275
  "operationId",
186
276
  "decisionId",
187
277
  "category",
188
- "contractHash",
278
+ "contract",
189
279
  "refs",
190
280
  ]) &&
191
281
  typeof value.decisionId === "string" &&
192
282
  IDENTIFIER.test(value.decisionId) &&
193
283
  (value.category === "behavior" || value.category === "technical") &&
194
- typeof value.contractHash === "string" &&
195
- SHA256.test(value.contractHash) &&
284
+ typeof value.contract === "string" &&
285
+ value.contract.trim().length > 0 &&
286
+ Buffer.byteLength(value.contract, "utf8") <= MAX_DESIGN_CONTRACT_BYTES &&
196
287
  Array.isArray(value.refs) &&
197
288
  value.refs.every((ref) => typeof ref === "string")
198
289
  ? { ok: true, value: structuredClone(value) as DesignControlRequest }
@@ -407,6 +498,12 @@ export class DesignController {
407
498
  if (!validation.ok) throw new Error(validation.code);
408
499
  const request = validation.value;
409
500
  switch (request.operation) {
501
+ case "start":
502
+ return this.#start(request);
503
+ case "status":
504
+ return this.#statusOutcome(request.runId);
505
+ case "bind-change":
506
+ return this.#bindChange(request);
410
507
  case "record-decision":
411
508
  return this.#journal.recordDecision(request);
412
509
  case "approve-gate":
@@ -422,6 +519,116 @@ export class DesignController {
422
519
  }
423
520
  }
424
521
 
522
+ #statusOutcome(runId: string): Record<string, unknown> {
523
+ const run = this.#runs.status(runId);
524
+ if (run.stage !== "abel-design") throw new Error("design-run-invalid");
525
+ const terminal = ["completed", "discarded", "rejected"].includes(run.state);
526
+ const design = this.#journal.status(runId);
527
+ const latestDecisionSequence = design.decisions.reduce(
528
+ (latest, decision) => Math.max(latest, decision.sequence),
529
+ 0,
530
+ );
531
+ const planReadyForGateB =
532
+ design.gates.gateA.current &&
533
+ design.plan !== null &&
534
+ design.plan.sequence >
535
+ Math.max(
536
+ design.gates.gateA.approvedSequence ?? 0,
537
+ latestDecisionSequence,
538
+ );
539
+ const canApproveGate =
540
+ !design.gates.gateA.current ||
541
+ (planReadyForGateB && !design.gates.gateB.current);
542
+ const legalActions = terminal
543
+ ? ["status"]
544
+ : [
545
+ "status",
546
+ "record-decision",
547
+ ...(canApproveGate ? ["approve-gate"] : []),
548
+ ...(design.gates.gateA.current && !design.change
549
+ ? ["bind-change"]
550
+ : []),
551
+ ...(design.gates.gateA.current && design.change
552
+ ? ["write-artifact", "delete-artifact", "compile-plan"]
553
+ : []),
554
+ ...(design.change &&
555
+ design.plan &&
556
+ design.gates.gateA.current &&
557
+ design.gates.gateB.current
558
+ ? ["finalize-delivery"]
559
+ : []),
560
+ "finish",
561
+ ];
562
+ return {
563
+ runId: run.runId,
564
+ stage: run.stage,
565
+ ...(run.change ? { change: run.change } : {}),
566
+ state: run.state,
567
+ completed: run.state === "completed",
568
+ ...(run.pauseCode ? { pause: { code: run.pauseCode } } : {}),
569
+ legalActions,
570
+ design,
571
+ };
572
+ }
573
+
574
+ #start(request: Extract<DesignControlRequest, { operation: "start" }>) {
575
+ const fromRequirement = "requirement" in request;
576
+ const run = this.#runs.startRun({
577
+ stage: "abel-design",
578
+ ...(fromRequirement
579
+ ? {
580
+ provisionalKey: transientHash(
581
+ "abel-design-requirement-v1",
582
+ request.requirement,
583
+ ),
584
+ }
585
+ : { change: request.change }),
586
+ operationId: request.operationId,
587
+ });
588
+ if (run.state === "created") {
589
+ this.#runs.transition({
590
+ runId: run.runId,
591
+ to: "paused",
592
+ operationId: lifecycleOperationId(
593
+ fromRequirement ? "await-gate-a" : "await-evidence",
594
+ run.runId,
595
+ ),
596
+ code: fromRequirement
597
+ ? "design-awaiting-gate-a"
598
+ : "design-awaiting-evidence",
599
+ });
600
+ }
601
+ return this.#statusOutcome(run.runId);
602
+ }
603
+
604
+ #bindChange(
605
+ request: Extract<DesignControlRequest, { operation: "bind-change" }>,
606
+ ) {
607
+ const run = this.#runs.status(request.runId);
608
+ if (run.change && run.change !== request.change) {
609
+ throw new Error("design-operation-conflict");
610
+ }
611
+ if (!run.change) {
612
+ const design = this.#journal.status(request.runId);
613
+ if (!design.gates.gateA.current)
614
+ throw new Error("design-gate-a-required");
615
+ }
616
+ const bound = this.#runs.bindChange({
617
+ runId: request.runId,
618
+ change: request.change,
619
+ operationId: request.operationId,
620
+ });
621
+ if (bound.state === "paused") {
622
+ this.#runs.transition({
623
+ runId: request.runId,
624
+ to: "paused",
625
+ operationId: lifecycleOperationId("bound", request.runId),
626
+ code: "design-awaiting-evidence",
627
+ });
628
+ }
629
+ return this.#statusOutcome(request.runId);
630
+ }
631
+
425
632
  #artifactRun(runId: string): { change: string; gateACurrent: boolean } {
426
633
  const status = this.#journal.status(runId);
427
634
  if (!status.change) throw new Error("design-change-required");
@@ -11,6 +11,7 @@ const SHA256 = /^[a-f0-9]{64}$/u;
11
11
  const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
12
12
  const ZERO_HASH = "0".repeat(64);
13
13
  const MAX_PLAN_BYTES = 16 * 1024 * 1024;
14
+ const MAX_CONTRACT_BYTES = 256 * 1024;
14
15
 
15
16
  const SCHEMA = `
16
17
  CREATE TABLE IF NOT EXISTS design_facts (
@@ -144,6 +145,7 @@ export interface DesignGateProjection {
144
145
  | "behavior-decision-changed"
145
146
  | "technical-decision-changed"
146
147
  | "plan-recompiled"
148
+ | "gate-a-reapproved"
147
149
  | "gate-a-stale"
148
150
  | "plan-missing";
149
151
  }
@@ -223,6 +225,21 @@ function sha256(value: Uint8Array | string): string {
223
225
  return createHash("sha256").update(value).digest("hex");
224
226
  }
225
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
+
226
243
  function requireIdentifier(value: string, label: string): void {
227
244
  if (!IDENTIFIER.test(value)) throw new Error(`invalid-${label}`);
228
245
  }
@@ -877,11 +894,14 @@ export class DesignJournal {
877
894
  operationId: string;
878
895
  decisionId: string;
879
896
  category: DecisionCategory;
880
- contractHash: string;
897
+ contract: string;
881
898
  refs: string[];
882
899
  }): Record<string, unknown> {
883
900
  requireIdentifier(input.decisionId, "design-decision-id");
884
- requireHash(input.contractHash, "design-decision-hash");
901
+ const decisionContractHash = contractHash(
902
+ "abel-design-decision-v1",
903
+ input.contract,
904
+ );
885
905
  if (input.category !== "behavior" && input.category !== "technical") {
886
906
  throw new Error("design-decision-category-invalid");
887
907
  }
@@ -890,7 +910,7 @@ export class DesignJournal {
890
910
  operation: "record-decision",
891
911
  decisionId: input.decisionId,
892
912
  category: input.category,
893
- contractHash: input.contractHash,
913
+ contractHash: decisionContractHash,
894
914
  refs,
895
915
  };
896
916
  return this.#transaction(() => {
@@ -909,7 +929,7 @@ export class DesignJournal {
909
929
  let decision: DesignDecisionProjection;
910
930
  if (
911
931
  prior &&
912
- prior.contractHash === input.contractHash &&
932
+ prior.contractHash === decisionContractHash &&
913
933
  canonicalJson(prior.refs) === canonicalJson(refs)
914
934
  ) {
915
935
  decision = prior;
@@ -919,7 +939,7 @@ export class DesignJournal {
919
939
  decisionId: input.decisionId,
920
940
  category: input.category,
921
941
  revision,
922
- contractHash: input.contractHash,
942
+ contractHash: decisionContractHash,
923
943
  refs,
924
944
  };
925
945
  const fact = this.#appendFact(
@@ -980,12 +1000,20 @@ export class DesignJournal {
980
1000
  return undefined;
981
1001
  }
982
1002
 
983
- approveGate(input: {
984
- runId: string;
985
- operationId: string;
986
- gate: DesignGate;
987
- contractHash: string;
988
- }): {
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
+ ): {
989
1017
  operation: "approve-gate";
990
1018
  runId: string;
991
1019
  gate: DesignGate;
@@ -994,14 +1022,17 @@ export class DesignJournal {
994
1022
  if (input.gate !== "gate-a" && input.gate !== "gate-b") {
995
1023
  throw new Error("design-gate-invalid");
996
1024
  }
997
- requireHash(input.contractHash, "design-decision-hash");
998
- const request = {
999
- operation: "approve-gate",
1000
- gate: input.gate,
1001
- contractHash: input.contractHash,
1002
- };
1025
+ const gateAContractHash =
1026
+ input.gate === "gate-a"
1027
+ ? contractHash("abel-design-gate-a-v1", input.contract)
1028
+ : undefined;
1003
1029
  return this.#transaction(() => {
1004
1030
  this.#run(input.runId, true);
1031
+ const request = {
1032
+ operation: "approve-gate",
1033
+ gate: input.gate,
1034
+ ...(gateAContractHash ? { contractHash: gateAContractHash } : {}),
1035
+ };
1005
1036
  const replay = this.#operationReplay(
1006
1037
  input.runId,
1007
1038
  input.operationId,
@@ -1012,15 +1043,26 @@ export class DesignJournal {
1012
1043
  return structuredClone(replay) as ReturnType<
1013
1044
  DesignJournal["approveGate"]
1014
1045
  >;
1046
+ let approvedContractHash = gateAContractHash;
1015
1047
  if (input.gate === "gate-b") {
1016
1048
  const current = this.#gateStatus(input.runId);
1017
1049
  if (!current.gateA.current) throw new Error("design-gate-a-required");
1018
1050
  const plan = this.#currentPlanProjection(input.runId);
1019
1051
  if (!plan) throw new Error("design-plan-required");
1020
- if (plan.canonicalHash !== input.contractHash) {
1021
- throw new Error("design-gate-b-plan-mismatch");
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");
1022
1061
  }
1062
+ approvedContractHash = plan.canonicalHash;
1023
1063
  }
1064
+ if (!approvedContractHash)
1065
+ throw new Error("design-transient-input-invalid");
1024
1066
  const existing = this.#latestApproval(input.runId, input.gate);
1025
1067
  const currentGate = this.#gateStatus(input.runId)[
1026
1068
  input.gate === "gate-a" ? "gateA" : "gateB"
@@ -1029,7 +1071,7 @@ export class DesignJournal {
1029
1071
  if (
1030
1072
  existing &&
1031
1073
  currentGate.current &&
1032
- existing.proof.contractHash === input.contractHash
1074
+ existing.proof.contractHash === approvedContractHash
1033
1075
  ) {
1034
1076
  proof = existing.proof;
1035
1077
  } else {
@@ -1037,7 +1079,7 @@ export class DesignJournal {
1037
1079
  const payload = {
1038
1080
  gate: input.gate,
1039
1081
  revision,
1040
- contractHash: input.contractHash,
1082
+ contractHash: approvedContractHash,
1041
1083
  };
1042
1084
  const fact = this.#appendFact(
1043
1085
  input.runId,
@@ -1047,7 +1089,7 @@ export class DesignJournal {
1047
1089
  );
1048
1090
  proof = {
1049
1091
  revision,
1050
- contractHash: input.contractHash,
1092
+ contractHash: approvedContractHash,
1051
1093
  recordHash: fact.record_hash,
1052
1094
  };
1053
1095
  }
@@ -1506,6 +1548,11 @@ export class DesignJournal {
1506
1548
  latestTechnical > gateBApproval.sequence
1507
1549
  ) {
1508
1550
  staleReason = "technical-decision-changed";
1551
+ } else if (
1552
+ gateAApproval !== undefined &&
1553
+ gateAApproval.sequence > gateBApproval.sequence
1554
+ ) {
1555
+ staleReason = "gate-a-reapproved";
1509
1556
  } else if (plan && plan.sequence > gateBApproval.sequence) {
1510
1557
  staleReason = "plan-recompiled";
1511
1558
  } else if (!plan) {