@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.
@@ -0,0 +1,250 @@
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { isValidRelativePath } from "./contracts.ts";
6
+ import { observeSafePath } from "./safe-path.ts";
7
+
8
+ export type CandidatePatchOperation =
9
+ | {
10
+ kind: "replace";
11
+ path: string;
12
+ oldText: string;
13
+ newText: string;
14
+ }
15
+ | { kind: "rewrite"; path: string; content: string }
16
+ | {
17
+ kind: "create";
18
+ path: string;
19
+ content: string;
20
+ mode: "regular" | "executable";
21
+ }
22
+ | { kind: "delete"; path: string };
23
+
24
+ interface CandidateFileState {
25
+ path: string;
26
+ base: string | null;
27
+ current: string | null;
28
+ mode: "100644" | "100755";
29
+ }
30
+
31
+ function exactKeys(value: object, expected: readonly string[]): boolean {
32
+ const actual = Object.keys(value).sort();
33
+ return (
34
+ actual.length === expected.length &&
35
+ [...expected].sort().every((key, index) => actual[index] === key)
36
+ );
37
+ }
38
+
39
+ function safeText(value: unknown, maxBytes: number): value is string {
40
+ if (typeof value !== "string" || value.includes("\0")) return false;
41
+ const encoded = Buffer.from(value, "utf8");
42
+ return encoded.byteLength <= maxBytes && encoded.toString("utf8") === value;
43
+ }
44
+
45
+ function renderFileDiff(state: CandidateFileState): string {
46
+ if (state.base === state.current) return "";
47
+ if (state.base === null && state.current === "") {
48
+ throw new Error("candidate-empty-file-create-unsupported");
49
+ }
50
+ if (state.current === null && state.base === "") {
51
+ throw new Error("candidate-empty-file-delete-unsupported");
52
+ }
53
+ const output: string[] = [`diff --git a/${state.path} b/${state.path}`];
54
+ if (state.base === null) output.push(`new file mode ${state.mode}`);
55
+ if (state.current === null) output.push(`deleted file mode ${state.mode}`);
56
+ const generated = generateUnifiedPatch(
57
+ state.path,
58
+ state.base ?? "",
59
+ state.current ?? "",
60
+ 3,
61
+ );
62
+ const generatedHeaders = `--- ${state.path}\n+++ ${state.path}\n`;
63
+ if (!generated.startsWith(generatedHeaders)) {
64
+ throw new Error("candidate-patch-diff-invalid");
65
+ }
66
+ output.push(state.base === null ? "--- /dev/null" : `--- a/${state.path}`);
67
+ output.push(state.current === null ? "+++ /dev/null" : `+++ b/${state.path}`);
68
+ return `${output.join("\n")}\n${generated.slice(generatedHeaders.length)}`;
69
+ }
70
+
71
+ function occurrenceCount(value: string, search: string): number {
72
+ let count = 0;
73
+ let offset = 0;
74
+ for (;;) {
75
+ const found = value.indexOf(search, offset);
76
+ if (found < 0) return count;
77
+ count += 1;
78
+ if (count > 1) return count;
79
+ offset = found + 1;
80
+ }
81
+ }
82
+
83
+ export function compileCandidatePatch(input: {
84
+ root: string;
85
+ writePaths: readonly string[];
86
+ deletePaths: readonly string[];
87
+ operations: readonly CandidatePatchOperation[];
88
+ maxBytes: number;
89
+ }): string {
90
+ if (
91
+ !path.isAbsolute(input.root) ||
92
+ !Number.isSafeInteger(input.maxBytes) ||
93
+ input.maxBytes < 1 ||
94
+ !Array.isArray(input.operations) ||
95
+ input.operations.length < 1 ||
96
+ input.operations.length > 128
97
+ ) {
98
+ throw new Error("candidate-patch-invalid");
99
+ }
100
+ const writePaths = new Set(input.writePaths);
101
+ const deletePaths = new Set(input.deletePaths);
102
+ const states = new Map<string, CandidateFileState>();
103
+
104
+ const requirePath = (relative: unknown): string => {
105
+ if (
106
+ !isValidRelativePath(relative) ||
107
+ relative === "." ||
108
+ relative.trim() !== relative ||
109
+ /[\r\n\t]/u.test(relative)
110
+ ) {
111
+ throw new Error("candidate-patch-path-invalid");
112
+ }
113
+ return relative;
114
+ };
115
+ const load = (relative: string): CandidateFileState => {
116
+ const existing = states.get(relative);
117
+ if (existing) return existing;
118
+ const observation = observeSafePath(input.root, relative);
119
+ if (observation.kind === "directory" || observation.kind === "unsafe") {
120
+ throw new Error("candidate-patch-path-unsafe");
121
+ }
122
+ let base: string | null = null;
123
+ let mode: CandidateFileState["mode"] = "100644";
124
+ if (observation.kind === "file") {
125
+ const target = path.join(input.root, relative);
126
+ const stat = lstatSync(target);
127
+ if (stat.size > input.maxBytes) {
128
+ throw new Error("candidate-patch-source-too-large");
129
+ }
130
+ const bytes = readFileSync(target);
131
+ try {
132
+ base = new TextDecoder("utf-8", {
133
+ fatal: true,
134
+ ignoreBOM: true,
135
+ }).decode(bytes);
136
+ } catch {
137
+ throw new Error("candidate-patch-source-invalid");
138
+ }
139
+ if (base.includes("\0")) {
140
+ throw new Error("candidate-patch-source-invalid");
141
+ }
142
+ mode = stat.mode & 0o111 ? "100755" : "100644";
143
+ }
144
+ const state = { path: relative, base, current: base, mode };
145
+ states.set(relative, state);
146
+ return state;
147
+ };
148
+
149
+ for (const operation of input.operations) {
150
+ if (
151
+ !operation ||
152
+ typeof operation !== "object" ||
153
+ Array.isArray(operation)
154
+ ) {
155
+ throw new Error("candidate-patch-operation-invalid");
156
+ }
157
+ switch (operation.kind) {
158
+ case "replace": {
159
+ if (
160
+ !exactKeys(operation, ["kind", "path", "oldText", "newText"]) ||
161
+ !safeText(operation.oldText, input.maxBytes) ||
162
+ operation.oldText.length === 0 ||
163
+ !safeText(operation.newText, input.maxBytes)
164
+ ) {
165
+ throw new Error("candidate-patch-replace-invalid");
166
+ }
167
+ const relative = requirePath(operation.path);
168
+ if (!writePaths.has(relative)) {
169
+ throw new Error("candidate-patch-replace-invalid");
170
+ }
171
+ const state = load(relative);
172
+ if (
173
+ state.current === null ||
174
+ occurrenceCount(state.current, operation.oldText) !== 1
175
+ ) {
176
+ throw new Error("candidate-patch-replace-invalid");
177
+ }
178
+ state.current = state.current.replace(
179
+ operation.oldText,
180
+ operation.newText,
181
+ );
182
+ break;
183
+ }
184
+ case "rewrite": {
185
+ if (
186
+ !exactKeys(operation, ["kind", "path", "content"]) ||
187
+ !safeText(operation.content, input.maxBytes)
188
+ ) {
189
+ throw new Error("candidate-patch-rewrite-invalid");
190
+ }
191
+ const relative = requirePath(operation.path);
192
+ if (!writePaths.has(relative)) {
193
+ throw new Error("candidate-patch-rewrite-invalid");
194
+ }
195
+ const state = load(relative);
196
+ if (state.current === null) {
197
+ throw new Error("candidate-patch-rewrite-invalid");
198
+ }
199
+ state.current = operation.content;
200
+ break;
201
+ }
202
+ case "create": {
203
+ if (
204
+ !exactKeys(operation, ["kind", "path", "content", "mode"]) ||
205
+ !safeText(operation.content, input.maxBytes) ||
206
+ operation.content.length === 0 ||
207
+ (operation.mode !== "regular" && operation.mode !== "executable")
208
+ ) {
209
+ throw new Error("candidate-patch-create-invalid");
210
+ }
211
+ const relative = requirePath(operation.path);
212
+ if (!writePaths.has(relative)) {
213
+ throw new Error("candidate-patch-create-invalid");
214
+ }
215
+ const state = load(relative);
216
+ if (state.base !== null || state.current !== null) {
217
+ throw new Error("candidate-patch-create-invalid");
218
+ }
219
+ state.current = operation.content;
220
+ state.mode = operation.mode === "executable" ? "100755" : "100644";
221
+ break;
222
+ }
223
+ case "delete": {
224
+ if (!exactKeys(operation, ["kind", "path"])) {
225
+ throw new Error("candidate-patch-delete-invalid");
226
+ }
227
+ const relative = requirePath(operation.path);
228
+ if (!deletePaths.has(relative)) {
229
+ throw new Error("candidate-patch-delete-invalid");
230
+ }
231
+ const state = load(relative);
232
+ if (state.base === null || state.current === null) {
233
+ throw new Error("candidate-patch-delete-invalid");
234
+ }
235
+ state.current = null;
236
+ break;
237
+ }
238
+ default:
239
+ throw new Error("candidate-patch-operation-invalid");
240
+ }
241
+ }
242
+
243
+ const diff = [...states.values()]
244
+ .filter((state) => state.base !== state.current)
245
+ .sort((left, right) => left.path.localeCompare(right.path))
246
+ .map(renderFileDiff)
247
+ .join("");
248
+ if (diff.length === 0) throw new Error("candidate-patch-empty");
249
+ return diff;
250
+ }
@@ -200,14 +200,8 @@ function structuralSubmitCount(message: AssistantMessage | undefined) {
200
200
  ).length;
