@brightbeamai/chap-coordinator 0.2.13 → 0.3.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.
package/dist/index.cjs CHANGED
@@ -35,8 +35,12 @@ __export(src_exports, {
35
35
  deriveKeypair: () => deriveKeypair,
36
36
  deriveSeed: () => deriveSeed,
37
37
  diffJsonPatch: () => diffJsonPatch,
38
+ entryCall: () => entryCall,
39
+ entryRecord: () => entryRecord,
40
+ isRefusal: () => isRefusal,
38
41
  isValidEnvelope: () => isValidEnvelope,
39
42
  jwkFromPrivateKey: () => jwkFromPrivateKey,
43
+ linkHash: () => linkHash,
40
44
  makeApi: () => makeApi,
41
45
  makeDefaultPolicy: () => makeDefaultPolicy,
42
46
  modeLE: () => modeLE,
@@ -57,7 +61,21 @@ var import_node_crypto = require("crypto");
57
61
  var ZERO_HASH = "sha256:" + "0".repeat(64);
58
62
  var NON_INTEGER_ERROR = 'CHAP canonical numbers must be integers; represent decimals as strings (e.g. "8.2") so the hash is deterministic across implementations.';
59
63
  var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <= Number.MAX_SAFE_INTEGER); represent larger numbers as strings.";
64
+ var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
65
+ function assertNoLoneSurrogate(s) {
66
+ for (let i = 0; i < s.length; i++) {
67
+ const c = s.charCodeAt(i);
68
+ if (c >= 55296 && c <= 56319) {
69
+ const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0;
70
+ if (next < 56320 || next > 57343) throw new Error(LONE_SURROGATE_ERROR);
71
+ i++;
72
+ } else if (c >= 56320 && c <= 57343) {
73
+ throw new Error(LONE_SURROGATE_ERROR);
74
+ }
75
+ }
76
+ }
60
77
  function canonicalString(s) {
78
+ assertNoLoneSurrogate(s);
61
79
  return JSON.stringify(s);
62
80
  }
63
81
  function canonicalNumber(n) {
@@ -106,6 +124,55 @@ function contentHash(content) {
106
124
  return sha256Hex(canonicalize(content));
107
125
  }
108
126
 
127
+ // src/catalogue.ts
128
+ var OWNING_PROFILE = Object.freeze({
129
+ "abstain.declare": "review/1.0",
130
+ "audit.read": "core/1.0",
131
+ "audit.submit_to_scitt": "audit-scitt/1.0",
132
+ "audit.verify_chain": "audit-scitt/1.0",
133
+ "audit.verify_receipt": "audit-scitt/1.0",
134
+ "control.cancel": "control/1.0",
135
+ "control.pause": "control/1.0",
136
+ "control.resume": "control/1.0",
137
+ "control.rollback": "control/1.0",
138
+ "control.set_mode_ceiling": "control/1.0",
139
+ "control.snapshot": "control/1.0",
140
+ "control.supersede": "control/1.0",
141
+ "decide.approve": "review/1.0",
142
+ "decide.override": "review/1.0",
143
+ "decide.reject": "review/1.0",
144
+ "deliberate.close": "deliberation/1.0",
145
+ "deliberate.comment": "deliberation/1.0",
146
+ "deliberate.open": "deliberation/1.0",
147
+ "deliberate.vote": "deliberation/1.0",
148
+ "escalate.auto": "routing/1.0",
149
+ "escalate.raise": "review/1.0",
150
+ "handoff.accept": "handoff/1.0",
151
+ "handoff.decline": "handoff/1.0",
152
+ "handoff.propose": "handoff/1.0",
153
+ "participant.join": "core/1.0",
154
+ "participant.leave": "core/1.0",
155
+ "participant.revoke_key": "core/1.0",
156
+ "participant.rotate_key": "core/1.0",
157
+ "review.depth": "routing/1.0",
158
+ "review.request": "review/1.0",
159
+ "task.complete": "core/1.0",
160
+ "task.create": "core/1.0",
161
+ "task.route": "routing/1.0",
162
+ "task.update": "core/1.0",
163
+ "whisper.answer": "whisper/1.0",
164
+ "whisper.ask": "whisper/1.0",
165
+ "workspace.create": "core/1.0",
166
+ "workspace.describe": "core/1.0",
167
+ "workspace.set_profiles": "core/1.0"
168
+ });
169
+ var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
170
+ "workspace.describe",
171
+ "audit.read",
172
+ "audit.verify_chain",
173
+ "audit.verify_receipt"
174
+ ]);
175
+
109
176
  // src/crypto.ts
110
177
  var import_node_crypto2 = require("crypto");
111
178
  var ED25519_OID = Buffer.from("302a300506032b6570032100", "hex");
@@ -319,11 +386,84 @@ function isValidEnvelope(env) {
319
386
  return e.jsonrpc === "2.0";
320
387
  }
321
388
 
389
+ // src/audit.ts
390
+ function entryRecord(entry) {
391
+ if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
392
+ return entry.envelope;
393
+ }
394
+ function entryCall(entry) {
395
+ return entry.envelope ?? entry.request ?? void 0;
396
+ }
397
+ function isRefusal(entry) {
398
+ return entry.outcome != null;
399
+ }
400
+ function isCall(value) {
401
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
402
+ const v = value;
403
+ return v.jsonrpc === "2.0" && typeof v.method === "string";
404
+ }
405
+ function entryIsWellFormed(entry) {
406
+ const accepted = entry.envelope != null;
407
+ const refused = entry.request != null;
408
+ if (accepted === refused) return false;
409
+ if (accepted) return isCall(entry.envelope) && entry.outcome == null;
410
+ const o = entry.outcome;
411
+ return isCall(entry.request) && o != null && typeof o === "object" && o.status === "refused" && Number.isInteger(o.code);
412
+ }
413
+ function isSigned(call2) {
414
+ return call2 !== null && typeof call2 === "object" && typeof call2.sig === "string";
415
+ }
416
+ function signedDigest(call2) {
417
+ const signed = { ...call2 };
418
+ delete signed.sig;
419
+ return sha256Hex(canonicalize(signed));
420
+ }
421
+ function linkHash(record, prev) {
422
+ return sha256Hex(Buffer.concat([canonicalize(record), Buffer.from(prev, "utf-8")]));
423
+ }
424
+ var UNRECORDED_CODES = /* @__PURE__ */ new Set([
425
+ E.PARSE,
426
+ E.REQUEST,
427
+ E.PARAMS,
428
+ E.INTERNAL,
429
+ E.SIG_VERIFY_FAILED,
430
+ E.SIG_KEY_NOT_FOUND,
431
+ E.SIG_KEY_REVOKED,
432
+ E.SIG_ROTATION_KEY_MISMATCH
433
+ ]);
434
+ var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
435
+ function refusalIsRecorded(method, error, privileged) {
436
+ if (!Number.isInteger(error.code)) return false;
437
+ if (UNRECORDED_METHODS.has(method)) return false;
438
+ if (UNRECORDED_CODES.has(error.code)) return false;
439
+ if (error.code === E.METHOD) {
440
+ const data = error.data;
441
+ return typeof data?.profile === "string" && privileged.has(method);
442
+ }
443
+ return true;
444
+ }
445
+
322
446
  // src/patch.ts
323
447
  var PatchError = class extends Error {
324
448
  };
325
449
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
326
450
  var ARRAY_INDEX_RE = /^(0|[1-9][0-9]*)$/;
451
+ function jsonType(value) {
452
+ if (value === null) return "null";
453
+ if (Array.isArray(value)) return "array";
454
+ switch (typeof value) {
455
+ case "object":
456
+ return "object";
457
+ case "string":
458
+ return "string";
459
+ case "number":
460
+ return "number";
461
+ case "boolean":
462
+ return "boolean";
463
+ default:
464
+ return "null";
465
+ }
466
+ }
327
467
  function arrayIndex(seg) {
328
468
  if (!ARRAY_INDEX_RE.test(seg)) throw new PatchError(`Array index expected at ${JSON.stringify(seg)}`);
329
469
  return Number(seg);
@@ -363,7 +503,7 @@ function navigate(doc, parts) {
363
503
  }
364
504
  parent = parent[part];
365
505
  } else {
366
- throw new PatchError(`Cannot traverse into ${typeof parent} at ${JSON.stringify(part)}`);
506
+ throw new PatchError(`Cannot traverse into ${jsonType(parent)} at ${JSON.stringify(part)}`);
367
507
  }
