@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.js CHANGED
@@ -3,7 +3,21 @@ import { createHash } from "crypto";
3
3
  var ZERO_HASH = "sha256:" + "0".repeat(64);
4
4
  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.';
5
5
  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.";
6
+ var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
7
+ function assertNoLoneSurrogate(s) {
8
+ for (let i = 0; i < s.length; i++) {
9
+ const c = s.charCodeAt(i);
10
+ if (c >= 55296 && c <= 56319) {
11
+ const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0;
12
+ if (next < 56320 || next > 57343) throw new Error(LONE_SURROGATE_ERROR);
13
+ i++;
14
+ } else if (c >= 56320 && c <= 57343) {
15
+ throw new Error(LONE_SURROGATE_ERROR);
16
+ }
17
+ }
18
+ }
6
19
  function canonicalString(s) {
20
+ assertNoLoneSurrogate(s);
7
21
  return JSON.stringify(s);
8
22
  }
9
23
  function canonicalNumber(n) {
@@ -52,6 +66,55 @@ function contentHash(content) {
52
66
  return sha256Hex(canonicalize(content));
53
67
  }
54
68
 
69
+ // src/catalogue.ts
70
+ var OWNING_PROFILE = Object.freeze({
71
+ "abstain.declare": "review/1.0",
72
+ "audit.read": "core/1.0",
73
+ "audit.submit_to_scitt": "audit-scitt/1.0",
74
+ "audit.verify_chain": "audit-scitt/1.0",
75
+ "audit.verify_receipt": "audit-scitt/1.0",
76
+ "control.cancel": "control/1.0",
77
+ "control.pause": "control/1.0",
78
+ "control.resume": "control/1.0",
79
+ "control.rollback": "control/1.0",
80
+ "control.set_mode_ceiling": "control/1.0",
81
+ "control.snapshot": "control/1.0",
82
+ "control.supersede": "control/1.0",
83
+ "decide.approve": "review/1.0",
84
+ "decide.override": "review/1.0",
85
+ "decide.reject": "review/1.0",
86
+ "deliberate.close": "deliberation/1.0",
87
+ "deliberate.comment": "deliberation/1.0",
88
+ "deliberate.open": "deliberation/1.0",
89
+ "deliberate.vote": "deliberation/1.0",
90
+ "escalate.auto": "routing/1.0",
91
+ "escalate.raise": "review/1.0",
92
+ "handoff.accept": "handoff/1.0",
93
+ "handoff.decline": "handoff/1.0",
94
+ "handoff.propose": "handoff/1.0",
95
+ "participant.join": "core/1.0",
96
+ "participant.leave": "core/1.0",
97
+ "participant.revoke_key": "core/1.0",
98
+ "participant.rotate_key": "core/1.0",
99
+ "review.depth": "routing/1.0",
100
+ "review.request": "review/1.0",
101
+ "task.complete": "core/1.0",
102
+ "task.create": "core/1.0",
103
+ "task.route": "routing/1.0",
104
+ "task.update": "core/1.0",
105
+ "whisper.answer": "whisper/1.0",
106
+ "whisper.ask": "whisper/1.0",
107
+ "workspace.create": "core/1.0",
108
+ "workspace.describe": "core/1.0",
109
+ "workspace.set_profiles": "core/1.0"
110
+ });
111
+ var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
112
+ "workspace.describe",
113
+ "audit.read",
114
+ "audit.verify_chain",
115
+ "audit.verify_receipt"
116
+ ]);
117
+
55
118
  // src/crypto.ts
56
119
  import {
57
120
  createPrivateKey,
@@ -271,11 +334,84 @@ function isValidEnvelope(env) {
271
334
  return e.jsonrpc === "2.0";
272
335
  }
273
336
 
337
+ // src/audit.ts
338
+ function entryRecord(entry) {
339
+ if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
340
+ return entry.envelope;
341
+ }
342
+ function entryCall(entry) {
343
+ return entry.envelope ?? entry.request ?? void 0;
344
+ }
345
+ function isRefusal(entry) {
346
+ return entry.outcome != null;
347
+ }
348
+ function isCall(value) {
349
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
350
+ const v = value;
351
+ return v.jsonrpc === "2.0" && typeof v.method === "string";
352
+ }
353
+ function entryIsWellFormed(entry) {
354
+ const accepted = entry.envelope != null;
355
+ const refused = entry.request != null;
356
+ if (accepted === refused) return false;
357
+ if (accepted) return isCall(entry.envelope) && entry.outcome == null;
358
+ const o = entry.outcome;
359
+ return isCall(entry.request) && o != null && typeof o === "object" && o.status === "refused" && Number.isInteger(o.code);
360
+ }
361
+ function isSigned(call2) {
362
+ return call2 !== null && typeof call2 === "object" && typeof call2.sig === "string";
363
+ }
364
+ function signedDigest(call2) {
365
+ const signed = { ...call2 };
366
+ delete signed.sig;
367
+ return sha256Hex(canonicalize(signed));
368
+ }
369
+ function linkHash(record, prev) {
370
+ return sha256Hex(Buffer.concat([canonicalize(record), Buffer.from(prev, "utf-8")]));
371
+ }
372
+ var UNRECORDED_CODES = /* @__PURE__ */ new Set([
373
+ E.PARSE,
374
+ E.REQUEST,
375
+ E.PARAMS,
376
+ E.INTERNAL,
377
+ E.SIG_VERIFY_FAILED,
378
+ E.SIG_KEY_NOT_FOUND,
379
+ E.SIG_KEY_REVOKED,
380
+ E.SIG_ROTATION_KEY_MISMATCH
381
+ ]);
382
+ var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
383
+ function refusalIsRecorded(method, error, privileged) {
384
+ if (!Number.isInteger(error.code)) return false;
385
+ if (UNRECORDED_METHODS.has(method)) return false;
386
+ if (UNRECORDED_CODES.has(error.code)) return false;
387
+ if (error.code === E.METHOD) {
388
+ const data = error.data;
389
+ return typeof data?.profile === "string" && privileged.has(method);
390
+ }
391
+ return true;
392
+ }
393
+
274
394
  // src/patch.ts
275
395
  var PatchError = class extends Error {
276
396
  };
277
397
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
278
398
  var ARRAY_INDEX_RE = /^(0|[1-9][0-9]*)$/;
399
+ function jsonType(value) {
400
+ if (value === null) return "null";
401
+ if (Array.isArray(value)) return "array";
402
+ switch (typeof value) {
403
+ case "object":
404
+ return "object";
405
+ case "string":
406
+ return "string";
407
+ case "number":
408
+ return "number";
409
+ case "boolean":
410
+ return "boolean";
411
+ default:
412
+ return "null";
413
+ }
414
+ }
279
415
  function arrayIndex(seg) {
280
416
  if (!ARRAY_INDEX_RE.test(seg)) throw new PatchError(`Array index expected at ${JSON.stringify(seg)}`);
281
417
  return Number(seg);
@@ -315,7 +451,7 @@ function navigate(doc, parts) {
315
451
  }
316
452
  parent = parent[part];
317
453
  } else {
318
- throw new PatchError(`Cannot traverse into ${typeof parent} at ${JSON.stringify(part)}`);
454
+ throw new PatchError(`Cannot traverse into ${jsonType(parent)} at ${JSON.stringify(part)}`);
319
455
  }
320
456
  }
321
457
  const last = parts[parts.length - 1];
@@ -385,7 +521,7 @@ function applyOne(doc, op) {
385
521
  } else if (parent !== null && typeof parent === "object") {
386
522
  parent[key] = op.value;
387
523
  } else {
388
- throw new PatchError(`Cannot add into ${typeof parent}`);
524
+ throw new PatchError(`Cannot add into ${jsonType(parent)}`);
389
525
  }
390
526
  return doc;
391
527
  }
