@brightbeamai/chap-coordinator 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,14 +1982,35 @@ 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
+ }
1997
+ var REVIEW_REQUEST_STOPPED = ["cancelled", "superseded", "paused"];
1750
1998
  var Coordinator = class {
1751
1999
  workspaces = /* @__PURE__ */ new Map();
1752
2000
  options;
1753
2001
  ids;
1754
2002
  store;
1755
2003
  wsVersions = /* @__PURE__ */ new Map();
2004
+ saveChains = /* @__PURE__ */ new Map();
1756
2005
  clockMs;
2006
+ frozenNow;
1757
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();
1758
2014
  /** Method handler registry; profiles plug into this. */
1759
2015
  handlers = /* @__PURE__ */ new Map();
1760
2016
  /** Custom lapse-check function (whisper/1.0). Set by the whisper profile. */
@@ -1796,42 +2052,72 @@ var Coordinator = class {
1796
2052
  this.applyRecords(records);
1797
2053
  }
1798
2054
  applyRecords(records) {
1799
- this.workspaces.clear();
1800
2055
  this.wsVersions.clear();
1801
- for (const r of records) {
1802
- this.restore([r.data]);
1803
- this.wsVersions.set(r.id, r.version);
1804
- }
2056
+ this.restore(records.map((r) => r.data));
2057
+ for (const r of records) this.wsVersions.set(r.id, r.version);
1805
2058
  }
1806
2059
  /** Persist the current snapshot of one workspace to the store. */
1807
2060
  persist(ws) {
1808
2061
  const next = (this.wsVersions.get(ws.id) ?? 0) + 1;
1809
2062
  this.wsVersions.set(ws.id, next);
2063
+ let record;
1810
2064
  try {
1811
2065
  const all = this.snapshot();
1812
2066
  const data = all.find((w) => w.id === ws.id);
1813
2067
  if (!data) return;
1814
- const result = this.store.save({
1815
- id: ws.id,
1816
- data,
1817
- version: next,
1818
- updated_at: this.now()
1819
- });
1820
- if (result instanceof Promise) {
1821
- result.catch(() => {
1822
- });
1823
- }
2068
+ record = { id: ws.id, data, version: next, updated_at: this.now() };
1824
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()]);
1825
2108
  }
1826
2109
  }
1827
2110
  // -- lifecycle -----------------------------------------------------