368
508
  }
369
509
  const last = parts[parts.length - 1];
@@ -433,7 +573,7 @@ function applyOne(doc, op) {
433
573
  } else if (parent !== null && typeof parent === "object") {
434
574
  parent[key] = op.value;
435
575
  } else {
436
- throw new PatchError(`Cannot add into ${typeof parent}`);
576
+ throw new PatchError(`Cannot add into ${jsonType(parent)}`);
437
577
  }
438
578
  return doc;
439
579
  }
@@ -443,7 +583,7 @@ function applyOne(doc, op) {
443
583
  if (parts.length === 0) return op.value;
444
584
  const [parent, key] = navigate(doc, parts);
445
585
  if (Array.isArray(parent)) {
446
- const idx = key;
586
+ const idx = key === "-" ? -1 : key;
447
587
  if (idx < 0 || idx >= parent.length) {
448
588
  throw new PatchError(`Path not found for replace: ${path}`);
449
589
  }
@@ -454,7 +594,7 @@ function applyOne(doc, op) {
454
594
  }
455
595
  parent[key] = op.value;
456
596
  } else {
457
- throw new PatchError(`Cannot replace in ${typeof parent}`);
597
+ throw new PatchError(`Cannot replace in ${jsonType(parent)}`);
458
598
  }
459
599
  return doc;
460
600
  }
@@ -463,7 +603,7 @@ function applyOne(doc, op) {
463
603
  if (parts.length === 0) throw new PatchError("Cannot remove root.");
464
604
  const [parent, key] = navigate(doc, parts);
465
605
  if (Array.isArray(parent)) {
466
- const idx = key;
606
+ const idx = key === "-" ? -1 : key;
467
607
  if (idx < 0 || idx >= parent.length) {
468
608
  throw new PatchError(`Index out of range for remove: ${path}`);
469
609
  }
@@ -474,7 +614,7 @@ function applyOne(doc, op) {
474
614
  }
475
615
  delete parent[key];
476
616
  } else {
477
- throw new PatchError(`Cannot remove from ${typeof parent}`);
617
+ throw new PatchError(`Cannot remove from ${jsonType(parent)}`);
478
618
  }
479
619
  return doc;
480
620
  }
@@ -682,7 +822,8 @@ function modeLE(a, ceiling) {
682
822
  }
683
823
 
684
824
  // src/profiles/audit_scitt.ts