@@ -395,7 +531,7 @@ function applyOne(doc, op) {
395
531
  if (parts.length === 0) return op.value;
396
532
  const [parent, key] = navigate(doc, parts);
397
533
  if (Array.isArray(parent)) {
398
- const idx = key;
534
+ const idx = key === "-" ? -1 : key;
399
535
  if (idx < 0 || idx >= parent.length) {
400
536
  throw new PatchError(`Path not found for replace: ${path}`);
401
537
  }
@@ -406,7 +542,7 @@ function applyOne(doc, op) {
406
542
  }
407
543
  parent[key] = op.value;
408
544
  } else {
409
- throw new PatchError(`Cannot replace in ${typeof parent}`);
545
+ throw new PatchError(`Cannot replace in ${jsonType(parent)}`);
410
546
  }
411
547
  return doc;
412
548
  }
@@ -415,7 +551,7 @@ function applyOne(doc, op) {
415
551
  if (parts.length === 0) throw new PatchError("Cannot remove root.");
416
552
  const [parent, key] = navigate(doc, parts);
417
553
  if (Array.isArray(parent)) {
418
- const idx = key;
554
+ const idx = key === "-" ? -1 : key;
419
555
  if (idx < 0 || idx >= parent.length) {
420
556
  throw new PatchError(`Index out of range for remove: ${path}`);
421
557
  }
@@ -426,7 +562,7 @@ function applyOne(doc, op) {
426
562
  }
427
563
  delete parent[key];
428
564
  } else {
429
- throw new PatchError(`Cannot remove from ${typeof parent}`);
565
+ throw new PatchError(`Cannot remove from ${jsonType(parent)}`);
430
566
  }
431
567
  return doc;
432
568
  }
@@ -634,7 +770,8 @@ function modeLE(a, ceiling) {
634
770
  }
635
771
 
636
772
  // src/profiles/audit_scitt.ts