201
201
  }
202
202
 
203
- function isSingleStructuralSubmit(message: AssistantMessage | undefined) {
204
- const content = finalDeliveryContent(message);
205
- return (
206
- message?.role === "assistant" &&
207
- content.length === 1 &&
208
- content[0]?.type === "toolCall" &&
209
- content[0].name === "abel_submit_result"
210
- );
203
+ function hasOneStructuralSubmit(message: AssistantMessage | undefined) {
204
+ return message?.role === "assistant" && structuralSubmitCount(message) === 1;
211
205
  }
212
206
 
213
207
  function safeChildError(failure: ChildFailure): string {
@@ -317,7 +311,7 @@ export async function runChildSession(input: {
317
311
  onStreamStart?: () => void;
318
312
  onStreamProgress?: () => void;
319
313
  }): Promise<ChildSessionResult> {
320
- const segmentedCandidate = input.candidateArtifact !== undefined;
314
+ const candidateProtocol = input.candidateArtifact !== undefined;
321
315
  const submit = input.candidateArtifact
322
316
  ? createCandidateArtifactTool(input.candidateArtifact)
323
317
  : createSubmitTool({
@@ -402,14 +396,19 @@ export async function runChildSession(input: {
402
396
  const assistants =
403
397
  session?.messages.filter((m) => m.role === "assistant") ?? [];
404
398
  const last = assistants.at(-1);
399
+ const lastSubmit = assistants.findLast((message) =>
400
+ hasStructuralSubmit(message),
401
+ );
402
+ const structuralAttempts = assistants.reduce(
403
+ (count, message) => count + structuralSubmitCount(message),
404
+ 0,
405
+ );
405
406
  let finalCategory: FinalCategory;
406
407
  if (!last) {
407
408
  finalCategory = "no-final-assistant";
408
- } else if (isSingleStructuralSubmit(last)) {
409
+ } else if (hasOneStructuralSubmit(lastSubmit)) {
409
410
  finalCategory =
410
- !segmentedCandidate && submit.getAttempts() > 1
411
- ? "multiple-submit"
412
- : "single-submit-only";
411
+ structuralAttempts > 1 ? "multiple-submit" : "single-submit-only";
413
412
  } else {
414
413
  const content = finalDeliveryContent(last);
415
414
  finalCategory =
@@ -504,13 +503,17 @@ export async function runChildSession(input: {
504
503
  if (childAgent) {
505
504
  const previousShouldStopAfterTurn = childAgent.shouldStopAfterTurn;
506
505
  childAgent.shouldStopAfterTurn = async (turn, signal) => {
507
- if (
508
- segmentedCandidate
509
- ? submit.getResult() !== undefined
510
- : hasStructuralSubmit(turn.message)
511
- ) {
506
+ if (submit.getResult() !== undefined) {
512
507
  return true;
513
508
  }
509
+ const structuralAttempts =
510
+ session?.messages
511
+ .filter((message) => message.role === "assistant")
512
+ .reduce(
513
+ (count, message) => count + structuralSubmitCount(message),
514
+ 0,
515
+ ) ?? 0;
516
+ if (structuralAttempts >= 2) return true;
514
517
  return (await previousShouldStopAfterTurn?.(turn, signal)) ?? false;
515
518
  };
516
519
  }
@@ -560,11 +563,11 @@ export async function runChildSession(input: {
560
563
  const result = submit.getResult();
561
564
  const attempts = submit.getAttempts();
562
565
  const classification = classifySession();
563
- const segmentedComplete =
564
- segmentedCandidate &&
566
+ const candidateComplete =
567
+ candidateProtocol &&
565
568
  result !== undefined &&
566
569
  (result.kind === "sealed-candidate" || result.kind === "context-request");
567
- if (!result || (segmentedCandidate ? !segmentedComplete : attempts !== 1)) {
570
+ if (!result || (candidateProtocol && !candidateComplete)) {
568
571
  const failure = withSubmitDetails(
569
572
  submit.getFailure() ??
570
573
  input.failureOverride?.() ??
@@ -589,9 +592,9 @@ export async function runChildSession(input: {
589
592
  const assistants = session.messages.filter((m) => m.role === "assistant");
590
593
  const final = assistants.at(-1);
591
594
  if (
592
- !(segmentedCandidate
593
- ? segmentedComplete && structuralSubmitCount(final) === 1
594
- : isSingleStructuralSubmit(final))
595
+ !(candidateProtocol
596
+ ? candidateComplete && structuralSubmitCount(final) === 1
597
+ : hasOneStructuralSubmit(final))
595
598
  ) {
596
599
  const failure = {
597
600
  kind: "artifact",
package/src/contracts.ts CHANGED
@@ -10,7 +10,6 @@ export type {
10
10
  export {
11
11
  assertControlCommand,
12
12
  CONTROL_COMMANDS,
13
- CONTROL_PROTOCOL_VERSION,
14
13
  CONTROL_STAGES,
15
14
  controlRunKey,
16
15
  validateControlCommand,
@@ -23,18 +22,11 @@ export const STAGES = [
23
22
  ] as const;
24
23
  export const ROLES = [
25
24
  "design-explorer",
26
- "contract-reviewer",
27
25
  "implementation-worker",
28
26
  "diagnosis-worker",
29
27
  ] as const;
30
28
  export const OUTPUT_KINDS = ["evidence", "diff"] as const;
31
- export const PHASES = [
32
- "evidence",
33
- "red",
34
- "green",
35
- "refactor",
36
- "review",
37
- ] as const;
29
+ export const PHASES = ["evidence", "red", "green", "refactor"] as const;
38
30
 
39
31
  export const AGENTS_IMPACTS = [
40
32
  "none",
@@ -146,8 +138,10 @@ export const APPROVAL_BOUNDARY_CODES = [
146
138
  "behavior-contract-insufficient",
147
139
  "architecture-contract-insufficient",
148
140
  "task-scope-insufficient",
141
+ "conflict-resource-authority-insufficient",
149
142
  "verification-contract-insufficient",
150
143
  "agents-contract-insufficient",
144
+ "irreversible-scope-insufficient",
151
145
  ] as const;
152
146
  export const VERIFICATION_ADAPTER_CODES = [
153
147
  "input-missing",
@@ -439,6 +433,7 @@ export type ImplementGraphReadinessDiagnostic =
439
433
  export interface PacketEnvelope {
440
434
  stage: "abel-design" | "abel-diagnose";
441
435
  role: "design-explorer" | "diagnosis-worker";
436
+ runId?: string;
442
437
  id: string;
443
438
  phase: string;
444
439
  objective: string;
@@ -1230,7 +1225,7 @@ export function validatePacketEnvelope(
1230
1225
  return { ok: false, reason: "missing request envelope" };
1231
1226
  }
1232
1227
  const packet = value as Record<string, unknown>;
1233
- const required = [
1228
+ const baseRequired = [
1234
1229
  "stage",
1235
1230
  "role",
1236
1231
  "id",
@@ -1241,6 +1236,14 @@ export function validatePacketEnvelope(
1241
1236
  "declared",
1242
1237
  "output",
1243
1238
  ] as const;
1239
+ const required =
1240
+ packet.stage === "abel-design"
1241
+ ? ([
1242
+ ...baseRequired.slice(0, 2),
1243
+ "runId",
1244
+ ...baseRequired.slice(2),
1245
+ ] as const)
1246
+ : baseRequired;
1244
1247
  for (const field of required) {
1245
1248
  if (packet[field] === undefined) {
1246
1249
  return { ok: false, reason: `missing required field: ${field}` };
@@ -1262,6 +1265,12 @@ export function validatePacketEnvelope(
1262
1265
  ) {
1263
1266
  return { ok: false, reason: "packet role does not match stage" };
1264
1267
  }
1268
+ if (
1269
+ (packet.stage === "abel-design" && !validIdentifier(packet.runId)) ||
1270
+ (packet.stage === "abel-diagnose" && packet.runId !== undefined)
1271
+ ) {
1272
+ return { ok: false, reason: "invalid Design run identity" };
1273
+ }
1265
1274
  if (
1266
1275
  typeof packet.id !== "string" ||
1267
1276
  packet.id.length === 0 ||
@@ -2,8 +2,6 @@
2
2
  // Mechanical graph, snapshot, task, and apply identities are intentionally not
3
3
  // part of this surface: the control plane derives them from approved delivery.
4
4
 
5
- export const CONTROL_PROTOCOL_VERSION = 2 as const;
6
-
7
5
  export const CONTROL_COMMANDS = [
8
6
  "start",
9
7
  "status",
@@ -13,7 +11,7 @@ export const CONTROL_COMMANDS = [
13
11
  "discard",
14
12
  ] as const;
15
13
 
16
- export const CONTROL_STAGES = ["abel-design", "abel-implement"] as const;
14
+ export const CONTROL_STAGES = ["abel-implement"] as const;
17
15
 
18
16
  export type ControlCommandName = (typeof CONTROL_COMMANDS)[number];
19
17
  export type ControlStage = (typeof CONTROL_STAGES)[number];
@@ -23,55 +21,39 @@ const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
23
21
  const SHA256 = /^[a-f0-9]{64}$/u;
24
22
 
25
23
  interface ControlBase {
26
- version: typeof CONTROL_PROTOCOL_VERSION;
27
24
  command: ControlCommandName;
28
25
  stage: ControlStage;
29
- }
30
-
31
- interface NamedControlBase extends ControlBase {
32
26
  change: string;
33
27
  }
34
28
 
35
- export interface NamedStartControlCommand extends NamedControlBase {
36
- command: "start";
37
- operationId: string;
38
- provisionalKey?: string;
39
- }
40
-
41
- export interface ProvisionalDesignStartControlCommand extends ControlBase {
29
+ export interface StartControlCommand extends ControlBase {
42
30
  command: "start";
43
- stage: "abel-design";
44
- provisionalKey: string;
45
31
  operationId: string;
46
32
  }
47
33
 
48
- export type StartControlCommand =
49
- | NamedStartControlCommand
50
- | ProvisionalDesignStartControlCommand;
51
-
52
- export interface StatusControlCommand extends NamedControlBase {
34
+ export interface StatusControlCommand extends ControlBase {
53
35
  command: "status";
54
36
  }
55
37
 
56
- export interface ResumeControlCommand extends NamedControlBase {
38
+ export interface ResumeControlCommand extends ControlBase {
57
39
  command: "resume";
58
40
  operationId: string;
59
41
  deliveryRevision?: number;
60
42
  receiptHash?: string;
61
43
  }
62
44
 
63
- export interface RebindControlCommand extends NamedControlBase {
45
+ export interface RebindControlCommand extends ControlBase {
64
46
  command: "rebind";
65
47
  operationId: string;
66
48
  routeId: string;
67
49
  }
68
50
 
69
- export interface CancelControlCommand extends NamedControlBase {
51
+ export interface CancelControlCommand extends ControlBase {
70
52
  command: "cancel";
71
53
  operationId: string;
72
54
  }
73
55
 
74
- export interface DiscardControlCommand extends NamedControlBase {
56
+ export interface DiscardControlCommand extends ControlBase {
75
57
  command: "discard";
76
58
  operationId: string;
77
59
  }
@@ -86,10 +68,7 @@ export type ControlCommand =
86
68
 
87
69
  export type ControlCommandValidation =
88
70
  | { ok: true; value: ControlCommand }
89
- | {
90
- ok: false;
91
- code: "invalid-control-command" | "unsupported-control-version";
92
- };
71
+ | { ok: false; code: "invalid-control-command" };
93
72
 
94
73
  function isRecord(value: unknown): value is Record<string, unknown> {
95
74
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -110,7 +89,6 @@ function hasExactKeys(
110
89
 
111
90
  function validCommon(value: Record<string, unknown>): boolean {
112
91
  return (
113
- value.version === CONTROL_PROTOCOL_VERSION &&
114
92
  (CONTROL_COMMANDS as readonly unknown[]).includes(value.command) &&
115
93
  (CONTROL_STAGES as readonly unknown[]).includes(value.stage)
116
94
  );
@@ -128,21 +106,14 @@ export function validateControlCommand(
128
106
  value: unknown,
129
107
  ): ControlCommandValidation {
130
108
  if (!isRecord(value)) return { ok: false, code: "invalid-control-command" };
131
- if (
132
- Object.hasOwn(value, "version") &&
133
- value.version !== CONTROL_PROTOCOL_VERSION
134
- ) {
135
- return { ok: false, code: "unsupported-control-version" };
136
- }
137
109
  if (!validCommon(value)) {
138
110
  return { ok: false, code: "invalid-control-command" };
139
111
  }
140
112
 
141
- const common = ["version", "command", "stage"] as const;
142
- const named = [...common, "change"] as const;
113
+ const common = ["command", "stage", "change"] as const;
143
114
  switch (value.command) {
144
115
  case "status":
145
- if (!hasExactKeys(value, named) || !validChange(value.change)) break;
116
+ if (!hasExactKeys(value, common) || !validChange(value.change)) break;
146
117
  return {
147
118
  ok: true,
148
119
  value: structuredClone(value) as unknown as StatusControlCommand,
@@ -150,7 +121,7 @@ export function validateControlCommand(
150
121
  case "cancel":
151
122
  case "discard":
152
123
  if (
153
- hasExactKeys(value, [...named, "operationId"]) &&
124
+ hasExactKeys(value, [...common, "operationId"]) &&
154
125
  validChange(value.change) &&
155
126
  validOperationId(value.operationId)
156
127
  ) {
@@ -162,37 +133,19 @@ export function validateControlCommand(
162
133
  break;
163
134
  case "start":
164
135
  if (
165
- hasExactKeys(value, [...named, "operationId"], ["provisionalKey"]) &&
136
+ hasExactKeys(value, [...common, "operationId"]) &&
166
137
  validChange(value.change) &&
167
- validOperationId(value.operationId) &&
168
- (value.provisionalKey === undefined ||
169
- (value.stage === "abel-design" &&
170
- typeof value.provisionalKey === "string" &&
171
- SHA256.test(value.provisionalKey)))
172
- ) {
173
- return {
174
- ok: true,
175
- value: structuredClone(value) as unknown as NamedStartControlCommand,
176
- };
177
- }
178
- if (
179
- hasExactKeys(value, [...common, "provisionalKey", "operationId"]) &&
180
- value.stage === "abel-design" &&
181
- typeof value.provisionalKey === "string" &&
182
- SHA256.test(value.provisionalKey) &&
183
138
  validOperationId(value.operationId)
184
139
  ) {
185
140
  return {
186
141
  ok: true,
187
- value: structuredClone(
188
- value,
189
- ) as unknown as ProvisionalDesignStartControlCommand,
142
+ value: structuredClone(value) as unknown as StartControlCommand,
190
143
  };
191
144
  }
192
145
  break;
193
146
  case "rebind":
194
147
  if (
195
- hasExactKeys(value, [...named, "operationId", "routeId"]) &&
148
+ hasExactKeys(value, [...common, "operationId", "routeId"]) &&
196
149
  validChange(value.change) &&
197
150
  validOperationId(value.operationId) &&
198
151
  typeof value.routeId === "string" &&
@@ -208,7 +161,7 @@ export function validateControlCommand(
208
161
  if (
209
162
  !hasExactKeys(
210
163
  value,
211
- [...named, "operationId"],
164
+ [...common, "operationId"],
212
165
  ["deliveryRevision", "receiptHash"],
213
166
  ) ||
214
167
  !validChange(value.change) ||