685
- function buildStatement(workspaceId, envelope, issuer) {
825
+ var MALFORMED = "sha256:malformed";
826
+ function buildStatement(workspaceId, record, issuer) {
686
827
  return {
687
828
  protected: {
688
829
  alg: -8,
@@ -690,9 +831,9 @@ function buildStatement(workspaceId, envelope, issuer) {
690
831
  iss: issuer,
691
832
  kid: "scitt-issuer",
692
833
  cwt_claims: { sub: workspaceId, iat: null },
693
- "content-type": "application/chap+json;version=0.2"
834
+ "content-type": "application/chap+json;version=0.3"
694
835
  },
695
- payload: canonicalize(envelope).toString("utf-8"),
836
+ payload: canonicalize(record).toString("utf-8"),
696
837
  signature: "<deployment-supplied>"
697
838
  };
698
839
  }
@@ -705,7 +846,7 @@ function registerAuditScitt(coord) {
705
846
  const toSeq = range.to_seq ?? ws.audit.length;
706
847
  const issuer = p.issuer ?? "service:coordinator";
707
848
  if (!coord.options.scittSubmitter) {
708
- const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, e.envelope, issuer));
849
+ const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
709
850
  return { result: {
710
851
  statements,
711
852
  note: "No scittSubmitter configured; submit these out-of-band"
@@ -713,7 +854,7 @@ function registerAuditScitt(coord) {
713
854
  }
714
855
  const receipts = [];
715
856
  for (const entry of ws.audit.slice(fromSeq, toSeq)) {
716
- const statement = buildStatement(ws.id, entry.envelope, issuer);
857
+ const statement = buildStatement(ws.id, entryRecord(entry), issuer);
717
858
  let receipt;
718
859
  try {
719
860
  receipt = coord.options.scittSubmitter(statement);
@@ -765,12 +906,20 @@ function registerAuditScitt(coord) {
765
906
  if (start < 0) start = ws.audit.length;
766
907
  const errors = [];
767
908
  let prev = ZERO_HASH;
909
+ let resync = false;
768
910
  for (const e of ws.audit.slice(start)) {
769
- const expectedPrev = prev;
911
+ const expectedPrev = resync && typeof e.prev_hash === "string" ? e.prev_hash : prev;
912
+ resync = false;
770
913
  if (e.prev_hash !== expectedPrev) {
771
914
  errors.push(`seq ${e.seq}: prev_hash mismatch`);
772
915
  }
773
- prev = sha256Hex(Buffer.concat([canonicalize(e.envelope), Buffer.from(expectedPrev, "utf-8")]));
916
+ if (!entryIsWellFormed(e)) {
917
+ errors.push(`seq ${e.seq}: malformed entry`);
918
+ prev = MALFORMED;
919
+ resync = true;
920
+ continue;
921
+ }
922
+ prev = linkHash(entryRecord(e), expectedPrev);
774
923
  }
775
924
  const storedHead = ws.chain_head === void 0 || ws.chain_head === null ? ZERO_HASH : ws.chain_head;
776
925
  if (prev !== storedHead) {
@@ -825,6 +974,7 @@ function registerControl(coord) {
825
974
  }
826
975
  task.paused = true;
827
976
  const prior = task.state;
977
+ if (prior !== "paused") task.paused_from = prior;
828
978
  task.state = "paused";
829
979
  task.updated_at = coord.now();
830
980
  task.history.push({ ts: task.updated_at, from: p.from, state: "paused", note: `was ${prior}` });
@@ -858,10 +1008,12 @@ function registerControl(coord) {
858
1008
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot resume ${task.state} task`) };
859
1009
  }
860
1010
  task.paused = false;
861
- task.state = "in_progress";
1011
+ const restored = task.paused_from ?? "in_progress";
1012
+ task.paused_from = void 0;
1013
+ task.state = restored;
862
1014
  task.updated_at = coord.now();
863
- task.history.push({ ts: task.updated_at, from: p.from, state: "in_progress" });
864
- return { result: { scope: "task", task_id: task.id, state: "in_progress" } };
1015
+ task.history.push({ ts: task.updated_at, from: p.from, state: restored });
1016
+ return { result: { scope: "task", task_id: task.id, state: restored } };
865
1017
  }
866
1018
  if (scope === "participant") {
867
1019
  const uri = p.participant_uri || p.uri;
@@ -888,36 +1040,56 @@ function registerControl(coord) {
888
1040
  coord.handlers.set("control.snapshot", (p) => {
889
1041
  const ws = coord.workspaces.get(p.workspace);
890
1042
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
891
- const include = p.include ?? ["members", "open_tasks", "mode_ceiling"];
1043
+ if (Array.isArray(p.include) && p.include.length === 0) {
1044
+ return { error: rpcError(E.PARAMS, "include must not be empty") };
1045
+ }
1046
+ const include = Array.isArray(p.include) ? [...p.include] : ["members", "open_tasks", "mode_ceiling"];
892
1047
  const state = {};
893
1048
  if (include.includes("members")) {
894
- state.members = Array.from(ws.members.values()).map((m) => ({
1049
+ const members = Array.from(ws.members.values()).map((m) => ({
895
1050
  uri: m.uri,
896
1051
  type: m.type,
897
- role: m.role,
898
- scopes: m.scopes
1052
+ ...m.role ? { role: m.role } : {},
1053
+ ...m.scopes?.length ? { scopes: m.scopes } : {}
899
1054
  }));
1055
+ if (members.length) state.members = members;
900
1056
  }
901
1057
  if (include.includes("open_tasks")) {
902
- state.open_tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled" && t.state !== "superseded").map((t) => ({ id: t.id, kind: t.kind, state: t.state, assignee: t.assignee }));
1058
+ const tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled" && t.state !== "superseded").map((t) => ({
1059
+ id: t.id,
1060
+ kind: t.kind,
1061
+ state: t.state,
1062
+ ...t.assignee ? { assignee: t.assignee } : {}
1063
+ }));
1064
+ if (tasks.length) state.open_tasks = tasks;
903
1065
  }
904
1066
  if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
905
- if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
1067
+ if (include.includes("policy") && ws.routing_policy_uri) state.routing_policy_uri = ws.routing_policy_uri;
906
1068
  if (include.includes("audit") || include.includes("all")) state.audit_seq = ws.audit.length;
1069
+ const content = structuredClone({
1070
+ workspace: ws.id,
1071
+ audit_seq: ws.audit.length,
1072
+ include,
1073
+ state,
1074
+ ...p.label ? { label: p.label } : {}
1075
+ });
907
1076
  const snapId = coord.ids.artefactId();
908
1077
  const snap = {
909
1078
  id: snapId,
910
1079
  kind: "snapshot",
911
- ts: coord.now(),
912
- by: p.from,
913
- workspace: ws.id,
914
- audit_seq: ws.audit.length,
915
- label: p.label,
916
- include,
917
- state
1080
+ produced_at: coord.now(),
1081
+ produced_by: p.from,
1082
+ content_hash: contentHash(content),
1083
+ content
918
1084
  };
919
1085
  ws.snapshots.set(snapId, snap);
920
- return { result: { snapshot_artefact_id: snapId, audit_seq: snap.audit_seq, artefact: snap } };
1086
+ return {
1087
+ result: {
1088
+ snapshot_artefact_id: snapId,
1089
+ audit_seq: snap.content.audit_seq,
1090
+ artefact: structuredClone(snap)
1091
+ }
1092
+ };
921
1093
  });
922
1094
  coord.handlers.set("control.rollback", (p) => {
923
1095
  const ws = coord.workspaces.get(p.workspace);
@@ -926,31 +1098,34 @@ function registerControl(coord) {
926
1098
  if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
927
1099
  const snap = ws.snapshots.get(snapId);
928
1100
  if (!snap) return { error: rpcError(E.CONTROL_SNAPSHOT_NOT_FOUND, `Unknown snapshot: ${snapId}`) };
929
- const what = p.what_to_restore ?? snap.include;
1101
+ if (Array.isArray(p.what_to_restore) && p.what_to_restore.length === 0) {
1102
+ return { error: rpcError(E.PARAMS, "what_to_restore must not be empty") };
1103
+ }
1104
+ const what = p.what_to_restore ?? snap.content.include;
930
1105
  const restored = [];
931
- if (what.includes("mode_ceiling") && snap.state.mode_ceiling) {
932
- ws.mode_ceiling = snap.state.mode_ceiling;
1106
+ if (what.includes("mode_ceiling") && snap.content.state.mode_ceiling) {
1107
+ ws.mode_ceiling = snap.content.state.mode_ceiling;
933
1108
  restored.push("mode_ceiling");
934
1109
  }
935
- if (what.includes("members") && Array.isArray(snap.state.members)) {
1110
+ if (what.includes("members") && Array.isArray(snap.content.state.members)) {
936
1111
  const snapByUri = /* @__PURE__ */ new Map();
937
- for (const m of snap.state.members) {
1112
+ for (const m of snap.content.state.members) {
938
1113
  snapByUri.set(m.uri, m);
939
1114
  }
940
1115
  for (const [uri, snapM] of snapByUri) {
941
1116
  if (ws.members.has(uri)) {
942
1117
  const cur = ws.members.get(uri);
943
1118
  cur.role = snapM.role ?? cur.role;
944
- cur.scopes = snapM.scopes;
1119
+ cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
945
1120
  }
946
1121
  }
947
1122
  restored.push("members");
948
1123
  }
949
1124
  return { result: {
950
1125
  rolled_back_to: snapId,
951
- audit_seq: snap.audit_seq,
1126
+ audit_seq: snap.content.audit_seq,
952
1127
  restored,
953
- reason: p.reason
1128
+ ...p.reason ? { reason: p.reason } : {}
954
1129
  } };
955
1130
  });
956
1131
  coord.handlers.set("control.supersede", (p) => {
@@ -964,16 +1139,25 @@ function registerControl(coord) {
964
1139
  return { error: rpcError(E.PARAMS, "successor_task must include kind") };
965
1140
  }
966
1141
  const assignee = newSpec.assignee || old.assignee;
967
- if (!ws.members.has(assignee)) {
1142
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
968
1143
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
969
1144
  }
970
- const requestedMode = newSpec.mode ?? old.mode;
1145
+ if (ws.members.get(assignee).paused) {
1146
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
1147
+ }
1148
+ if (newSpec.mode !== void 0 && newSpec.mode !== null && typeof newSpec.mode !== "string") {
1149
+ return { error: rpcError(E.PARAMS, "successor_task.mode must be a string") };
1150
+ }
1151
+ const requestedMode = newSpec.mode || old.mode;
971
1152
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
972
1153
  return { error: rpcError(
973
1154
  E.MODE_CEILING_EXCEEDED,
974
1155
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
975
1156
  ) };
976
1157
  }
1158
+ if (newSpec.review_required !== void 0 && newSpec.review_required !== null && typeof newSpec.review_required !== "boolean") {
1159
+ return { error: rpcError(E.PARAMS, "successor_task.review_required must be a boolean") };
1160
+ }
977
1161
  const now = coord.now();
978
1162
  const newId = coord.ids.taskId();
979
1163
  const newTask = {
@@ -995,8 +1179,11 @@ function registerControl(coord) {
995
1179
  }],
996
1180
  paused: false
997
1181
  };
998
- if (ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") newTask.review_required = true;
999
- else if ("review_required" in newSpec) newTask.review_required = !!newSpec.review_required;
1182
+ if (old.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
1183
+ newTask.review_required = true;
1184
+ } else if ("review_required" in newSpec) {
1185
+ newTask.review_required = !!newSpec.review_required;
1186
+ }
1000
1187
  ws.tasks.set(newId, newTask);
1001
1188
  old.state = "superseded";
1002
1189
  old.superseded_by = newId;
@@ -1293,8 +1480,14 @@ function registerHandoff(coord) {
1293
1480
  ) };
1294
1481
  }
1295
1482
  }
1483
+ if (ws.members.get(acceptor).paused) {
1484
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
1485
+ }
1486
+ if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
1487
+ return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
1488
+ }
1296
1489
  const validIds = new Set(ho.tasks.map((t) => t.task_id));
1297
- const acceptedIds = Array.isArray(p.accepted_task_ids) ? p.accepted_task_ids : ho.tasks.map((t) => t.task_id);
1490
+ const acceptedIds = Array.isArray(p.accepted_task_ids) ? [...p.accepted_task_ids] : ho.tasks.map((t) => t.task_id);
1298
1491
  for (const tid of acceptedIds) {
1299
1492
  if (!validIds.has(tid)) {
1300
1493
  return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
@@ -1365,6 +1558,9 @@ function registerRouting(coord) {
1365
1558
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1366
1559
  const task = ws.tasks.get(p.task_id);
1367
1560
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
1561
+ if (p.candidates !== void 0 && p.candidates !== null && !Array.isArray(p.candidates)) {
1562
+ return { error: rpcError(E.PARAMS, "candidates must be a list") };
1563
+ }
1368
1564
  const candidates = p.candidates ?? [];
1369
1565
  if (!candidates.length) {
1370
1566
  return { error: rpcError(E.ROUTING_CANDIDATES_EMPTY, "candidates array was empty") };
@@ -1381,19 +1577,29 @@ function registerRouting(coord) {
1381
1577
  return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `routing policy error: ${msg}`) };
1382
1578
  }
1383
1579
  } else {
1384
- const eligible = candidates.filter((c) => ws.members.has(c));
1580
+ const memberOf = (c) => typeof c === "string" ? ws.members.get(c) : void 0;
1581
+ const eligible = candidates.filter((c) => {
1582
+ const m = memberOf(c);
1583
+ return !!m && !m.paused;
1584
+ });
1385
1585
  if (!eligible.length) {
1386
- return { error: rpcError(E.ROUTING_NO_ELIGIBLE_ASSIGNEE, "No candidate is a workspace member") };
1586
+ return { error: rpcError(E.ROUTING_NO_ELIGIBLE_ASSIGNEE, "No candidate is a workspace member who is not paused") };
1387
1587
  }
1388
1588
  selected = eligible[0];
1589
+ const alternatives = [];
1590
+ let chosen = false;
1591
+ for (const c of candidates) {
1592
+ const m = memberOf(c);
1593
+ if (!m) continue;
1594
+ if (m.paused) alternatives.push({ candidate: c, reason_excluded: "paused" });
1595
+ else if (!chosen) chosen = true;
1596
+ else alternatives.push({ candidate: c, reason_excluded: "not first eligible" });
1597
+ }
1389
1598
  rationale = {
1390
1599
  policy_id: "default",
1391
1600
  hints_used: Object.keys(task.routing_hints ?? {}),
1392
1601
  summary: "default policy: first eligible candidate",
1393
- alternatives_considered: eligible.slice(1).map((c) => ({
1394
- candidate: c,
1395
- reason_excluded: "not first eligible"
1396
- }))
1602
+ alternatives_considered: alternatives
1397
1603
  };
1398
1604
  }
1399
1605
  if (!ws.members.has(selected)) {
@@ -1402,6 +1608,9 @@ function registerRouting(coord) {
1402
1608
  `Selected ${JSON.stringify(selected)} is not a member`
1403
1609
  ) };
1404
1610
  }
1611
+ if (ws.members.get(selected).paused) {
1612
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
1613
+ }
1405
1614
  task.assignee = selected;
1406
1615
  task.updated_at = coord.now();
1407
1616
  task.history.push({
@@ -1519,6 +1728,18 @@ function registerRouting(coord) {
1519
1728
  hints_used: ["criticality", "confidence"]
1520
1729
  };
1521
1730
  }
1731
+ if (escalate) {
1732
+ if (!to) return { error: rpcError(
1733
+ E.ROUTING_ESC_TARGET_UNAVAILABLE,
1734
+ "Escalation triggered but no target available"
1735
+ ) };
1736
+ if (!to.startsWith("group:") && !ws.members.has(to)) {
1737
+ return { error: rpcError(
1738
+ E.ROUTING_ESC_TARGET_UNAVAILABLE,
1739
+ `Escalation target ${to} is not a member or group`
1740
+ ) };
1741
+ }
1742
+ }
1522
1743
  const artId = coord.ids.artefactId();
1523
1744
  const artefact = {
1524
1745
  id: artId,
@@ -1535,17 +1756,7 @@ function registerRouting(coord) {
1535
1756
  };
1536
1757
  ws.route_decisions.set(artId, artefact);
1537
1758
  if (escalate) {
1538
- if (!to) return { error: rpcError(
1539
- E.ROUTING_ESC_TARGET_UNAVAILABLE,
1540
- "Escalation triggered but no target available"
1541
- ) };
1542
- if (!to.startsWith("group:") && !ws.members.has(to)) {
1543
- return { error: rpcError(
1544
- E.ROUTING_ESC_TARGET_UNAVAILABLE,
1545
- `Escalation target ${to} is not a member or group`
1546
- ) };
1547
- }
1548
- if (coord.options.onAutoEscalate) {
1759
+ if (coord.options.onAutoEscalate && to) {
1549
1760
  try {
1550
1761
  coord.options.onAutoEscalate(task, to);
1551
1762
  } catch {
@@ -1615,6 +1826,12 @@ function registerSecuritySigned(coord) {
1615
1826
  function parseIso(ts) {
1616
1827
  return new Date(ts).getTime();
1617
1828
  }
1829
+ function present(v) {
1830
+ if (v === void 0 || v === null || v === false || v === 0 || v === "") return false;
1831
+ if (Array.isArray(v)) return v.length > 0;
1832
+ if (typeof v === "object") return Object.keys(v).length > 0;
1833
+ return true;
1834
+ }
1618
1835
  function registerWhisper(coord) {
1619
1836
  coord.handlers.set("whisper.ask", (p) => {
1620
1837
  const ws = coord.workspaces.get(p.workspace);
@@ -1625,6 +1842,9 @@ function registerWhisper(coord) {
1625
1842
  if (!ws.tasks.has(p.task_id)) {
1626
1843
  return { error: rpcError(E.PARAMS, "Unknown task") };
1627
1844
  }
1845
+ if (p.options !== void 0 && p.options !== null && !Array.isArray(p.options)) {
1846
+ return { error: rpcError(E.PARAMS, "options must be a list") };
1847
+ }
1628
1848
  const to = p.to;
1629
1849
  const askee = Array.isArray(to) ? to : [to];
1630
1850
  const id = p.whisper_id || coord.ids.artefactId();
@@ -1668,22 +1888,21 @@ function registerWhisper(coord) {
1668
1888
  return { error: rpcError(E.WHISPER_LAPSED, "Whisper already lapsed") };
1669
1889
  }
1670
1890
  const answerOption = p.answer_option;
1671
- const answerText = p.answer ?? p.answer_text;
1891
+ const hasOption = answerOption !== void 0 && answerOption !== null;
1892
+ const answerText = present(p.answer) ? p.answer : p.answer_text;
1672
1893
  if (prompt.options && prompt.options.length) {
1673
- if (answerOption === void 0) {
1894
+ if (!hasOption) {
1674
1895
  return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
1675
1896
  }
1676
1897
  const valid = new Set(prompt.options.map((o) => o.id));
1677
1898
  if (!valid.has(answerOption)) {
1678
1899
  return { error: rpcError(
1679
1900
  E.WHISPER_OPTION_NOT_IN_SET,
1680
- `Answer option ${JSON.stringify(answerOption)} not in option set`
1901
+ `Answer option ${canonicalize(answerOption).toString("utf-8")} not in option set`
1681
1902
  ) };
1682
1903
  }
1683
- } else {
1684
- if (answerText === void 0 && answerOption === void 0) {
1685
- return { error: rpcError(E.PARAMS, "answer or answer_option required") };
1686
- }
1904
+ } else if (!present(answerText) && !hasOption) {
1905
+ return { error: rpcError(E.PARAMS, "answer or answer_option required") };
1687
1906
  }
1688
1907
  prompt.state = "answered";
1689
1908
  prompt.answered_at = coord.now();
@@ -1691,7 +1910,6 @@ function registerWhisper(coord) {
1691
1910
  prompt.answer_option = answerOption;
1692
1911
  prompt.answer_text = answerText;
1693
1912
  prompt.comment = p.comment;
1694
- p.task_id = prompt.task_id;
1695
1913
  return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
1696
1914
  });
1697
1915
  coord.checkWhisperLapses = (workspaceId, now) => {
@@ -1744,14 +1962,38 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
1744
1962
  "participant.rotate_key",
1745
1963
  "participant.revoke_key"
1746
1964
  ]);
1965
+ var MEMBER_ONLY_METHODS = /* @__PURE__ */ new Set([
1966
+ "task.route",
1967
+ "review.depth",
1968
+ "escalate.auto",
1969
+ "participant.leave"
1970
+ ]);
1747
1971
  var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1748
1972
  "workspace.describe",
1749
1973
  "audit.read",
1750
1974
  "audit.verify_chain",
1751
- "audit.verify_receipt"
1975
+ "audit.verify_receipt",
1976
+ // Submitting the chain to a transparency service reads it and sends it
1977
+ // onward. Recording the submission would append to the very log being
1978
+ // submitted, so the receipt would attest a chain one entry shorter.
1979
+ "audit.submit_to_scitt"
1752
1980
  ]);
1753
1981
  var MAX_IDEMPOTENCY_KEYS = 1e4;
1754
1982
  var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
1983
+ var MAX_NESTING = 64;
1984
+ function withinNesting(value, limit) {
1985
+ const stack = [[value, 1]];
1986
+ while (stack.length) {
1987
+ const [node, depth] = stack.pop();
1988
+ if (depth > limit) return false;
1989
+ if (Array.isArray(node)) {
1990
+ for (const v of node) stack.push([v, depth + 1]);
1991
+ } else if (node !== null && typeof node === "object") {
1992
+ for (const v of Object.values(node)) stack.push([v, depth + 1]);
1993
+ }
1994
+ }
1995
+ return true;
1996
+ }
1755
1997
  function nowIso(clockMs) {
1756
1998
  const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
1757
1999
  return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
@@ -1767,9 +2009,6 @@ function tagsError(p) {
1767
2009
  }
1768
2010
  return null;
1769
2011
  }
1770
- function linkHash(envelope, prev) {
1771
- return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
1772
- }
1773
2012
  function reviewRuleSupported(rule) {
1774
2013
  if (rule === "any_one_approves" || rule === "all_approve") return true;
1775
2014
  if (rule.startsWith("quorum:")) {
@@ -1795,6 +2034,18 @@ function reply(env, body) {
1795
2034
  else out.result = body.result;
1796
2035
  return out;
1797
2036
  }
2037
+ function indexSigned(index, entry) {
2038
+ const call2 = entryCall(entry);
2039
+ if (!isSigned(call2)) return;
2040
+ let digest;
2041
+ try {
2042
+ digest = signedDigest(call2);
2043
+ } catch {
2044
+ return;
2045
+ }
2046
+ if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
2047
+ else index.accepted.add(digest);
2048
+ }
1798
2049
  var REVIEW_REQUEST_STOPPED = ["cancelled", "superseded", "paused"];
1799
2050
  var Coordinator = class {
1800
2051
  workspaces = /* @__PURE__ */ new Map();
@@ -1802,8 +2053,16 @@ var Coordinator = class {
1802
2053
  ids;
1803
2054
  store;
1804
2055
  wsVersions = /* @__PURE__ */ new Map();
2056
+ saveChains = /* @__PURE__ */ new Map();
1805
2057
  clockMs;
2058
+ frozenNow;
1806
2059
  auditListeners = [];
2060
+ /**
2061
+ * The signed calls on each workspace's log, by what their senders signed:
2062
+ * recorded refusals with their seq and code, and accepted calls. Rebuilt
2063
+ * from the log when missing, so a snapshot never carries it.
2064
+ */
2065
+ signedIndexes = /* @__PURE__ */ new WeakMap();
1807
2066
  /** Method handler registry; profiles plug into this. */
1808
2067
  handlers = /* @__PURE__ */ new Map();
1809
2068
  /** Custom lapse-check function (whisper/1.0). Set by the whisper profile. */
@@ -1845,42 +2104,72 @@ var Coordinator = class {
1845
2104
  this.applyRecords(records);
1846
2105
  }
1847
2106
  applyRecords(records) {
1848
- this.workspaces.clear();
1849
2107
  this.wsVersions.clear();
1850
- for (const r of records) {
1851
- this.restore([r.data]);
1852
- this.wsVersions.set(r.id, r.version);
1853
- }
2108
+ this.restore(records.map((r) => r.data));
2109
+ for (const r of records) this.wsVersions.set(r.id, r.version);
1854
2110
  }
1855
2111
  /** Persist the current snapshot of one workspace to the store. */
1856
2112
  persist(ws) {
1857
2113
  const next = (this.wsVersions.get(ws.id) ?? 0) + 1;
1858
2114
  this.wsVersions.set(ws.id, next);
2115
+ let record;
1859
2116
  try {
1860
2117
  const all = this.snapshot();
1861
2118
  const data = all.find((w) => w.id === ws.id);
1862
2119
  if (!data) return;
1863
- const result = this.store.save({
1864
- id: ws.id,
1865
- data,
1866
- version: next,
1867
- updated_at: this.now()
1868
- });
1869
- if (result instanceof Promise) {
1870
- result.catch(() => {
1871
- });
1872
- }
2120
+ record = { id: ws.id, data, version: next, updated_at: this.now() };
1873
2121
  } catch {
2122
+ return;
2123
+ }
2124
+ const pending = this.saveChains.get(ws.id);
2125
+ if (pending) {
2126
+ this.trackSave(pending.then(() => this.store.save(record)), record);
2127
+ return;
2128
+ }
2129
+ let result;
2130
+ try {
2131
+ result = this.store.save(record);
2132
+ } catch (err) {
2133
+ this.reportStoreError(err, record);
2134
+ return;
2135
+ }
2136
+ if (result instanceof Promise) {
2137
+ this.trackSave(result, record);
2138
+ }
2139
+ }
2140
+ trackSave(save, record) {
2141
+ const guarded = save.catch((err) => this.reportStoreError(err, record));
2142
+ this.saveChains.set(record.id, guarded);
2143
+ void guarded.then(() => {
2144
+ if (this.saveChains.get(record.id) === guarded) {
2145
+ this.saveChains.delete(record.id);
2146
+ }
2147
+ });
2148
+ }
2149
+ reportStoreError(err, record) {
2150
+ if (this.options.onStoreError) {
2151
+ try {
2152
+ this.options.onStoreError(err, { id: record.id, version: record.version });
2153
+ } catch {
2154
+ }
2155
+ }
2156
+ }
2157
+ async drainSaves() {
2158
+ while (this.saveChains.size) {
2159
+ await Promise.allSettled([...this.saveChains.values()]);
1874
2160
  }
1875
2161
  }
1876
2162
  // -- lifecycle -----------------------------------------------------
1877
- now() {
2163
+ advanceClock() {
1878
2164
  if (this.clockMs !== void 0) {
1879
2165
  this.clockMs += 1e3;
1880
2166
  return nowIso(this.clockMs);
1881
2167
  }
1882
2168
  return nowIso();
1883
2169
  }
2170
+ now() {
2171
+ return this.frozenNow ?? this.advanceClock();
2172
+ }
1884
2173
  onAudit(listener) {
1885
2174
  this.auditListeners.push(listener);
1886
2175
  return () => {
@@ -1905,8 +2194,11 @@ var Coordinator = class {
1905
2194
  * error-table divergence is tracked separately.)
1906
2195
  */
1907
2196
  requireMember(ws, sender) {
1908
- if (typeof sender !== "string" || !ws.members.has(sender)) {
1909
- return { error: rpcError(E.NOT_AUTHORISED, `Not a workspace member: ${String(sender)}`) };
2197
+ if (typeof sender !== "string") {
2198
+ return { error: rpcError(E.NOT_AUTHORISED, "Not a workspace member: from is not a participant URI") };
2199
+ }
2200
+ if (!ws.members.has(sender)) {
2201
+ return { error: rpcError(E.NOT_AUTHORISED, `Not a workspace member: ${sender}`) };
1910
2202
  }
1911
2203
  return null;
1912
2204
  }
@@ -2003,8 +2295,40 @@ var Coordinator = class {
2003
2295
  ws.deliberations.set(x.id, x);
2004
2296
  for (const x of w.handoffs ?? [])
2005
2297
  ws.handoffs.set(x.id, x);
2006
- for (const x of w.snapshots ?? [])
2007
- ws.snapshots.set(x.id, x);
2298
+ for (const x of w.snapshots ?? []) {
2299
+ const content = structuredClone(x.content ?? {
2300
+ workspace: x.workspace,
2301
+ audit_seq: x.audit_seq,
2302
+ include: x.include,
2303
+ state: x.state,
2304
+ ...x.label ? { label: x.label } : {}
2305
+ });
2306
+ if (!x.content) {
2307
+ const oldState = content.state;
2308
+ const state = {};
2309
+ for (const key of ["mode_ceiling", "routing_policy_uri", "audit_seq"]) {
2310
+ if (oldState[key] != null && oldState[key] !== "") state[key] = oldState[key];
2311
+ }
2312
+ for (const [key, fields] of [
2313
+ ["members", ["uri", "type", "role", "scopes"]],
2314
+ ["open_tasks", ["id", "kind", "state", "assignee"]]
2315
+ ]) {
2316
+ const items = oldState[key];
2317
+ if (Array.isArray(items) && items.length) {
2318
+ state[key] = items.map((item) => Object.fromEntries(fields.filter((field) => item[field] != null && item[field] !== "" && (!Array.isArray(item[field]) || item[field].length > 0)).map((field) => [field, item[field]])));
2319
+ }
2320
+ }
2321
+ content.state = state;
2322
+ }
2323
+ ws.snapshots.set(x.id, {
2324
+ id: x.id,
2325
+ kind: "snapshot",
2326
+ produced_by: x.produced_by ?? x.by,
2327
+ produced_at: x.produced_at ?? x.ts,
2328
+ content_hash: x.content_hash ?? contentHash(content),
2329
+ content
2330
+ });
2331
+ }
2008
2332
  for (const x of w.route_decisions ?? [])
2009
2333
  ws.route_decisions.set(x.id, x);
2010
2334
  this.workspaces.set(ws.id, ws);
@@ -2012,14 +2336,29 @@ var Coordinator = class {
2012
2336
  }
2013
2337
  // -- dispatch ------------------------------------------------------
2014
2338
  dispatch(envelope) {
2015
- if (!isValidEnvelope(envelope) || !envelope.method) {
2339
+ this.frozenNow = this.advanceClock();
2340
+ try {
2341
+ const response = this._dispatch(envelope);
2342
+ if (response.error) this.recordRefusal(envelope, response.error);
2343
+ return response;
2344
+ } finally {
2345
+ this.frozenNow = void 0;
2346
+ }
2347
+ }
2348
+ _dispatch(envelope) {
2349
+ if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
2016
2350
  return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
2017
2351
  }
2352
+ if (!withinNesting(envelope, MAX_NESTING)) {
2353
+ return reply(envelope, { error: rpcError(E.REQUEST, `Envelope nests deeper than ${MAX_NESTING} levels`) });
2354
+ }
2018
2355
  const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
2019
2356
  let size = 0;
2357
+ let canonicalError;
2020
2358
  try {
2021
2359
  size = canonicalize(envelope).length;
2022
- } catch {
2360
+ } catch (e) {
2361
+ canonicalError = e;
2023
2362
  }
2024
2363
  if (size > maxBytes) {
2025
2364
  return reply(envelope, { error: rpcError(
@@ -2033,6 +2372,28 @@ var Coordinator = class {
2033
2372
  return reply(envelope, { error: rpcError(E.PARAMS, "Invalid params: expected an object") });
2034
2373
  }
2035
2374
  const params = rawParams ?? {};
2375
+ const handler = this.handlers.get(method);
2376
+ if (!handler) {
2377
+ return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
2378
+ }
2379
+ if (canonicalError !== void 0) {
2380
+ const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
2381
+ return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2382
+ }
2383
+ if (isSigned(envelope)) {
2384
+ const wsId2 = params.workspace;
2385
+ const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2386
+ if (ws) {
2387
+ const prior = this.signedIndex(ws).refused.get(signedDigest(envelope));
2388
+ if (prior) {
2389
+ return reply(envelope, { error: rpcError(
2390
+ prior.code,
2391
+ `Refused at seq ${prior.seq}; a refused request is not evaluated again`,
2392
+ { refused_at_seq: prior.seq }
2393
+ ) });
2394
+ }
2395
+ }
2396
+ }
2036
2397
  if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
2037
2398
  const sigErr = this.verifySignature(envelope);
2038
2399
  if (sigErr) return reply(envelope, { error: sigErr });
@@ -2051,7 +2412,11 @@ var Coordinator = class {
2051
2412
  const stale = this.checkStepUp(params);
2052
2413
  if (stale) return reply(envelope, { error: stale });
2053
2414
  }
2054
- if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.")) {
2415
+ {
2416
+ const gate = this.profileGate(method, params);
2417
+ if (gate) return reply(envelope, { error: gate });
2418
+ }
2419
+ if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.") || MEMBER_ONLY_METHODS.has(method)) {
2055
2420
  const wsId2 = params.workspace;
2056
2421
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2057
2422
  if (ws) {
@@ -2086,16 +2451,6 @@ var Coordinator = class {
2086
2451
  if (task) return reply(envelope, { result: { task_id: existingId, state: task.state } });
2087
2452
  }
2088
2453
  }
2089
- const handler = this.handlers.get(method);
2090
- if (!handler) {
2091
- return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
2092
- }
2093
- try {
2094
- canonicalize(envelope);
2095
- } catch (e) {
2096
- const msg = e instanceof Error ? e.message : String(e);
2097
- return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2098
- }
2099
2454
  let out;
2100
2455
  try {
2101
2456
  out = handler(params);
@@ -2113,17 +2468,56 @@ var Coordinator = class {
2113
2468
  }
2114
2469
  // -- audit ---------------------------------------------------------
2115
2470
  recordAudit(ws, envelope) {
2116
- const entry = {
2117
- seq: ws.audit.length,
2118
- arrived: this.now(),
2119
- envelope: JSON.parse(JSON.stringify(envelope))
2120
- };
2471
+ this.appendEntry(ws, { envelope: JSON.parse(JSON.stringify(envelope)) });
2472
+ }
2473
+ /**
2474
+ * SPECIFICATION 10.1: record a refused call when it is a governed attempt.
2475
+ * The caller must be a member of an existing workspace, the call must be a
2476
+ * JSON-RPC call for a method that is not a read, and the refusal must be one
2477
+ * `refusalIsRecorded` names. The request is kept as it arrived, so a
2478
+ * signature on it still verifies. Every call that reaches a recorded refusal
2479
+ * has passed the canonical check, so it can be hashed into the chain.
2480
+ */
2481
+ recordRefusal(envelope, error) {
2482
+ if (!isValidEnvelope(envelope) || typeof envelope.method !== "string") return;
2483
+ const method = envelope.method;
2484
+ if (READ_ONLY_METHODS.has(method)) return;
2485
+ if (!refusalIsRecorded(method, error, PRIVILEGED_METHODS)) return;
2486
+ const raw = envelope.params;
2487
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return;
2488
+ const params = raw;
2489
+ const ws = typeof params.workspace === "string" ? this.workspaces.get(params.workspace) : void 0;
2490
+ if (!ws || typeof params.from !== "string" || !ws.members.has(params.from)) return;
2491
+ if (isSigned(envelope)) {
2492
+ const index = this.signedIndex(ws);
2493
+ const digest = signedDigest(envelope);
2494
+ if (index.refused.has(digest) || index.accepted.has(digest)) return;
2495
+ }
2496
+ this.appendEntry(ws, {
2497
+ request: JSON.parse(JSON.stringify(envelope)),
2498
+ outcome: { status: "refused", code: error.code }
2499
+ });
2500
+ }
2501
+ /** The signed calls on this workspace's log, built from the log when missing. */
2502
+ signedIndex(ws) {
2503
+ let index = this.signedIndexes.get(ws);
2504
+ if (!index) {
2505
+ index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Set() };
2506
+ for (const entry of ws.audit) indexSigned(index, entry);
2507
+ this.signedIndexes.set(ws, index);
2508
+ }
2509
+ return index;
2510
+ }
2511
+ appendEntry(ws, record) {
2512
+ const entry = { seq: ws.audit.length, arrived: this.now(), ...record };
2121
2513
  if (ws.chain_enabled || this.options.enableChain) {
2122
2514
  const prev = ws.chain_head ?? ZERO_HASH;
2123
2515
  entry.prev_hash = prev;
2124
- ws.chain_head = linkHash(entry.envelope, prev);
2516
+ ws.chain_head = linkHash(entryRecord(entry), prev);
2125
2517
  }
2126
2518
  ws.audit.push(entry);
2519
+ const index = this.signedIndexes.get(ws);
2520
+ if (index) indexSigned(index, entry);
2127
2521
  for (const l of this.auditListeners) {
2128
2522
  try {
2129
2523
  l(ws, entry);
@@ -2146,10 +2540,14 @@ var Coordinator = class {
2146
2540
  const params = envelope.params ?? {};
2147
2541
  const sender = params.from;
2148
2542
  const wsId = params.workspace;
2149
- const ts = params.ts ?? this.now();
2150
- if (!sender || !wsId) {
2543
+ const rawTs = params.ts;
2544
+ if (typeof sender !== "string" || !sender || typeof wsId !== "string" || !wsId) {
2151
2545
  return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: missing from/workspace");
2152
2546
  }
2547
+ if (rawTs !== void 0 && rawTs !== null && typeof rawTs !== "string") {
2548
+ return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: ts must be a string");
2549
+ }
2550
+ const ts = rawTs ?? this.now();
2153
2551
  const ws = this.workspaces.get(wsId);
2154
2552
  if (!ws) {
2155
2553
  return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: unknown workspace");
@@ -2194,10 +2592,34 @@ var Coordinator = class {
2194
2592
  return null;
2195
2593
  }
2196
2594
  // -- step-up (identity-oidc/1.0) -----------------------------------
2595
+ /**
2596
+ * The SPECIFICATION 15.4 check, per method rather than per namespace.
2597
+ *
2598
+ * Core is always present, so a core method is never gated. The reads in
2599
+ * ALWAYS_AVAILABLE are never gated either: a workspace must be able to ask
2600
+ * what it is and to check its own chain, and `audit.verify_chain` belongs to
2601
+ * `audit-scitt/1.0` while chaining also turns on through an option, so
2602
+ * gating it would let a workspace write a hash-linked chain it is refused
2603
+ * permission to verify.
2604
+ */
2605
+ profileGate(method, params) {
2606
+ const owner = OWNING_PROFILE[method];
2607
+ if (owner === void 0 || owner === "core/1.0" || ALWAYS_AVAILABLE.has(method)) return void 0;
2608
+ const wsId = params.workspace;
2609
+ const ws = typeof wsId === "string" ? this.workspaces.get(wsId) : void 0;
2610
+ if (!ws) return void 0;
2611
+ const name = owner.split("/")[0];
2612
+ if (ws.profiles.some((p) => p === name || p.startsWith(name + "/"))) return void 0;
2613
+ return rpcError(
2614
+ E.METHOD,
2615
+ `Unknown method: ${method}`,
2616
+ { profile: owner, advertised: [...ws.profiles] }
2617
+ );
2618
+ }
2197
2619
  checkStepUp(params) {
2198
2620
  const wsId = params.workspace;
2199
2621
  const sender = params.from;
2200
- if (!wsId || !sender) return null;
2622
+ if (typeof wsId !== "string" || typeof sender !== "string" || !wsId || !sender) return null;
2201
2623
  const ws = this.workspaces.get(wsId);
2202
2624
  if (!ws) return null;
2203
2625
  const member = ws.members.get(sender);
@@ -2261,6 +2683,20 @@ var Coordinator = class {
2261
2683
  if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
2262
2684
  if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
2263
2685
  const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
2686
+ for (const [profile, enforced, option] of [
2687
+ ["security-signed/1.0", !!this.options.requireSignatures, "requireSignatures"],
2688
+ ["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
2689
+ ]) {
2690
+ const name = profile.split("/")[0];
2691
+ const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
2692
+ if (enforced && !advertised) profiles.push(profile);
2693
+ else if (advertised && !enforced) {
2694
+ return { error: rpcError(
2695
+ E.PARAMS,
2696
+ `${profile} is advertised but ${option} is not configured. A workspace that advertises it and does not enforce it describes a guarantee it does not give.`
2697
+ ) };
2698
+ }
2699
+ }
2264
2700
  const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
2265
2701
  const ws = {
2266
2702
  id,
@@ -2310,7 +2746,10 @@ var Coordinator = class {
2310
2746
  audit_count: ws.audit.length,
2311
2747
  task_count: ws.tasks.size,
2312
2748
  override_count: ws.overrides.size,
2313
- evidence_head: ws.chain_head,
2749
+ // Omitted where the workspace has no chain, which is what the Python
2750
+ // reference now sends too: JSON.stringify drops an undefined value, so
2751
+ // the two answered the same call differently on the wire.
2752
+ ...ws.chain_head ? { evidence_head: ws.chain_head } : {},
2314
2753
  ...ws.routing_policy_uri ? { routing_policy_uri: ws.routing_policy_uri } : {}
2315
2754
  } };
2316
2755
  }
@@ -2344,6 +2783,8 @@ var Coordinator = class {
2344
2783
  }
2345
2784
  const uri = p.from;
2346
2785
  const now = this.now();
2786
+ const existing = ws.members.get(uri);
2787
+ let tokenVerified = false;
2347
2788
  const member = {
2348
2789
  uri,
2349
2790
  type: p.type,
@@ -2358,20 +2799,41 @@ var Coordinator = class {
2358
2799
  if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
2359
2800
  const claims = this.options.verifyOidcToken(p.oidc_token);
2360
2801
  if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
2361
- member.oidc_sub = claims.sub;
2802
+ const boundUri = claims.chap_participant_uri;
2803
+ const sub = typeof claims.sub === "string" && claims.sub !== "" ? claims.sub : void 0;
2804
+ if (boundUri != null && boundUri !== uri) {
2805
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
2806
+ }
2807
+ if (existing) {
2808
+ if (existing.oidc_sub != null) {
2809
+ if (sub !== existing.oidc_sub) {
2810
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token subject does not match the member") };
2811
+ }
2812
+ } else if (boundUri !== uri) {
2813
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
2814
+ }
2815
+ }
2816
+ member.oidc_sub = sub;
2817
+ tokenVerified = true;
2362
2818
  const at = claims.auth_time;
2363
2819
  if (typeof at === "number") member.oidc_auth_time = at;
2364
- member.oidc_acr = claims.acr;
2820
+ member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
2365
2821
  const cnf = claims.cnf;
2366
2822
  const cnfJwk = cnf?.jwk;
2367
2823
  if (cnfJwk && typeof cnfJwk.kid === "string") {
2368
2824
  member.keys.push({ jwk: cnfJwk, kid: cnfJwk.kid, valid_from: now });
2369
2825
  }
2370
2826
  }
2371
- if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null) {
2827
+ if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null && !Array.isArray(p.vc_presentation)) {
2372
2828
  const subject = this.options.verifyVc(p.vc_presentation);
2373
2829
  if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
2374
- member.vc_holder = subject.holder ?? subject.id;
2830
+ const holder = [subject.holder, subject.id].find(
2831
+ (h) => typeof h === "string" && h !== ""
2832
+ );
2833
+ if (existing && (existing.vc_holder == null || holder !== existing.vc_holder)) {
2834
+ return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
2835
+ }
2836
+ member.vc_holder = holder;
2375
2837
  const vpJwk = subject.cnf_jwk;
2376
2838
  if (vpJwk && typeof vpJwk.kid === "string") {
2377
2839
  member.keys.push({ jwk: vpJwk, kid: vpJwk.kid, valid_from: now });
@@ -2386,11 +2848,11 @@ var Coordinator = class {
2386
2848
  }
2387
2849
  }
2388
2850
  }
2389
- const existing = ws.members.get(uri);
2390
2851
  if (existing) {
2391
2852
  if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
2392
2853
  if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
2393
2854
  if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
2855
+ if (tokenVerified) existing.oidc_acr = member.oidc_acr;
2394
2856
  if (member.vc_holder !== void 0) existing.vc_holder = member.vc_holder;
2395
2857
  for (const k of attested) {
2396
2858
  if (!existing.keys.some((x) => x.kid === k.kid)) existing.keys.push(k);
@@ -2414,12 +2876,15 @@ var Coordinator = class {
2414
2876
  const notMember = this.requireMember(ws, p.from);
2415
2877
  if (notMember) return notMember;
2416
2878
  const assignee = p.assignee || p.to;
2417
- if (!assignee || !ws.members.has(assignee)) {
2879
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
2418
2880
  return { error: rpcError(E.PARAMS, "Assignee not in workspace") };
2419
2881
  }
2420
2882
  if (ws.members.get(assignee).paused) {
2421
2883
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
2422
2884
  }
2885
+ if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
2886
+ return { error: rpcError(E.PARAMS, "mode must be a string") };
2887
+ }
2423
2888
  const requestedMode = p.mode || ws.mode;
2424
2889
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
2425
2890
  return { error: rpcError(
@@ -2427,6 +2892,9 @@ var Coordinator = class {
2427
2892
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
2428
2893
  ) };
2429
2894
  }
2895
+ if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
2896
+ return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
2897
+ }
2430
2898
  const taskId = this.ids.taskId();
2431
2899
  const now = this.now();
2432
2900
  const task = {
@@ -2463,10 +2931,10 @@ var Coordinator = class {
2463
2931
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2464
2932
  const newState = p.state;
2465
2933
  const legal = {
2466
- created: ["in_progress", "declined", "paused"],
2467
- in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
2934
+ created: ["in_progress", "declined"],
2935
+ in_progress: ["in_progress", "completed", "declined"],
2468
2936
  review_requested: ["in_progress"],
2469
- paused: ["in_progress", "cancelled"]
2937
+ paused: ["cancelled"]
2470
2938
  };
2471
2939
  if (!legal[task.state]?.includes(newState)) {
2472
2940
  return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
@@ -2499,7 +2967,6 @@ var Coordinator = class {
2499
2967
  }
2500
2968
  if (task.review_required) {
2501
2969
  const now = this.now();
2502
- task.pending_artefact = p.output;
2503
2970
  if (!task.review) {
2504
2971
  const producer = p.from;
2505
2972
  const eligible = [...ws.members.entries()].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
@@ -2511,6 +2978,7 @@ var Coordinator = class {
2511
2978
  }
2512
2979
  task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
2513
2980
  }
2981
+ task.pending_artefact = p.output;
2514
2982
  task.state = "review_requested";
2515
2983
  task.updated_at = now;
2516
2984
  task.history.push({
@@ -2537,23 +3005,39 @@ var Coordinator = class {
2537
3005
  }
2538
3006
  const range = p.range ?? {};
2539
3007
  const filter = p.filter ?? {};
3008
+ if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
3009
+ return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
3010
+ }
2540
3011
  const fromSeq = range.from_seq ?? 0;
2541
3012
  const toSeq = range.to_seq ?? ws.audit.length;
2542
3013
  const out = [];
2543
3014
  for (const entry of ws.audit.slice(fromSeq, toSeq)) {
2544
- const ep = entry.envelope.params ?? {};
2545
- if (filter.method && entry.envelope.method !== filter.method) continue;
3015
+ const refused = entry.outcome != null;
3016
+ if (filter.outcome === "accepted" && refused) continue;
3017
+ if (filter.outcome === "refused" && !refused) continue;
3018
+ const call2 = entryCall(entry) ?? {};
3019
+ const ep = call2.params ?? {};
3020
+ if (filter.method && call2.method !== filter.method) continue;
2546
3021
  if (filter.from && ep.from !== filter.from) continue;
2547
- if (filter.task_id && ep.task_id !== filter.task_id) continue;
2548
- const item = {
2549
- seq: entry.seq,
2550
- arrived: entry.arrived,
2551
- envelope: entry.envelope
2552
- };
3022
+ if (filter.task_id) {
3023
+ let taskId = ep.task_id;
3024
+ if (call2.method === "whisper.answer") {
3025
+ const prompt = ws.whispers.get(ep.whisper_id);
3026
+ taskId = prompt ? prompt.task_id : void 0;
3027
+ }
3028
+ if (taskId !== filter.task_id) continue;
3029
+ }
3030
+ const item = { seq: entry.seq, arrived: entry.arrived };
3031
+ if (refused) {
3032
+ item.request = entry.request;
3033
+ item.outcome = entry.outcome;
3034
+ } else {
3035
+ item.envelope = entry.envelope;
3036
+ }
2553
3037
  if (entry.prev_hash) item.prev_hash = entry.prev_hash;
2554
3038
  out.push(item);
2555
3039
  }
2556
- return { result: { entries: out, next_seq: toSeq } };
3040
+ return { result: { entries: out, next_seq: Math.min(toSeq, ws.audit.length) } };
2557
3041
  }
2558
3042
  // ==========================================================
2559
3043
  // review/1.0 handlers
@@ -2778,16 +3262,34 @@ var Coordinator = class {
2778
3262
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2779
3263
  const notMember = this.requireMember(ws, p.from);
2780
3264
  if (notMember) return notMember;
2781
- const orig = ws.tasks.get(p.original_task_id);
3265
+ const origId = p.original_task_id;
3266
+ const orig = typeof origId === "string" ? ws.tasks.get(origId) : void 0;
2782
3267
  if (!orig) return { error: rpcError(E.PARAMS, "Unknown original task") };
2783
3268
  if (orig.state === "completed" || orig.state === "cancelled" || orig.state === "superseded") {
2784
3269
  return { error: rpcError(E.PARAMS, `Cannot escalate a terminal task: ${orig.state}`) };
2785
3270
  }
3271
+ if (p.new_task === null || typeof p.new_task !== "object" || Array.isArray(p.new_task)) {
3272
+ return { error: rpcError(E.PARAMS, "new_task must be an object") };
3273
+ }
2786
3274
  const nt = p.new_task;
2787
3275
  const assignee = nt.assignee;
2788
- if (!assignee || !ws.members.has(assignee)) {
3276
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
2789
3277
  return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
2790
3278
  }
3279
+ const named = nt.mode;
3280
+ if (named !== void 0 && named !== null && typeof named !== "string") {
3281
+ return { error: rpcError(E.PARAMS, "new_task.mode must be a string") };
3282
+ }
3283
+ if (ws.members.get(assignee).paused) {
3284
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
3285
+ }
3286
+ const mode = named || orig.mode;
3287
+ if (!modeLE(mode, ws.mode_ceiling)) {
3288
+ return { error: rpcError(
3289
+ E.MODE_CEILING_EXCEEDED,
3290
+ `Requested mode ${mode} exceeds ceiling ${ws.mode_ceiling}`
3291
+ ) };
3292
+ }
2791
3293
  const now = this.now();
2792
3294
  const newId = this.ids.taskId();
2793
3295
  const newTask = {
@@ -2799,11 +3301,14 @@ var Coordinator = class {
2799
3301
  input: nt.input ?? {},
2800
3302
  created_at: now,
2801
3303
  updated_at: now,
2802
- mode: orig.mode,
3304
+ mode,
2803
3305
  supersedes: orig.id,
2804
3306
  history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
2805
3307
  paused: false
2806
3308
  };
3309
+ if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
3310
+ newTask.review_required = true;
3311
+ }
2807
3312
  ws.tasks.set(newId, newTask);
2808
3313
  orig.state = "escalated";
2809
3314
  orig.updated_at = now;
@@ -2944,11 +3449,13 @@ function wrapMcpToolCall(coord, workspace, options) {
2944
3449
  method: "task.update",
2945
3450
  params: { workspace, from: options.caller, task_id: taskId, state: "in_progress" }
2946
3451
  });
3452
+ const output = { result: options.result, citations };
3453
+ if (options.fulfils) output.fulfils = options.fulfils;
2947
3454
  const completeParams = {
2948
3455
  workspace,
2949
3456
  from: options.caller,
2950
3457
  task_id: taskId,
2951
- output: { result: options.result, citations }
3458
+ output
2952
3459
  };
2953
3460
  if (options.confidence !== void 0) completeParams.confidence = options.confidence;
2954
3461
  const completeResp = coord.dispatch({
@@ -3000,11 +3507,13 @@ function wrapA2aMessageExchange(coord, workspace, options) {
3000
3507
  state: "in_progress"
3001
3508
  }
3002
3509
  });
3510
+ const output = { received: options.received, citations: [citation] };
3511
+ if (options.fulfils) output.fulfils = options.fulfils;
3003
3512
  const completeParams = {
3004
3513
  workspace,
3005
3514
  from: options.bridgeUri,
3006
3515
  task_id: taskId,
3007
- output: { received: options.received, citations: [citation] }
3516
+ output
3008
3517
  };
3009
3518
  if (options.confidence !== void 0) completeParams.confidence = options.confidence;
3010
3519
  const completeResp = coord.dispatch({
@@ -3033,8 +3542,12 @@ function wrapA2aMessageExchange(coord, workspace, options) {
3033
3542
  deriveKeypair,
3034
3543
  deriveSeed,
3035
3544
  diffJsonPatch,
3545
+ entryCall,
3546
+ entryRecord,
3547
+ isRefusal,
3036
3548
  isValidEnvelope,
3037
3549
  jwkFromPrivateKey,
3550
+ linkHash,
3038
3551
  makeApi,
3039
3552
  makeDefaultPolicy,
3040
3553
  modeLE,