637
- function buildStatement(workspaceId, envelope, issuer) {
773
+ var MALFORMED = "sha256:malformed";
774
+ function buildStatement(workspaceId, record, issuer) {
638
775
  return {
639
776
  protected: {
640
777
  alg: -8,
@@ -642,9 +779,9 @@ function buildStatement(workspaceId, envelope, issuer) {
642
779
  iss: issuer,
643
780
  kid: "scitt-issuer",
644
781
  cwt_claims: { sub: workspaceId, iat: null },
645
- "content-type": "application/chap+json;version=0.2"
782
+ "content-type": "application/chap+json;version=0.3"
646
783
  },
647
- payload: canonicalize(envelope).toString("utf-8"),
784
+ payload: canonicalize(record).toString("utf-8"),
648
785
  signature: "<deployment-supplied>"
649
786
  };
650
787
  }
@@ -657,7 +794,7 @@ function registerAuditScitt(coord) {
657
794
  const toSeq = range.to_seq ?? ws.audit.length;
658
795
  const issuer = p.issuer ?? "service:coordinator";
659
796
  if (!coord.options.scittSubmitter) {
660
- const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, e.envelope, issuer));
797
+ const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
661
798
  return { result: {
662
799
  statements,
663
800
  note: "No scittSubmitter configured; submit these out-of-band"
@@ -665,7 +802,7 @@ function registerAuditScitt(coord) {
665
802
  }
666
803
  const receipts = [];
667
804
  for (const entry of ws.audit.slice(fromSeq, toSeq)) {
668
- const statement = buildStatement(ws.id, entry.envelope, issuer);
805
+ const statement = buildStatement(ws.id, entryRecord(entry), issuer);
669
806
  let receipt;
670
807
  try {
671
808
  receipt = coord.options.scittSubmitter(statement);
@@ -717,12 +854,20 @@ function registerAuditScitt(coord) {
717
854
  if (start < 0) start = ws.audit.length;
718
855
  const errors = [];
719
856
  let prev = ZERO_HASH;
857
+ let resync = false;
720
858
  for (const e of ws.audit.slice(start)) {
721
- const expectedPrev = prev;
859
+ const expectedPrev = resync && typeof e.prev_hash === "string" ? e.prev_hash : prev;
860
+ resync = false;
722
861
  if (e.prev_hash !== expectedPrev) {
723
862
  errors.push(`seq ${e.seq}: prev_hash mismatch`);
724
863
  }
725
- prev = sha256Hex(Buffer.concat([canonicalize(e.envelope), Buffer.from(expectedPrev, "utf-8")]));
864
+ if (!entryIsWellFormed(e)) {
865
+ errors.push(`seq ${e.seq}: malformed entry`);
866
+ prev = MALFORMED;
867
+ resync = true;
868
+ continue;
869
+ }
870
+ prev = linkHash(entryRecord(e), expectedPrev);
726
871
  }
727
872
  const storedHead = ws.chain_head === void 0 || ws.chain_head === null ? ZERO_HASH : ws.chain_head;
728
873
  if (prev !== storedHead) {
@@ -777,6 +922,7 @@ function registerControl(coord) {
777
922
  }
778
923
  task.paused = true;
779
924
  const prior = task.state;
925
+ if (prior !== "paused") task.paused_from = prior;
780
926
  task.state = "paused";
781
927
  task.updated_at = coord.now();
782
928
  task.history.push({ ts: task.updated_at, from: p.from, state: "paused", note: `was ${prior}` });
@@ -810,10 +956,12 @@ function registerControl(coord) {
810
956
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot resume ${task.state} task`) };
811
957
  }
812
958
  task.paused = false;
813
- task.state = "in_progress";
959
+ const restored = task.paused_from ?? "in_progress";
960
+ task.paused_from = void 0;
961
+ task.state = restored;
814
962
  task.updated_at = coord.now();
815
- task.history.push({ ts: task.updated_at, from: p.from, state: "in_progress" });
816
- return { result: { scope: "task", task_id: task.id, state: "in_progress" } };
963
+ task.history.push({ ts: task.updated_at, from: p.from, state: restored });
964
+ return { result: { scope: "task", task_id: task.id, state: restored } };
817
965
  }
818
966
  if (scope === "participant") {
819
967
  const uri = p.participant_uri || p.uri;
@@ -840,36 +988,56 @@ function registerControl(coord) {
840
988
  coord.handlers.set("control.snapshot", (p) => {
841
989
  const ws = coord.workspaces.get(p.workspace);
842
990
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
843
- const include = p.include ?? ["members", "open_tasks", "mode_ceiling"];
991
+ if (Array.isArray(p.include) && p.include.length === 0) {
992
+ return { error: rpcError(E.PARAMS, "include must not be empty") };
993
+ }
994
+ const include = Array.isArray(p.include) ? [...p.include] : ["members", "open_tasks", "mode_ceiling"];
844
995
  const state = {};
845
996
  if (include.includes("members")) {
846
- state.members = Array.from(ws.members.values()).map((m) => ({
997
+ const members = Array.from(ws.members.values()).map((m) => ({
847
998
  uri: m.uri,
848
999
  type: m.type,
849
- role: m.role,
850
- scopes: m.scopes
1000
+ ...m.role ? { role: m.role } : {},
1001
+ ...m.scopes?.length ? { scopes: m.scopes } : {}
851
1002
  }));
1003
+ if (members.length) state.members = members;
852
1004
  }
853
1005
  if (include.includes("open_tasks")) {
854
- 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 }));
1006
+ const tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled" && t.state !== "superseded").map((t) => ({
1007
+ id: t.id,
1008
+ kind: t.kind,
1009
+ state: t.state,
1010
+ ...t.assignee ? { assignee: t.assignee } : {}
1011
+ }));
1012
+ if (tasks.length) state.open_tasks = tasks;
855
1013
  }
856
1014
  if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
857
- if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
1015
+ if (include.includes("policy") && ws.routing_policy_uri) state.routing_policy_uri = ws.routing_policy_uri;
858
1016
  if (include.includes("audit") || include.includes("all")) state.audit_seq = ws.audit.length;
1017
+ const content = structuredClone({
1018
+ workspace: ws.id,
1019
+ audit_seq: ws.audit.length,
1020
+ include,
1021
+ state,
1022
+ ...p.label ? { label: p.label } : {}
1023
+ });
859
1024
  const snapId = coord.ids.artefactId();
860
1025
  const snap = {
861
1026
  id: snapId,
862
1027
  kind: "snapshot",
863
- ts: coord.now(),
864
- by: p.from,
865
- workspace: ws.id,
866
- audit_seq: ws.audit.length,
867
- label: p.label,
868
- include,
869
- state
1028
+ produced_at: coord.now(),
1029
+ produced_by: p.from,
1030
+ content_hash: contentHash(content),
1031
+ content
870
1032
  };
871
1033
  ws.snapshots.set(snapId, snap);
872
- return { result: { snapshot_artefact_id: snapId, audit_seq: snap.audit_seq, artefact: snap } };
1034
+ return {
1035
+ result: {
1036
+ snapshot_artefact_id: snapId,
1037
+ audit_seq: snap.content.audit_seq,
1038
+ artefact: structuredClone(snap)
1039
+ }
1040
+ };
873
1041
  });
874
1042
  coord.handlers.set("control.rollback", (p) => {
875
1043
  const ws = coord.workspaces.get(p.workspace);
@@ -878,31 +1046,34 @@ function registerControl(coord) {
878
1046
  if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
879
1047
  const snap = ws.snapshots.get(snapId);
880
1048
  if (!snap) return { error: rpcError(E.CONTROL_SNAPSHOT_NOT_FOUND, `Unknown snapshot: ${snapId}`) };
881
- const what = p.what_to_restore ?? snap.include;
1049
+ if (Array.isArray(p.what_to_restore) && p.what_to_restore.length === 0) {
1050
+ return { error: rpcError(E.PARAMS, "what_to_restore must not be empty") };
1051
+ }
1052
+ const what = p.what_to_restore ?? snap.content.include;
882
1053
  const restored = [];
883
- if (what.includes("mode_ceiling") && snap.state.mode_ceiling) {
884
- ws.mode_ceiling = snap.state.mode_ceiling;
1054
+ if (what.includes("mode_ceiling") && snap.content.state.mode_ceiling) {
1055
+ ws.mode_ceiling = snap.content.state.mode_ceiling;
885
1056
  restored.push("mode_ceiling");
886
1057
  }
887
- if (what.includes("members") && Array.isArray(snap.state.members)) {
1058
+ if (what.includes("members") && Array.isArray(snap.content.state.members)) {
888
1059
  const snapByUri = /* @__PURE__ */ new Map();
889
- for (const m of snap.state.members) {
1060
+ for (const m of snap.content.state.members) {
890
1061
  snapByUri.set(m.uri, m);
891
1062
  }
892
1063
  for (const [uri, snapM] of snapByUri) {
893
1064
  if (ws.members.has(uri)) {
894
1065
  const cur = ws.members.get(uri);
895
1066
  cur.role = snapM.role ?? cur.role;
896
- cur.scopes = snapM.scopes;
1067
+ cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
897
1068
  }
898
1069
  }
899
1070
  restored.push("members");
900
1071
  }
901
1072
  return { result: {
902
1073
  rolled_back_to: snapId,
903
- audit_seq: snap.audit_seq,
1074
+ audit_seq: snap.content.audit_seq,
904
1075
  restored,
905
- reason: p.reason
1076
+ ...p.reason ? { reason: p.reason } : {}
906
1077
  } };
907
1078
  });
908
1079
  coord.handlers.set("control.supersede", (p) => {
@@ -916,16 +1087,25 @@ function registerControl(coord) {
916
1087
  return { error: rpcError(E.PARAMS, "successor_task must include kind") };
917
1088
  }
918
1089
  const assignee = newSpec.assignee || old.assignee;
919
- if (!ws.members.has(assignee)) {
1090
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
920
1091
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
921
1092
  }
922
- const requestedMode = newSpec.mode ?? old.mode;
1093
+ if (ws.members.get(assignee).paused) {
1094
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
1095
+ }
1096
+ if (newSpec.mode !== void 0 && newSpec.mode !== null && typeof newSpec.mode !== "string") {
1097
+ return { error: rpcError(E.PARAMS, "successor_task.mode must be a string") };
1098
+ }
1099
+ const requestedMode = newSpec.mode || old.mode;
923
1100
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
924
1101
  return { error: rpcError(
925
1102
  E.MODE_CEILING_EXCEEDED,
926
1103
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
927
1104
  ) };
928
1105
  }
1106
+ if (newSpec.review_required !== void 0 && newSpec.review_required !== null && typeof newSpec.review_required !== "boolean") {
1107
+ return { error: rpcError(E.PARAMS, "successor_task.review_required must be a boolean") };
1108
+ }
929
1109
  const now = coord.now();
930
1110
  const newId = coord.ids.taskId();
931
1111
  const newTask = {
@@ -947,8 +1127,11 @@ function registerControl(coord) {
947
1127
  }],
948
1128
  paused: false
949
1129
  };
950
- if (ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") newTask.review_required = true;
951
- else if ("review_required" in newSpec) newTask.review_required = !!newSpec.review_required;
1130
+ if (old.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
1131
+ newTask.review_required = true;
1132
+ } else if ("review_required" in newSpec) {
1133
+ newTask.review_required = !!newSpec.review_required;
1134
+ }
952
1135
  ws.tasks.set(newId, newTask);
953
1136
  old.state = "superseded";
954
1137
  old.superseded_by = newId;
@@ -1245,8 +1428,14 @@ function registerHandoff(coord) {
1245
1428
  ) };
1246
1429
  }
1247
1430
  }
1431
+ if (ws.members.get(acceptor).paused) {
1432
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
1433
+ }
1434
+ if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
1435
+ return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
1436
+ }
1248
1437
  const validIds = new Set(ho.tasks.map((t) => t.task_id));
1249
- const acceptedIds = Array.isArray(p.accepted_task_ids) ? p.accepted_task_ids : ho.tasks.map((t) => t.task_id);
1438
+ const acceptedIds = Array.isArray(p.accepted_task_ids) ? [...p.accepted_task_ids] : ho.tasks.map((t) => t.task_id);
1250
1439
  for (const tid of acceptedIds) {
1251
1440
  if (!validIds.has(tid)) {
1252
1441
  return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
@@ -1317,6 +1506,9 @@ function registerRouting(coord) {
1317
1506
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1318
1507
  const task = ws.tasks.get(p.task_id);
1319
1508
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
1509
+ if (p.candidates !== void 0 && p.candidates !== null && !Array.isArray(p.candidates)) {
1510
+ return { error: rpcError(E.PARAMS, "candidates must be a list") };
1511
+ }
1320
1512
  const candidates = p.candidates ?? [];
1321
1513
  if (!candidates.length) {
1322
1514
  return { error: rpcError(E.ROUTING_CANDIDATES_EMPTY, "candidates array was empty") };
@@ -1333,19 +1525,29 @@ function registerRouting(coord) {
1333
1525
  return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `routing policy error: ${msg}`) };
1334
1526
  }
1335
1527
  } else {
1336
- const eligible = candidates.filter((c) => ws.members.has(c));
1528
+ const memberOf = (c) => typeof c === "string" ? ws.members.get(c) : void 0;
1529
+ const eligible = candidates.filter((c) => {
1530
+ const m = memberOf(c);
1531
+ return !!m && !m.paused;
1532
+ });
1337
1533
  if (!eligible.length) {
1338
- return { error: rpcError(E.ROUTING_NO_ELIGIBLE_ASSIGNEE, "No candidate is a workspace member") };
1534
+ return { error: rpcError(E.ROUTING_NO_ELIGIBLE_ASSIGNEE, "No candidate is a workspace member who is not paused") };
1339
1535
  }
1340
1536
  selected = eligible[0];
1537
+ const alternatives = [];
1538
+ let chosen = false;
1539
+ for (const c of candidates) {
1540
+ const m = memberOf(c);
1541
+ if (!m) continue;
1542
+ if (m.paused) alternatives.push({ candidate: c, reason_excluded: "paused" });
1543
+ else if (!chosen) chosen = true;
1544
+ else alternatives.push({ candidate: c, reason_excluded: "not first eligible" });
1545
+ }
1341
1546
  rationale = {
1342
1547
  policy_id: "default",
1343
1548
  hints_used: Object.keys(task.routing_hints ?? {}),
1344
1549
  summary: "default policy: first eligible candidate",
1345
- alternatives_considered: eligible.slice(1).map((c) => ({
1346
- candidate: c,
1347
- reason_excluded: "not first eligible"
1348
- }))
1550
+ alternatives_considered: alternatives
1349
1551
  };
1350
1552
  }
1351
1553
  if (!ws.members.has(selected)) {
@@ -1354,6 +1556,9 @@ function registerRouting(coord) {
1354
1556
  `Selected ${JSON.stringify(selected)} is not a member`
1355
1557
  ) };
1356
1558
  }
1559
+ if (ws.members.get(selected).paused) {
1560
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
1561
+ }
1357
1562
  task.assignee = selected;
1358
1563
  task.updated_at = coord.now();
1359
1564
  task.history.push({
@@ -1471,6 +1676,18 @@ function registerRouting(coord) {
1471
1676
  hints_used: ["criticality", "confidence"]
1472
1677
  };
1473
1678
  }
1679
+ if (escalate) {
1680
+ if (!to) return { error: rpcError(
1681
+ E.ROUTING_ESC_TARGET_UNAVAILABLE,
1682
+ "Escalation triggered but no target available"
1683
+ ) };
1684
+ if (!to.startsWith("group:") && !ws.members.has(to)) {
1685
+ return { error: rpcError(
1686
+ E.ROUTING_ESC_TARGET_UNAVAILABLE,
1687
+ `Escalation target ${to} is not a member or group`
1688
+ ) };
1689
+ }
1690
+ }
1474
1691
  const artId = coord.ids.artefactId();
1475
1692
  const artefact = {
1476
1693
  id: artId,
@@ -1487,17 +1704,7 @@ function registerRouting(coord) {
1487
1704
  };
1488
1705
  ws.route_decisions.set(artId, artefact);
1489
1706
  if (escalate) {
1490
- if (!to) return { error: rpcError(
1491
- E.ROUTING_ESC_TARGET_UNAVAILABLE,
1492
- "Escalation triggered but no target available"
1493
- ) };
1494
- if (!to.startsWith("group:") && !ws.members.has(to)) {
1495
- return { error: rpcError(
1496
- E.ROUTING_ESC_TARGET_UNAVAILABLE,
1497
- `Escalation target ${to} is not a member or group`
1498
- ) };
1499
- }
1500
- if (coord.options.onAutoEscalate) {
1707
+ if (coord.options.onAutoEscalate && to) {
1501
1708
  try {
1502
1709
  coord.options.onAutoEscalate(task, to);
1503
1710
  } catch {
@@ -1567,6 +1774,12 @@ function registerSecuritySigned(coord) {
1567
1774
  function parseIso(ts) {
1568
1775
  return new Date(ts).getTime();
1569
1776
  }
1777
+ function present(v) {
1778
+ if (v === void 0 || v === null || v === false || v === 0 || v === "") return false;
1779
+ if (Array.isArray(v)) return v.length > 0;
1780
+ if (typeof v === "object") return Object.keys(v).length > 0;
1781
+ return true;
1782
+ }
1570
1783
  function registerWhisper(coord) {
1571
1784
  coord.handlers.set("whisper.ask", (p) => {
1572
1785
  const ws = coord.workspaces.get(p.workspace);
@@ -1577,6 +1790,9 @@ function registerWhisper(coord) {
1577
1790
  if (!ws.tasks.has(p.task_id)) {
1578
1791
  return { error: rpcError(E.PARAMS, "Unknown task") };
1579
1792
  }
1793
+ if (p.options !== void 0 && p.options !== null && !Array.isArray(p.options)) {
1794
+ return { error: rpcError(E.PARAMS, "options must be a list") };
1795
+ }
1580
1796
  const to = p.to;
1581
1797
  const askee = Array.isArray(to) ? to : [to];
1582
1798
  const id = p.whisper_id || coord.ids.artefactId();
@@ -1620,22 +1836,21 @@ function registerWhisper(coord) {
1620
1836
  return { error: rpcError(E.WHISPER_LAPSED, "Whisper already lapsed") };
1621
1837
  }
1622
1838
  const answerOption = p.answer_option;
1623
- const answerText = p.answer ?? p.answer_text;
1839
+ const hasOption = answerOption !== void 0 && answerOption !== null;
1840
+ const answerText = present(p.answer) ? p.answer : p.answer_text;
1624
1841
  if (prompt.options && prompt.options.length) {
1625
- if (answerOption === void 0) {
1842
+ if (!hasOption) {
1626
1843
  return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
1627
1844
  }
1628
1845
  const valid = new Set(prompt.options.map((o) => o.id));
1629
1846
  if (!valid.has(answerOption)) {
1630
1847
  return { error: rpcError(
1631
1848
  E.WHISPER_OPTION_NOT_IN_SET,
1632
- `Answer option ${JSON.stringify(answerOption)} not in option set`
1849
+ `Answer option ${canonicalize(answerOption).toString("utf-8")} not in option set`
1633
1850
  ) };
1634
1851
  }
1635
- } else {
1636
- if (answerText === void 0 && answerOption === void 0) {
1637
- return { error: rpcError(E.PARAMS, "answer or answer_option required") };
1638
- }
1852
+ } else if (!present(answerText) && !hasOption) {
1853
+ return { error: rpcError(E.PARAMS, "answer or answer_option required") };
1639
1854
  }
1640
1855
  prompt.state = "answered";
1641
1856
  prompt.answered_at = coord.now();
@@ -1643,7 +1858,6 @@ function registerWhisper(coord) {
1643
1858
  prompt.answer_option = answerOption;
1644
1859
  prompt.answer_text = answerText;
1645
1860
  prompt.comment = p.comment;
1646
- p.task_id = prompt.task_id;
1647
1861
  return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
1648
1862
  });
1649
1863
  coord.checkWhisperLapses = (workspaceId, now) => {
@@ -1696,14 +1910,38 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
1696
1910
  "participant.rotate_key",
1697
1911
  "participant.revoke_key"
1698
1912
  ]);
1913
+ var MEMBER_ONLY_METHODS = /* @__PURE__ */ new Set([
1914
+ "task.route",
1915
+ "review.depth",
1916
+ "escalate.auto",
1917
+ "participant.leave"
1918
+ ]);
1699
1919
  var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1700
1920
  "workspace.describe",
1701
1921
  "audit.read",
1702
1922
  "audit.verify_chain",
1703
- "audit.verify_receipt"
1923
+ "audit.verify_receipt",
1924
+ // Submitting the chain to a transparency service reads it and sends it
1925
+ // onward. Recording the submission would append to the very log being
1926
+ // submitted, so the receipt would attest a chain one entry shorter.
1927
+ "audit.submit_to_scitt"
1704
1928
  ]);
1705
1929
  var MAX_IDEMPOTENCY_KEYS = 1e4;
1706
1930
  var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
1931
+ var MAX_NESTING = 64;
1932
+ function withinNesting(value, limit) {
1933
+ const stack = [[value, 1]];
1934
+ while (stack.length) {
1935
+ const [node, depth] = stack.pop();
1936
+ if (depth > limit) return false;
1937
+ if (Array.isArray(node)) {
1938
+ for (const v of node) stack.push([v, depth + 1]);
1939
+ } else if (node !== null && typeof node === "object") {
1940
+ for (const v of Object.values(node)) stack.push([v, depth + 1]);
1941
+ }
1942
+ }
1943
+ return true;
1944
+ }
1707
1945
  function nowIso(clockMs) {
1708
1946
  const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
1709
1947
  return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
@@ -1719,9 +1957,6 @@ function tagsError(p) {
1719
1957
  }
1720
1958
  return null;
1721
1959
  }
1722
- function linkHash(envelope, prev) {
1723
- return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
1724
- }
1725
1960
  function reviewRuleSupported(rule) {
1726
1961
  if (rule === "any_one_approves" || rule === "all_approve") return true;
1727
1962
  if (rule.startsWith("quorum:")) {
@@ -1747,6 +1982,18 @@ function reply(env, body) {
1747
1982
  else out.result = body.result;
1748
1983
  return out;
1749
1984
  }
1985
+ function indexSigned(index, entry) {
1986
+ const call2 = entryCall(entry);
1987
+ if (!isSigned(call2)) return;
1988
+ let digest;
1989
+ try {
1990
+ digest = signedDigest(call2);
1991
+ } catch {
1992
+ return;
1993
+ }
1994
+ if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
1995
+ else index.accepted.add(digest);
1996
+ }
1750
1997
  var REVIEW_REQUEST_STOPPED = ["cancelled", "superseded", "paused"];
1751
1998
  var Coordinator = class {
1752
1999
  workspaces = /* @__PURE__ */ new Map();
@@ -1754,8 +2001,16 @@ var Coordinator = class {
1754
2001
  ids;
1755
2002
  store;
1756
2003
  wsVersions = /* @__PURE__ */ new Map();
2004
+ saveChains = /* @__PURE__ */ new Map();
1757
2005
  clockMs;
2006
+ frozenNow;
1758
2007
  auditListeners = [];
2008
+ /**
2009
+ * The signed calls on each workspace's log, by what their senders signed:
2010
+ * recorded refusals with their seq and code, and accepted calls. Rebuilt
2011
+ * from the log when missing, so a snapshot never carries it.
2012
+ */
2013
+ signedIndexes = /* @__PURE__ */ new WeakMap();
1759
2014
  /** Method handler registry; profiles plug into this. */
1760
2015
  handlers = /* @__PURE__ */ new Map();
1761
2016
  /** Custom lapse-check function (whisper/1.0). Set by the whisper profile. */
@@ -1797,42 +2052,72 @@ var Coordinator = class {
1797
2052
  this.applyRecords(records);
1798
2053
  }
1799
2054
  applyRecords(records) {
1800
- this.workspaces.clear();
1801
2055
  this.wsVersions.clear();
1802
- for (const r of records) {
1803
- this.restore([r.data]);
1804
- this.wsVersions.set(r.id, r.version);
1805
- }
2056
+ this.restore(records.map((r) => r.data));
2057
+ for (const r of records) this.wsVersions.set(r.id, r.version);
1806
2058
  }
1807
2059
  /** Persist the current snapshot of one workspace to the store. */
1808
2060
  persist(ws) {
1809
2061
  const next = (this.wsVersions.get(ws.id) ?? 0) + 1;
1810
2062
  this.wsVersions.set(ws.id, next);
2063
+ let record;
1811
2064
  try {
1812
2065
  const all = this.snapshot();
1813
2066
  const data = all.find((w) => w.id === ws.id);
1814
2067
  if (!data) return;
1815
- const result = this.store.save({
1816
- id: ws.id,
1817
- data,
1818
- version: next,
1819
- updated_at: this.now()
1820
- });
1821
- if (result instanceof Promise) {
1822
- result.catch(() => {
1823
- });
1824
- }
2068
+ record = { id: ws.id, data, version: next, updated_at: this.now() };
1825
2069
  } catch {
2070
+ return;
2071
+ }
2072
+ const pending = this.saveChains.get(ws.id);
2073
+ if (pending) {
2074
+ this.trackSave(pending.then(() => this.store.save(record)), record);
2075
+ return;
2076
+ }
2077
+ let result;
2078
+ try {
2079
+ result = this.store.save(record);
2080
+ } catch (err) {
2081
+ this.reportStoreError(err, record);
2082
+ return;
2083
+ }
2084
+ if (result instanceof Promise) {
2085
+ this.trackSave(result, record);
2086
+ }
2087
+ }
2088
+ trackSave(save, record) {
2089
+ const guarded = save.catch((err) => this.reportStoreError(err, record));
2090
+ this.saveChains.set(record.id, guarded);
2091
+ void guarded.then(() => {
2092
+ if (this.saveChains.get(record.id) === guarded) {
2093
+ this.saveChains.delete(record.id);
2094
+ }
2095
+ });
2096
+ }
2097
+ reportStoreError(err, record) {
2098
+ if (this.options.onStoreError) {
2099
+ try {
2100
+ this.options.onStoreError(err, { id: record.id, version: record.version });
2101
+ } catch {
2102
+ }
2103
+ }
2104
+ }
2105
+ async drainSaves() {
2106
+ while (this.saveChains.size) {
2107
+ await Promise.allSettled([...this.saveChains.values()]);
1826
2108
  }
1827
2109
  }
1828
2110
  // -- lifecycle -----------------------------------------------------
1829
- now() {
2111
+ advanceClock() {
1830
2112
  if (this.clockMs !== void 0) {
1831
2113
  this.clockMs += 1e3;
1832
2114
  return nowIso(this.clockMs);
1833
2115
  }
1834
2116
  return nowIso();
1835
2117
  }
2118
+ now() {
2119
+ return this.frozenNow ?? this.advanceClock();
2120
+ }
1836
2121
  onAudit(listener) {
1837
2122
  this.auditListeners.push(listener);
1838
2123
  return () => {
@@ -1857,8 +2142,11 @@ var Coordinator = class {
1857
2142
  * error-table divergence is tracked separately.)
1858
2143
  */
1859
2144
  requireMember(ws, sender) {
1860
- if (typeof sender !== "string" || !ws.members.has(sender)) {
1861
- return { error: rpcError(E.NOT_AUTHORISED, `Not a workspace member: ${String(sender)}`) };
2145
+ if (typeof sender !== "string") {
2146
+ return { error: rpcError(E.NOT_AUTHORISED, "Not a workspace member: from is not a participant URI") };
2147
+ }
2148
+ if (!ws.members.has(sender)) {
2149
+ return { error: rpcError(E.NOT_AUTHORISED, `Not a workspace member: ${sender}`) };
1862
2150
  }
1863
2151
  return null;
1864
2152
  }
@@ -1955,8 +2243,40 @@ var Coordinator = class {
1955
2243
  ws.deliberations.set(x.id, x);
1956
2244
  for (const x of w.handoffs ?? [])
1957
2245
  ws.handoffs.set(x.id, x);
1958
- for (const x of w.snapshots ?? [])
1959
- ws.snapshots.set(x.id, x);
2246
+ for (const x of w.snapshots ?? []) {
2247
+ const content = structuredClone(x.content ?? {
2248
+ workspace: x.workspace,
2249
+ audit_seq: x.audit_seq,
2250
+ include: x.include,
2251
+ state: x.state,
2252
+ ...x.label ? { label: x.label } : {}
2253
+ });
2254
+ if (!x.content) {
2255
+ const oldState = content.state;
2256
+ const state = {};
2257
+ for (const key of ["mode_ceiling", "routing_policy_uri", "audit_seq"]) {
2258
+ if (oldState[key] != null && oldState[key] !== "") state[key] = oldState[key];
2259
+ }
2260
+ for (const [key, fields] of [
2261
+ ["members", ["uri", "type", "role", "scopes"]],
2262
+ ["open_tasks", ["id", "kind", "state", "assignee"]]
2263
+ ]) {
2264
+ const items = oldState[key];
2265
+ if (Array.isArray(items) && items.length) {
2266
+ 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]])));
2267
+ }
2268
+ }
2269
+ content.state = state;
2270
+ }
2271
+ ws.snapshots.set(x.id, {
2272
+ id: x.id,
2273
+ kind: "snapshot",
2274
+ produced_by: x.produced_by ?? x.by,
2275
+ produced_at: x.produced_at ?? x.ts,
2276
+ content_hash: x.content_hash ?? contentHash(content),
2277
+ content
2278
+ });
2279
+ }
1960
2280
  for (const x of w.route_decisions ?? [])
1961
2281
  ws.route_decisions.set(x.id, x);
1962
2282
  this.workspaces.set(ws.id, ws);
@@ -1964,14 +2284,29 @@ var Coordinator = class {
1964
2284
  }
1965
2285
  // -- dispatch ------------------------------------------------------
1966
2286
  dispatch(envelope) {
1967
- if (!isValidEnvelope(envelope) || !envelope.method) {
2287
+ this.frozenNow = this.advanceClock();
2288
+ try {
2289
+ const response = this._dispatch(envelope);
2290
+ if (response.error) this.recordRefusal(envelope, response.error);
2291
+ return response;
2292
+ } finally {
2293
+ this.frozenNow = void 0;
2294
+ }
2295
+ }
2296
+ _dispatch(envelope) {
2297
+ if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
1968
2298
  return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
1969
2299
  }
2300
+ if (!withinNesting(envelope, MAX_NESTING)) {
2301
+ return reply(envelope, { error: rpcError(E.REQUEST, `Envelope nests deeper than ${MAX_NESTING} levels`) });
2302
+ }
1970
2303
  const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
1971
2304
  let size = 0;
2305
+ let canonicalError;
1972
2306
  try {
1973
2307
  size = canonicalize(envelope).length;
1974
- } catch {
2308
+ } catch (e) {
2309
+ canonicalError = e;
1975
2310
  }
1976
2311
  if (size > maxBytes) {
1977
2312
  return reply(envelope, { error: rpcError(
@@ -1985,6 +2320,28 @@ var Coordinator = class {
1985
2320
  return reply(envelope, { error: rpcError(E.PARAMS, "Invalid params: expected an object") });
1986
2321
  }
1987
2322
  const params = rawParams ?? {};
2323
+ const handler = this.handlers.get(method);
2324
+ if (!handler) {
2325
+ return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
2326
+ }
2327
+ if (canonicalError !== void 0) {
2328
+ const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
2329
+ return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2330
+ }
2331
+ if (isSigned(envelope)) {
2332
+ const wsId2 = params.workspace;
2333
+ const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2334
+ if (ws) {
2335
+ const prior = this.signedIndex(ws).refused.get(signedDigest(envelope));
2336
+ if (prior) {
2337
+ return reply(envelope, { error: rpcError(
2338
+ prior.code,
2339
+ `Refused at seq ${prior.seq}; a refused request is not evaluated again`,
2340
+ { refused_at_seq: prior.seq }
2341
+ ) });
2342
+ }
2343
+ }
2344
+ }
1988
2345
  if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
1989
2346
  const sigErr = this.verifySignature(envelope);
1990
2347
  if (sigErr) return reply(envelope, { error: sigErr });
@@ -2003,7 +2360,11 @@ var Coordinator = class {
2003
2360
  const stale = this.checkStepUp(params);
2004
2361
  if (stale) return reply(envelope, { error: stale });
2005
2362
  }
2006
- if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.")) {
2363
+ {
2364
+ const gate = this.profileGate(method, params);
2365
+ if (gate) return reply(envelope, { error: gate });
2366
+ }
2367
+ if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.") || MEMBER_ONLY_METHODS.has(method)) {
2007
2368
  const wsId2 = params.workspace;
2008
2369
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2009
2370
  if (ws) {
@@ -2038,16 +2399,6 @@ var Coordinator = class {
2038
2399
  if (task) return reply(envelope, { result: { task_id: existingId, state: task.state } });
2039
2400
  }
2040
2401
  }
2041
- const handler = this.handlers.get(method);
2042
- if (!handler) {
2043
- return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
2044
- }
2045
- try {
2046
- canonicalize(envelope);
2047
- } catch (e) {
2048
- const msg = e instanceof Error ? e.message : String(e);
2049
- return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2050
- }
2051
2402
  let out;
2052
2403
  try {
2053
2404
  out = handler(params);
@@ -2065,17 +2416,56 @@ var Coordinator = class {
2065
2416
  }
2066
2417
  // -- audit ---------------------------------------------------------
2067
2418
  recordAudit(ws, envelope) {
2068
- const entry = {
2069
- seq: ws.audit.length,
2070
- arrived: this.now(),
2071
- envelope: JSON.parse(JSON.stringify(envelope))
2072
- };
2419
+ this.appendEntry(ws, { envelope: JSON.parse(JSON.stringify(envelope)) });
2420
+ }
2421
+ /**
2422
+ * SPECIFICATION 10.1: record a refused call when it is a governed attempt.
2423
+ * The caller must be a member of an existing workspace, the call must be a
2424
+ * JSON-RPC call for a method that is not a read, and the refusal must be one
2425
+ * `refusalIsRecorded` names. The request is kept as it arrived, so a
2426
+ * signature on it still verifies. Every call that reaches a recorded refusal
2427
+ * has passed the canonical check, so it can be hashed into the chain.
2428
+ */
2429
+ recordRefusal(envelope, error) {
2430
+ if (!isValidEnvelope(envelope) || typeof envelope.method !== "string") return;
2431
+ const method = envelope.method;
2432
+ if (READ_ONLY_METHODS.has(method)) return;
2433
+ if (!refusalIsRecorded(method, error, PRIVILEGED_METHODS)) return;
2434
+ const raw = envelope.params;
2435
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return;
2436
+ const params = raw;
2437
+ const ws = typeof params.workspace === "string" ? this.workspaces.get(params.workspace) : void 0;
2438
+ if (!ws || typeof params.from !== "string" || !ws.members.has(params.from)) return;
2439
+ if (isSigned(envelope)) {
2440
+ const index = this.signedIndex(ws);
2441
+ const digest = signedDigest(envelope);
2442
+ if (index.refused.has(digest) || index.accepted.has(digest)) return;
2443
+ }
2444
+ this.appendEntry(ws, {
2445
+ request: JSON.parse(JSON.stringify(envelope)),
2446
+ outcome: { status: "refused", code: error.code }
2447
+ });
2448
+ }
2449
+ /** The signed calls on this workspace's log, built from the log when missing. */
2450
+ signedIndex(ws) {
2451
+ let index = this.signedIndexes.get(ws);
2452
+ if (!index) {
2453
+ index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Set() };
2454
+ for (const entry of ws.audit) indexSigned(index, entry);
2455
+ this.signedIndexes.set(ws, index);
2456
+ }
2457
+ return index;
2458
+ }
2459
+ appendEntry(ws, record) {
2460
+ const entry = { seq: ws.audit.length, arrived: this.now(), ...record };
2073
2461
  if (ws.chain_enabled || this.options.enableChain) {
2074
2462
  const prev = ws.chain_head ?? ZERO_HASH;
2075
2463
  entry.prev_hash = prev;
2076
- ws.chain_head = linkHash(entry.envelope, prev);
2464
+ ws.chain_head = linkHash(entryRecord(entry), prev);
2077
2465
  }
2078
2466
  ws.audit.push(entry);
2467
+ const index = this.signedIndexes.get(ws);
2468
+ if (index) indexSigned(index, entry);
2079
2469
  for (const l of this.auditListeners) {
2080
2470
  try {
2081
2471
  l(ws, entry);
@@ -2098,10 +2488,14 @@ var Coordinator = class {
2098
2488
  const params = envelope.params ?? {};
2099
2489
  const sender = params.from;
2100
2490
  const wsId = params.workspace;
2101
- const ts = params.ts ?? this.now();
2102
- if (!sender || !wsId) {
2491
+ const rawTs = params.ts;
2492
+ if (typeof sender !== "string" || !sender || typeof wsId !== "string" || !wsId) {
2103
2493
  return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: missing from/workspace");
2104
2494
  }
2495
+ if (rawTs !== void 0 && rawTs !== null && typeof rawTs !== "string") {
2496
+ return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: ts must be a string");
2497
+ }
2498
+ const ts = rawTs ?? this.now();
2105
2499
  const ws = this.workspaces.get(wsId);
2106
2500
  if (!ws) {
2107
2501
  return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: unknown workspace");
@@ -2146,10 +2540,34 @@ var Coordinator = class {
2146
2540
  return null;
2147
2541
  }
2148
2542
  // -- step-up (identity-oidc/1.0) -----------------------------------
2543
+ /**
2544
+ * The SPECIFICATION 15.4 check, per method rather than per namespace.
2545
+ *
2546
+ * Core is always present, so a core method is never gated. The reads in
2547
+ * ALWAYS_AVAILABLE are never gated either: a workspace must be able to ask
2548
+ * what it is and to check its own chain, and `audit.verify_chain` belongs to
2549
+ * `audit-scitt/1.0` while chaining also turns on through an option, so
2550
+ * gating it would let a workspace write a hash-linked chain it is refused
2551
+ * permission to verify.
2552
+ */
2553
+ profileGate(method, params) {
2554
+ const owner = OWNING_PROFILE[method];
2555
+ if (owner === void 0 || owner === "core/1.0" || ALWAYS_AVAILABLE.has(method)) return void 0;
2556
+ const wsId = params.workspace;
2557
+ const ws = typeof wsId === "string" ? this.workspaces.get(wsId) : void 0;
2558
+ if (!ws) return void 0;
2559
+ const name = owner.split("/")[0];
2560
+ if (ws.profiles.some((p) => p === name || p.startsWith(name + "/"))) return void 0;
2561
+ return rpcError(
2562
+ E.METHOD,
2563
+ `Unknown method: ${method}`,
2564
+ { profile: owner, advertised: [...ws.profiles] }
2565
+ );
2566
+ }
2149
2567
  checkStepUp(params) {
2150
2568
  const wsId = params.workspace;
2151
2569
  const sender = params.from;
2152
- if (!wsId || !sender) return null;
2570
+ if (typeof wsId !== "string" || typeof sender !== "string" || !wsId || !sender) return null;
2153
2571
  const ws = this.workspaces.get(wsId);
2154
2572
  if (!ws) return null;
2155
2573
  const member = ws.members.get(sender);
@@ -2213,6 +2631,20 @@ var Coordinator = class {
2213
2631
  if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
2214
2632
  if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
2215
2633
  const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
2634
+ for (const [profile, enforced, option] of [
2635
+ ["security-signed/1.0", !!this.options.requireSignatures, "requireSignatures"],
2636
+ ["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
2637
+ ]) {
2638
+ const name = profile.split("/")[0];
2639
+ const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
2640
+ if (enforced && !advertised) profiles.push(profile);
2641
+ else if (advertised && !enforced) {
2642
+ return { error: rpcError(
2643
+ E.PARAMS,
2644
+ `${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.`
2645
+ ) };
2646
+ }
2647
+ }
2216
2648
  const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
2217
2649
  const ws = {
2218
2650
  id,
@@ -2262,7 +2694,10 @@ var Coordinator = class {
2262
2694
  audit_count: ws.audit.length,
2263
2695
  task_count: ws.tasks.size,
2264
2696
  override_count: ws.overrides.size,
2265
- evidence_head: ws.chain_head,
2697
+ // Omitted where the workspace has no chain, which is what the Python
2698
+ // reference now sends too: JSON.stringify drops an undefined value, so
2699
+ // the two answered the same call differently on the wire.
2700
+ ...ws.chain_head ? { evidence_head: ws.chain_head } : {},
2266
2701
  ...ws.routing_policy_uri ? { routing_policy_uri: ws.routing_policy_uri } : {}
2267
2702
  } };
2268
2703
  }
@@ -2296,6 +2731,8 @@ var Coordinator = class {
2296
2731
  }
2297
2732
  const uri = p.from;
2298
2733
  const now = this.now();
2734
+ const existing = ws.members.get(uri);
2735
+ let tokenVerified = false;
2299
2736
  const member = {
2300
2737
  uri,
2301
2738
  type: p.type,
@@ -2310,20 +2747,41 @@ var Coordinator = class {
2310
2747
  if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
2311
2748
  const claims = this.options.verifyOidcToken(p.oidc_token);
2312
2749
  if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
2313
- member.oidc_sub = claims.sub;
2750
+ const boundUri = claims.chap_participant_uri;
2751
+ const sub = typeof claims.sub === "string" && claims.sub !== "" ? claims.sub : void 0;
2752
+ if (boundUri != null && boundUri !== uri) {
2753
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
2754
+ }
2755
+ if (existing) {
2756
+ if (existing.oidc_sub != null) {
2757
+ if (sub !== existing.oidc_sub) {
2758
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token subject does not match the member") };
2759
+ }
2760
+ } else if (boundUri !== uri) {
2761
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
2762
+ }
2763
+ }
2764
+ member.oidc_sub = sub;
2765
+ tokenVerified = true;
2314
2766
  const at = claims.auth_time;
2315
2767
  if (typeof at === "number") member.oidc_auth_time = at;
2316
- member.oidc_acr = claims.acr;
2768
+ member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
2317
2769
  const cnf = claims.cnf;
2318
2770
  const cnfJwk = cnf?.jwk;
2319
2771
  if (cnfJwk && typeof cnfJwk.kid === "string") {
2320
2772
  member.keys.push({ jwk: cnfJwk, kid: cnfJwk.kid, valid_from: now });
2321
2773
  }
2322
2774
  }
2323
- if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null) {
2775
+ if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null && !Array.isArray(p.vc_presentation)) {
2324
2776
  const subject = this.options.verifyVc(p.vc_presentation);
2325
2777
  if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
2326
- member.vc_holder = subject.holder ?? subject.id;
2778
+ const holder = [subject.holder, subject.id].find(
2779
+ (h) => typeof h === "string" && h !== ""
2780
+ );
2781
+ if (existing && (existing.vc_holder == null || holder !== existing.vc_holder)) {
2782
+ return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
2783
+ }
2784
+ member.vc_holder = holder;
2327
2785
  const vpJwk = subject.cnf_jwk;
2328
2786
  if (vpJwk && typeof vpJwk.kid === "string") {
2329
2787
  member.keys.push({ jwk: vpJwk, kid: vpJwk.kid, valid_from: now });
@@ -2338,11 +2796,11 @@ var Coordinator = class {
2338
2796
  }
2339
2797
  }
2340
2798
  }
2341
- const existing = ws.members.get(uri);
2342
2799
  if (existing) {
2343
2800
  if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
2344
2801
  if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
2345
2802
  if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
2803
+ if (tokenVerified) existing.oidc_acr = member.oidc_acr;
2346
2804
  if (member.vc_holder !== void 0) existing.vc_holder = member.vc_holder;
2347
2805
  for (const k of attested) {
2348
2806
  if (!existing.keys.some((x) => x.kid === k.kid)) existing.keys.push(k);
@@ -2366,12 +2824,15 @@ var Coordinator = class {
2366
2824
  const notMember = this.requireMember(ws, p.from);
2367
2825
  if (notMember) return notMember;
2368
2826
  const assignee = p.assignee || p.to;
2369
- if (!assignee || !ws.members.has(assignee)) {
2827
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
2370
2828
  return { error: rpcError(E.PARAMS, "Assignee not in workspace") };
2371
2829
  }
2372
2830
  if (ws.members.get(assignee).paused) {
2373
2831
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
2374
2832
  }
2833
+ if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
2834
+ return { error: rpcError(E.PARAMS, "mode must be a string") };
2835
+ }
2375
2836
  const requestedMode = p.mode || ws.mode;
2376
2837
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
2377
2838
  return { error: rpcError(
@@ -2379,6 +2840,9 @@ var Coordinator = class {
2379
2840
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
2380
2841
  ) };
2381
2842
  }
2843
+ if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
2844
+ return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
2845
+ }
2382
2846
  const taskId = this.ids.taskId();
2383
2847
  const now = this.now();
2384
2848
  const task = {
@@ -2415,10 +2879,10 @@ var Coordinator = class {
2415
2879
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2416
2880
  const newState = p.state;
2417
2881
  const legal = {
2418
- created: ["in_progress", "declined", "paused"],
2419
- in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
2882
+ created: ["in_progress", "declined"],
2883
+ in_progress: ["in_progress", "completed", "declined"],
2420
2884
  review_requested: ["in_progress"],
2421
- paused: ["in_progress", "cancelled"]
2885
+ paused: ["cancelled"]
2422
2886
  };
2423
2887
  if (!legal[task.state]?.includes(newState)) {
2424
2888
  return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
@@ -2451,7 +2915,6 @@ var Coordinator = class {
2451
2915
  }
2452
2916
  if (task.review_required) {
2453
2917
  const now = this.now();
2454
- task.pending_artefact = p.output;
2455
2918
  if (!task.review) {
2456
2919
  const producer = p.from;
2457
2920
  const eligible = [...ws.members.entries()].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
@@ -2463,6 +2926,7 @@ var Coordinator = class {
2463
2926
  }
2464
2927
  task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
2465
2928
  }
2929
+ task.pending_artefact = p.output;
2466
2930
  task.state = "review_requested";
2467
2931
  task.updated_at = now;
2468
2932
  task.history.push({
@@ -2489,23 +2953,39 @@ var Coordinator = class {
2489
2953
  }
2490
2954
  const range = p.range ?? {};
2491
2955
  const filter = p.filter ?? {};
2956
+ if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
2957
+ return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
2958
+ }
2492
2959
  const fromSeq = range.from_seq ?? 0;
2493
2960
  const toSeq = range.to_seq ?? ws.audit.length;
2494
2961
  const out = [];
2495
2962
  for (const entry of ws.audit.slice(fromSeq, toSeq)) {
2496
- const ep = entry.envelope.params ?? {};
2497
- if (filter.method && entry.envelope.method !== filter.method) continue;
2963
+ const refused = entry.outcome != null;
2964
+ if (filter.outcome === "accepted" && refused) continue;
2965
+ if (filter.outcome === "refused" && !refused) continue;
2966
+ const call2 = entryCall(entry) ?? {};
2967
+ const ep = call2.params ?? {};
2968
+ if (filter.method && call2.method !== filter.method) continue;
2498
2969
  if (filter.from && ep.from !== filter.from) continue;
2499
- if (filter.task_id && ep.task_id !== filter.task_id) continue;
2500
- const item = {
2501
- seq: entry.seq,
2502
- arrived: entry.arrived,
2503
- envelope: entry.envelope
2504
- };
2970
+ if (filter.task_id) {
2971
+ let taskId = ep.task_id;
2972
+ if (call2.method === "whisper.answer") {
2973
+ const prompt = ws.whispers.get(ep.whisper_id);
2974
+ taskId = prompt ? prompt.task_id : void 0;
2975
+ }
2976
+ if (taskId !== filter.task_id) continue;
2977
+ }
2978
+ const item = { seq: entry.seq, arrived: entry.arrived };
2979
+ if (refused) {
2980
+ item.request = entry.request;
2981
+ item.outcome = entry.outcome;
2982
+ } else {
2983
+ item.envelope = entry.envelope;
2984
+ }
2505
2985
  if (entry.prev_hash) item.prev_hash = entry.prev_hash;
2506
2986
  out.push(item);
2507
2987
  }
2508
- return { result: { entries: out, next_seq: toSeq } };
2988
+ return { result: { entries: out, next_seq: Math.min(toSeq, ws.audit.length) } };
2509
2989
  }
2510
2990
  // ==========================================================
2511
2991
  // review/1.0 handlers
@@ -2730,16 +3210,34 @@ var Coordinator = class {
2730
3210
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2731
3211
  const notMember = this.requireMember(ws, p.from);
2732
3212
  if (notMember) return notMember;
2733
- const orig = ws.tasks.get(p.original_task_id);
3213
+ const origId = p.original_task_id;
3214
+ const orig = typeof origId === "string" ? ws.tasks.get(origId) : void 0;
2734
3215
  if (!orig) return { error: rpcError(E.PARAMS, "Unknown original task") };
2735
3216
  if (orig.state === "completed" || orig.state === "cancelled" || orig.state === "superseded") {
2736
3217
  return { error: rpcError(E.PARAMS, `Cannot escalate a terminal task: ${orig.state}`) };
2737
3218
  }
3219
+ if (p.new_task === null || typeof p.new_task !== "object" || Array.isArray(p.new_task)) {
3220
+ return { error: rpcError(E.PARAMS, "new_task must be an object") };
3221
+ }
2738
3222
  const nt = p.new_task;
2739
3223
  const assignee = nt.assignee;
2740
- if (!assignee || !ws.members.has(assignee)) {
3224
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
2741
3225
  return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
2742
3226
  }
3227
+ const named = nt.mode;
3228
+ if (named !== void 0 && named !== null && typeof named !== "string") {
3229
+ return { error: rpcError(E.PARAMS, "new_task.mode must be a string") };
3230
+ }
3231
+ if (ws.members.get(assignee).paused) {
3232
+ return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
3233
+ }
3234
+ const mode = named || orig.mode;
3235
+ if (!modeLE(mode, ws.mode_ceiling)) {
3236
+ return { error: rpcError(
3237
+ E.MODE_CEILING_EXCEEDED,
3238
+ `Requested mode ${mode} exceeds ceiling ${ws.mode_ceiling}`
3239
+ ) };
3240
+ }
2743
3241
  const now = this.now();
2744
3242
  const newId = this.ids.taskId();
2745
3243
  const newTask = {
@@ -2751,11 +3249,14 @@ var Coordinator = class {
2751
3249
  input: nt.input ?? {},
2752
3250
  created_at: now,
2753
3251
  updated_at: now,
2754
- mode: orig.mode,
3252
+ mode,
2755
3253
  supersedes: orig.id,
2756
3254
  history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
2757
3255
  paused: false
2758
3256
  };
3257
+ if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
3258
+ newTask.review_required = true;
3259
+ }
2759
3260
  ws.tasks.set(newId, newTask);
2760
3261
  orig.state = "escalated";
2761
3262
  orig.updated_at = now;
@@ -2896,11 +3397,13 @@ function wrapMcpToolCall(coord, workspace, options) {
2896
3397
  method: "task.update",
2897
3398
  params: { workspace, from: options.caller, task_id: taskId, state: "in_progress" }
2898
3399
  });
3400
+ const output = { result: options.result, citations };
3401
+ if (options.fulfils) output.fulfils = options.fulfils;
2899
3402
  const completeParams = {
2900
3403
  workspace,
2901
3404
  from: options.caller,
2902
3405
  task_id: taskId,
2903
- output: { result: options.result, citations }
3406
+ output
2904
3407
  };
2905
3408
  if (options.confidence !== void 0) completeParams.confidence = options.confidence;
2906
3409
  const completeResp = coord.dispatch({
@@ -2952,11 +3455,13 @@ function wrapA2aMessageExchange(coord, workspace, options) {
2952
3455
  state: "in_progress"
2953
3456
  }
2954
3457
  });
3458
+ const output = { received: options.received, citations: [citation] };
3459
+ if (options.fulfils) output.fulfils = options.fulfils;
2955
3460
  const completeParams = {
2956
3461
  workspace,
2957
3462
  from: options.bridgeUri,
2958
3463
  task_id: taskId,
2959
- output: { received: options.received, citations: [citation] }
3464
+ output
2960
3465
  };
2961
3466
  if (options.confidence !== void 0) completeParams.confidence = options.confidence;
2962
3467
  const completeResp = coord.dispatch({
@@ -2984,8 +3489,12 @@ export {
2984
3489
  deriveKeypair,
2985
3490
  deriveSeed,
2986
3491
  diffJsonPatch,
3492
+ entryCall,
3493
+ entryRecord,
3494
+ isRefusal,
2987
3495
  isValidEnvelope,
2988
3496
  jwkFromPrivateKey,
3497
+ linkHash,
2989
3498
  makeApi,
2990
3499
  makeDefaultPolicy,
2991
3500
  modeLE,