1828
- now() {
2111
+ advanceClock() {
1829
2112
  if (this.clockMs !== void 0) {
1830
2113
  this.clockMs += 1e3;
1831
2114
  return nowIso(this.clockMs);
1832
2115
  }
1833
2116
  return nowIso();
1834
2117
  }
2118
+ now() {
2119
+ return this.frozenNow ?? this.advanceClock();
2120
+ }
1835
2121
  onAudit(listener) {
1836
2122
  this.auditListeners.push(listener);
1837
2123
  return () => {
@@ -1856,8 +2142,11 @@ var Coordinator = class {
1856
2142
  * error-table divergence is tracked separately.)
1857
2143
  */
1858
2144
  requireMember(ws, sender) {
1859
- if (typeof sender !== "string" || !ws.members.has(sender)) {
1860
- 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}`) };
1861
2150
  }
1862
2151
  return null;
1863
2152
  }
@@ -1954,8 +2243,40 @@ var Coordinator = class {
1954
2243
  ws.deliberations.set(x.id, x);
1955
2244
  for (const x of w.handoffs ?? [])
1956
2245
  ws.handoffs.set(x.id, x);
1957
- for (const x of w.snapshots ?? [])
1958
- 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
+ }
1959
2280
  for (const x of w.route_decisions ?? [])
1960
2281
  ws.route_decisions.set(x.id, x);
1961
2282
  this.workspaces.set(ws.id, ws);
@@ -1963,14 +2284,29 @@ var Coordinator = class {
1963
2284
  }
1964
2285
  // -- dispatch ------------------------------------------------------
1965
2286
  dispatch(envelope) {
1966
- 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) {
1967
2298
  return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
1968
2299
  }
2300
+ if (!withinNesting(envelope, MAX_NESTING)) {
2301
+ return reply(envelope, { error: rpcError(E.REQUEST, `Envelope nests deeper than ${MAX_NESTING} levels`) });
2302
+ }
1969
2303
  const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
1970
2304
  let size = 0;
2305
+ let canonicalError;
1971
2306
  try {
1972
2307
  size = canonicalize(envelope).length;
1973
- } catch {
2308
+ } catch (e) {
2309
+ canonicalError = e;
1974
2310
  }
1975
2311
  if (size > maxBytes) {
1976
2312
  return reply(envelope, { error: rpcError(
@@ -1984,6 +2320,28 @@ var Coordinator = class {
1984
2320
  return reply(envelope, { error: rpcError(E.PARAMS, "Invalid params: expected an object") });
1985
2321
  }
1986
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
+ }
1987
2345
  if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
1988
2346
  const sigErr = this.verifySignature(envelope);
1989
2347
  if (sigErr) return reply(envelope, { error: sigErr });
@@ -2002,7 +2360,11 @@ var Coordinator = class {
2002
2360
  const stale = this.checkStepUp(params);
2003
2361
  if (stale) return reply(envelope, { error: stale });
2004
2362
  }
2005
- 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)) {
2006
2368
  const wsId2 = params.workspace;
2007
2369
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2008
2370
  if (ws) {
@@ -2037,16 +2399,6 @@ var Coordinator = class {
2037
2399
  if (task) return reply(envelope, { result: { task_id: existingId, state: task.state } });
2038
2400
  }
2039
2401
  }
2040
- const handler = this.handlers.get(method);
2041
- if (!handler) {
2042
- return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
2043
- }
2044
- try {
2045
- canonicalize(envelope);
2046
- } catch (e) {
2047
- const msg = e instanceof Error ? e.message : String(e);
2048
- return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2049
- }
2050
2402
  let out;
2051
2403
  try {
2052
2404
  out = handler(params);
@@ -2064,17 +2416,56 @@ var Coordinator = class {
2064
2416
  }
2065
2417
  // -- audit ---------------------------------------------------------
2066
2418
  recordAudit(ws, envelope) {
2067
- const entry = {
2068
- seq: ws.audit.length,
2069
- arrived: this.now(),
2070
- envelope: JSON.parse(JSON.stringify(envelope))
2071
- };
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 };
2072
2461
  if (ws.chain_enabled || this.options.enableChain) {
2073
2462
  const prev = ws.chain_head ?? ZERO_HASH;
2074
2463
  entry.prev_hash = prev;
2075
- ws.chain_head = linkHash(entry.envelope, prev);
2464
+ ws.chain_head = linkHash(entryRecord(entry), prev);
2076
2465
  }
2077
2466
  ws.audit.push(entry);
2467
+ const index = this.signedIndexes.get(ws);
2468
+ if (index) indexSigned(index, entry);
2078
2469
  for (const l of this.auditListeners) {
2079
2470
  try {
2080
2471
  l(ws, entry);
@@ -2097,10 +2488,14 @@ var Coordinator = class {
2097
2488
  const params = envelope.params ?? {};
2098
2489
  const sender = params.from;
2099
2490
  const wsId = params.workspace;
2100
- const ts = params.ts ?? this.now();
2101
- if (!sender || !wsId) {
2491
+ const rawTs = params.ts;
2492
+ if (typeof sender !== "string" || !sender || typeof wsId !== "string" || !wsId) {
2102
2493
  return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: missing from/workspace");
2103
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();
2104
2499
  const ws = this.workspaces.get(wsId);
2105
2500
  if (!ws) {
2106
2501
  return rpcError(E.SIG_VERIFY_FAILED, "Cannot verify signature: unknown workspace");
@@ -2145,10 +2540,34 @@ var Coordinator = class {
2145
2540
  return null;
2146
2541
  }
2147
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
+ }
2148
2567
  checkStepUp(params) {
2149
2568
  const wsId = params.workspace;
2150
2569
  const sender = params.from;
2151
- if (!wsId || !sender) return null;
2570
+ if (typeof wsId !== "string" || typeof sender !== "string" || !wsId || !sender) return null;
2152
2571
  const ws = this.workspaces.get(wsId);
2153
2572
  if (!ws) return null;
2154
2573
  const member = ws.members.get(sender);
@@ -2212,6 +2631,20 @@ var Coordinator = class {
2212
2631
  if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
2213
2632
  if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
2214
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
+ }
2215
2648
  const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
2216
2649
  const ws = {
2217
2650
  id,
@@ -2261,7 +2694,10 @@ var Coordinator = class {
2261
2694
  audit_count: ws.audit.length,
2262
2695
  task_count: ws.tasks.size,
2263
2696
  override_count: ws.overrides.size,
2264
- 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 } : {},
2265
2701
  ...ws.routing_policy_uri ? { routing_policy_uri: ws.routing_policy_uri } : {}
2266
2702
  } };
2267
2703
  }
@@ -2295,6 +2731,8 @@ var Coordinator = class {
2295
2731
  }
2296
2732
  const uri = p.from;
2297
2733
  const now = this.now();
2734
+ const existing = ws.members.get(uri);
2735
+ let tokenVerified = false;
2298
2736
  const member = {
2299
2737
  uri,
2300
2738
  type: p.type,
@@ -2309,20 +2747,41 @@ var Coordinator = class {
2309
2747
  if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
2310
2748
  const claims = this.options.verifyOidcToken(p.oidc_token);
2311
2749
  if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
2312
- 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;
2313
2766
  const at = claims.auth_time;
2314
2767
  if (typeof at === "number") member.oidc_auth_time = at;
2315
- member.oidc_acr = claims.acr;
2768
+ member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
2316
2769
  const cnf = claims.cnf;
2317
2770
  const cnfJwk = cnf?.jwk;
2318
2771
  if (cnfJwk && typeof cnfJwk.kid === "string") {
2319
2772
  member.keys.push({ jwk: cnfJwk, kid: cnfJwk.kid, valid_from: now });
2320
2773
  }
2321
2774
  }
2322
- 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)) {
2323
2776
  const subject = this.options.verifyVc(p.vc_presentation);
2324
2777
  if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
2325
- 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;
2326
2785
  const vpJwk = subject.cnf_jwk;
2327
2786
  if (vpJwk && typeof vpJwk.kid === "string") {
2328
2787
  member.keys.push({ jwk: vpJwk, kid: vpJwk.kid, valid_from: now });
@@ -2337,11 +2796,11 @@ var Coordinator = class {
2337
2796
  }
2338
2797
  }
2339
2798
  }
2340
- const existing = ws.members.get(uri);
2341
2799
  if (existing) {
2342
2800
  if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
2343
2801
  if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
2344
2802
  if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
2803
+ if (tokenVerified) existing.oidc_acr = member.oidc_acr;
2345
2804
  if (member.vc_holder !== void 0) existing.vc_holder = member.vc_holder;
2346
2805
  for (const k of attested) {
2347
2806
  if (!existing.keys.some((x) => x.kid === k.kid)) existing.keys.push(k);
@@ -2365,12 +2824,15 @@ var Coordinator = class {
2365
2824
  const notMember = this.requireMember(ws, p.from);
2366
2825
  if (notMember) return notMember;
2367
2826
  const assignee = p.assignee || p.to;
2368
- if (!assignee || !ws.members.has(assignee)) {
2827
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
2369
2828
  return { error: rpcError(E.PARAMS, "Assignee not in workspace") };
2370
2829
  }
2371
2830
  if (ws.members.get(assignee).paused) {
2372
2831
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
2373
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
+ }
2374
2836
  const requestedMode = p.mode || ws.mode;
2375
2837
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
2376
2838
  return { error: rpcError(
@@ -2378,6 +2840,9 @@ var Coordinator = class {
2378
2840
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
2379
2841
  ) };
2380
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
+ }
2381
2846
  const taskId = this.ids.taskId();
2382
2847
  const now = this.now();
2383
2848
  const task = {
@@ -2414,14 +2879,20 @@ var Coordinator = class {
2414
2879
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2415
2880
  const newState = p.state;
2416
2881
  const legal = {
2417
- created: ["in_progress", "declined", "paused"],
2418
- in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
2882
+ created: ["in_progress", "declined"],
2883
+ in_progress: ["in_progress", "completed", "declined"],
2419
2884
  review_requested: ["in_progress"],
2420
- paused: ["in_progress", "cancelled"]
2885
+ paused: ["cancelled"]
2421
2886
  };
2422
2887
  if (!legal[task.state]?.includes(newState)) {
2423
2888
  return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
2424
2889
  }
2890
+ if (newState === "completed" && task.review_required) {
2891
+ return { error: rpcError(
2892
+ E.PARAMS,
2893
+ "This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
2894
+ ) };
2895
+ }
2425
2896
  task.state = newState;
2426
2897
  task.updated_at = this.now();
2427
2898
  task.history.push({
@@ -2444,18 +2915,18 @@ var Coordinator = class {
2444
2915
  }
2445
2916
  if (task.review_required) {
2446
2917
  const now = this.now();
2447
- task.pending_artefact = p.output;
2448
2918
  if (!task.review) {
2449
2919
  const producer = p.from;
2450
- const eligible = [...ws.members.keys()].filter((uri) => uri !== producer && uri !== task.assignee);
2920
+ const eligible = [...ws.members.entries()].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
2451
2921
  if (eligible.length === 0) {
2452
2922
  return { error: rpcError(
2453
2923
  E.NOT_AUTHORISED,
2454
- "Task requires review but has no eligible reviewer (needs a member other than its assignee and completer)"
2924
+ "Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
2455
2925
  ) };
2456
2926
  }
2457
2927
  task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
2458
2928
  }
2929
+ task.pending_artefact = p.output;
2459
2930
  task.state = "review_requested";
2460
2931
  task.updated_at = now;
2461
2932
  task.history.push({
@@ -2482,23 +2953,39 @@ var Coordinator = class {
2482
2953
  }
2483
2954
  const range = p.range ?? {};
2484
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
+ }
2485
2959
  const fromSeq = range.from_seq ?? 0;
2486
2960
  const toSeq = range.to_seq ?? ws.audit.length;
2487
2961
  const out = [];
2488
2962
  for (const entry of ws.audit.slice(fromSeq, toSeq)) {
2489
- const ep = entry.envelope.params ?? {};
2490
- 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;
2491
2969
  if (filter.from && ep.from !== filter.from) continue;
2492
- if (filter.task_id && ep.task_id !== filter.task_id) continue;
2493
- const item = {
2494
- seq: entry.seq,
2495
- arrived: entry.arrived,
2496
- envelope: entry.envelope
2497
- };
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
+ }
2498
2985
  if (entry.prev_hash) item.prev_hash = entry.prev_hash;
2499
2986
  out.push(item);
2500
2987
  }
2501
- return { result: { entries: out, next_seq: toSeq } };
2988
+ return { result: { entries: out, next_seq: Math.min(toSeq, ws.audit.length) } };
2502
2989
  }
2503
2990
  // ==========================================================
2504
2991
  // review/1.0 handlers
@@ -2512,11 +2999,15 @@ var Coordinator = class {
2512
2999
  if (notMember) return notMember;
2513
3000
  const task = ws.tasks.get(p.task_id);
2514
3001
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
3002
+ if (REVIEW_REQUEST_STOPPED.includes(task.state)) {
3003
+ return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
3004
+ }
2515
3005
  const to = p.to;
2516
3006
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
2517
3007
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
2518
3008
  const now = this.now();
2519
- const rule = p.rule || "any_one_approves";
3009
+ const declaredRule = p.rule;
3010
+ const rule = declaredRule || "any_one_approves";
2520
3011
  if (!reviewRuleSupported(rule)) {
2521
3012
  return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
2522
3013
  }
@@ -2527,7 +3018,7 @@ var Coordinator = class {
2527
3018
  "A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
2528
3019
  ) };
2529
3020
  }
2530
- if (rule !== task.review.rule) {
3021
+ if (declaredRule !== void 0 && declaredRule !== task.review.rule) {
2531
3022
  return { error: rpcError(
2532
3023
  E.REVIEW_ALREADY_OPEN,
2533
3024
  `Cannot change the decision rule of an open review (currently ${task.review.rule})`
@@ -2719,16 +3210,34 @@ var Coordinator = class {
2719
3210
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2720
3211
  const notMember = this.requireMember(ws, p.from);
2721
3212
  if (notMember) return notMember;
2722
- 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;
2723
3215
  if (!orig) return { error: rpcError(E.PARAMS, "Unknown original task") };
2724
3216
  if (orig.state === "completed" || orig.state === "cancelled" || orig.state === "superseded") {
2725
3217
  return { error: rpcError(E.PARAMS, `Cannot escalate a terminal task: ${orig.state}`) };
2726
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
+ }
2727
3222
  const nt = p.new_task;
2728
3223
  const assignee = nt.assignee;
2729
- if (!assignee || !ws.members.has(assignee)) {
3224
+ if (typeof assignee !== "string" || !ws.members.has(assignee)) {
2730
3225
  return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
2731
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
+ }
2732
3241
  const now = this.now();
2733
3242
  const newId = this.ids.taskId();
2734
3243
  const newTask = {
@@ -2740,11 +3249,14 @@ var Coordinator = class {
2740
3249
  input: nt.input ?? {},
2741
3250
  created_at: now,
2742
3251
  updated_at: now,
2743
- mode: orig.mode,
3252
+ mode,
2744
3253
  supersedes: orig.id,
2745
3254
  history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
2746
3255
  paused: false
2747
3256
  };
3257
+ if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
3258
+ newTask.review_required = true;
3259
+ }
2748
3260
  ws.tasks.set(newId, newTask);
2749
3261
  orig.state = "escalated";
2750
3262
  orig.updated_at = now;
@@ -2885,11 +3397,13 @@ function wrapMcpToolCall(coord, workspace, options) {
2885
3397
  method: "task.update",
2886
3398
  params: { workspace, from: options.caller, task_id: taskId, state: "in_progress" }
2887
3399
  });
3400
+ const output = { result: options.result, citations };
3401
+ if (options.fulfils) output.fulfils = options.fulfils;
2888
3402
  const completeParams = {
2889
3403
  workspace,
2890
3404
  from: options.caller,
2891
3405
  task_id: taskId,
2892
- output: { result: options.result, citations }
3406
+ output
2893
3407
  };
2894
3408
  if (options.confidence !== void 0) completeParams.confidence = options.confidence;
2895
3409
  const completeResp = coord.dispatch({
@@ -2941,11 +3455,13 @@ function wrapA2aMessageExchange(coord, workspace, options) {
2941
3455
  state: "in_progress"
2942
3456
  }
2943
3457
  });
3458
+ const output = { received: options.received, citations: [citation] };
3459
+ if (options.fulfils) output.fulfils = options.fulfils;
2944
3460
  const completeParams = {
2945
3461
  workspace,
2946
3462
  from: options.bridgeUri,
2947
3463
  task_id: taskId,
2948
- output: { received: options.received, citations: [citation] }
3464
+ output
2949
3465
  };
2950
3466
  if (options.confidence !== void 0) completeParams.confidence = options.confidence;
2951
3467
  const completeResp = coord.dispatch({
@@ -2973,8 +3489,12 @@ export {
2973
3489
  deriveKeypair,
2974
3490
  deriveSeed,
2975
3491
  diffJsonPatch,
3492
+ entryCall,
3493
+ entryRecord,
3494
+ isRefusal,
2976
3495
  isValidEnvelope,
2977
3496
  jwkFromPrivateKey,
3497
+ linkHash,
2978
3498
  makeApi,
2979
3499
  makeDefaultPolicy,
2980
3500
  modeLE,