@brightbeamai/chap-coordinator 0.3.3 → 0.3.5

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.
Files changed (47) hide show
  1. package/README.md +54 -8
  2. package/dist/{api-59aU01Bs.d.cts → api-CjZQnj63.d.cts} +128 -14
  3. package/dist/{api-CxciGFHA.d.ts → api-DG0VFGVr.d.ts} +128 -14
  4. package/dist/api.cjs +3 -1
  5. package/dist/api.cjs.map +1 -1
  6. package/dist/api.d.cts +1 -1
  7. package/dist/api.d.ts +1 -1
  8. package/dist/api.js +3 -1
  9. package/dist/api.js.map +1 -1
  10. package/dist/index.cjs +1578 -276
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +20 -6
  13. package/dist/index.d.ts +20 -6
  14. package/dist/index.js +1577 -276
  15. package/dist/index.js.map +1 -1
  16. package/dist/jsonrpc.cjs +1 -0
  17. package/dist/jsonrpc.cjs.map +1 -1
  18. package/dist/jsonrpc.d.cts +1 -0
  19. package/dist/jsonrpc.d.ts +1 -0
  20. package/dist/jsonrpc.js +1 -0
  21. package/dist/jsonrpc.js.map +1 -1
  22. package/dist/methods.cjs.map +1 -1
  23. package/dist/methods.d.cts +36 -3
  24. package/dist/methods.d.ts +36 -3
  25. package/dist/patch.cjs +195 -61
  26. package/dist/patch.cjs.map +1 -1
  27. package/dist/patch.d.cts +16 -2
  28. package/dist/patch.d.ts +16 -2
  29. package/dist/patch.js +194 -61
  30. package/dist/patch.js.map +1 -1
  31. package/dist/storage/sqlite.cjs +81 -19
  32. package/dist/storage/sqlite.cjs.map +1 -1
  33. package/dist/storage/sqlite.d.cts +19 -9
  34. package/dist/storage/sqlite.d.ts +19 -9
  35. package/dist/storage/sqlite.js +81 -19
  36. package/dist/storage/sqlite.js.map +1 -1
  37. package/dist/storage/store.cjs +20 -15
  38. package/dist/storage/store.cjs.map +1 -1
  39. package/dist/storage/store.d.cts +24 -6
  40. package/dist/storage/store.d.ts +24 -6
  41. package/dist/storage/store.js +20 -15
  42. package/dist/storage/store.js.map +1 -1
  43. package/dist/types.cjs.map +1 -1
  44. package/dist/types.d.cts +56 -1
  45. package/dist/types.d.ts +56 -1
  46. package/dist/types.js.map +1 -1
  47. package/package.json +2 -2
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var src_exports = {};
22
22
  __export(src_exports, {
23
23
  Coordinator: () => Coordinator,
24
24
  CoordinatorError: () => CoordinatorError,
25
+ DEFAULT_MAX_PATCHED_BYTES: () => DEFAULT_MAX_PATCHED_BYTES,
25
26
  E: () => E,
26
27
  IdFactory: () => IdFactory,
27
28
  MemoryStore: () => MemoryStore,
@@ -61,7 +62,7 @@ module.exports = __toCommonJS(src_exports);
61
62
  var import_node_crypto = require("crypto");
62
63
  var ZERO_HASH = "sha256:" + "0".repeat(64);
63
64
  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.';
64
- 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.";
65
+ var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <= 2^53 - 1); represent larger numbers as strings.";
65
66
  var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
66
67
  function assertNoLoneSurrogate(s) {
67
68
  for (let i = 0; i < s.length; i++) {
@@ -82,7 +83,7 @@ function canonicalString(s) {
82
83
  function canonicalNumber(n) {
83
84
  if (!Number.isFinite(n)) {
84
85
  throw new Error(
85
- "Non-finite numbers are not permitted in a CHAP canonical value"
86
+ "Non-finite numbers are not permitted in a CHAP canonical value."
86
87
  );
87
88
  }
88
89
  if (!Number.isInteger(n)) {
@@ -155,6 +156,7 @@ var OWNING_PROFILE = Object.freeze({
155
156
  "participant.leave": "core/1.0",
156
157
  "participant.revoke_key": "core/1.0",
157
158
  "participant.rotate_key": "core/1.0",
159
+ "review.acknowledge": "review/1.0",
158
160
  "review.depth": "routing/1.0",
159
161
  "review.request": "review/1.0",
160
162
  "task.complete": "core/1.0",
@@ -165,6 +167,7 @@ var OWNING_PROFILE = Object.freeze({
165
167
  "whisper.ask": "whisper/1.0",
166
168
  "workspace.create": "core/1.0",
167
169
  "workspace.describe": "core/1.0",
170
+ "workspace.invite": "core/1.0",
168
171
  "workspace.set_profiles": "core/1.0"
169
172
  });
170
173
  var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
@@ -173,6 +176,20 @@ var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
173
176
  "audit.verify_chain",
174
177
  "audit.verify_receipt"
175
178
  ]);
179
+ var PROFILE_VERSIONS = Object.freeze({
180
+ "audit-scitt": "1.0",
181
+ "control": "1.0",
182
+ "core": "1.0",
183
+ "deliberation": "1.0",
184
+ "handoff": "1.0",
185
+ "identity-oidc": "1.0",
186
+ "identity-vc": "1.0",
187
+ "modes": "1.0",
188
+ "review": "1.0",
189
+ "routing": "1.0",
190
+ "security-signed": "1.0",
191
+ "whisper": "1.0"
192
+ });
176
193
 
177
194
  // src/crypto.ts
178
195
  var import_node_crypto2 = require("crypto");
@@ -224,21 +241,132 @@ function signEnvelope(canonical, privateKey, kid) {
224
241
  const sig = (0, import_node_crypto2.sign)(null, canonical, privateKey);
225
242
  return `ed25519:${kid}:${sig.toString("base64")}`;
226
243
  }
244
+ function strictBase64(s) {
245
+ const bytes = Buffer.from(s, "base64");
246
+ return bytes.toString("base64") === s ? bytes : void 0;
247
+ }
227
248
  function verifyEnvelope(canonical, sigTag, publicKey) {
228
249
  if (!sigTag.startsWith("ed25519:")) return false;
229
250
  const parts = sigTag.split(":");
230
251
  if (parts.length < 2) return false;
231
- const sigB64 = parts[parts.length - 1];
252
+ const sig = strictBase64(parts[parts.length - 1]);
253
+ if (sig === void 0) return false;
232
254
  try {
233
- const sig = Buffer.from(sigB64, "base64");
234
255
  return (0, import_node_crypto2.verify)(null, canonical, publicKey, sig);
235
256
  } catch {
236
257
  return false;
237
258
  }
238
259
  }
260
+ function isEd25519Key(jwk) {
261
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
262
+ const key = jwk;
263
+ return key.kty === "OKP" && key.crv === "Ed25519" && typeof key.x === "string" && COORDINATE.test(key.x);
264
+ }
239
265
  function publicKeyFromJwk(jwk) {
240
266
  return publicKeyFromRaw(b64urlDecode(jwk.x));
241
267
  }
268
+ function jwkThumbprint(jwk) {
269
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return void 0;
270
+ const key = jwk;
271
+ const names = key.kty === "OKP" ? ["crv", "kty", "x"] : key.kty === "EC" ? ["crv", "kty", "x", "y"] : void 0;
272
+ if (!names || names.some((n) => typeof key[n] !== "string")) return void 0;
273
+ const members = Object.fromEntries(names.map((n) => [n, key[n]]));
274
+ return b64urlNoPad((0, import_node_crypto2.createHash)("sha256").update(canonicalize(members)).digest());
275
+ }
276
+ var B64URL = /^[A-Za-z0-9_-]+$/;
277
+ function parseWebAuthnAssertion(part) {
278
+ const pieces = part.split(".");
279
+ if (pieces.length !== 3 || pieces.some((p) => !B64URL.test(p) || p.length % 4 === 1)) return void 0;
280
+ const [authenticatorData, clientDataJSON, signature] = pieces.map((p) => Buffer.from(p, "base64url"));
281
+ return { authenticatorData, clientDataJSON, signature };
282
+ }
283
+ var COORDINATE = /^[A-Za-z0-9_-]{43}$/;
284
+ function isWebAuthnKey(jwk) {
285
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
286
+ const key = jwk;
287
+ const w = key.webauthn;
288
+ return key.kty === "EC" && key.crv === "P-256" && typeof key.x === "string" && COORDINATE.test(key.x) && typeof key.y === "string" && COORDINATE.test(key.y) && (key.alg === void 0 || key.alg === "ES256") && w !== null && typeof w === "object" && !Array.isArray(w) && typeof w.rp_id === "string" && w.rp_id !== "" && (w.origins === void 0 || Array.isArray(w.origins) && w.origins.every((o) => typeof o === "string"));
289
+ }
290
+ var UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
291
+ var MAX_CLIENT_DATA_NESTING = 64;
292
+ function jsonDepth(text) {
293
+ let depth = 0;
294
+ let deepest = 0;
295
+ let inString = false;
296
+ for (let i = 0; i < text.length; i++) {
297
+ const ch = text[i];
298
+ if (inString) {
299
+ if (ch === "\\") i++;
300
+ else if (ch === '"') inString = false;
301
+ } else if (ch === '"') {
302
+ inString = true;
303
+ } else if (ch === "{" || ch === "[") {
304
+ deepest = Math.max(deepest, ++depth);
305
+ } else if (ch === "}" || ch === "]") {
306
+ depth--;
307
+ }
308
+ }
309
+ return deepest;
310
+ }
311
+ var UP = 1;
312
+ var UV = 4;
313
+ var BE = 8;
314
+ var BS = 16;
315
+ var AT = 64;
316
+ var ED = 128;
317
+ function webAuthnFailure(canonical, assertion, jwk) {
318
+ const w = jwk.webauthn;
319
+ if (!Array.isArray(w.origins) || w.origins.length === 0) return "WebAuthn key lists no origin";
320
+ let text;
321
+ try {
322
+ text = UTF8.decode(assertion.clientDataJSON);
323
+ } catch {
324
+ text = void 0;
325
+ }
326
+ if (text !== void 0 && jsonDepth(text) > MAX_CLIENT_DATA_NESTING) {
327
+ return `WebAuthn clientDataJSON nests deeper than ${MAX_CLIENT_DATA_NESTING} levels`;
328
+ }
329
+ let client;
330
+ try {
331
+ client = text === void 0 ? void 0 : JSON.parse(text);
332
+ } catch {
333
+ client = void 0;
334
+ }
335
+ if (client === null || typeof client !== "object" || Array.isArray(client)) {
336
+ return "WebAuthn clientDataJSON is not a JSON object";
337
+ }
338
+ const data = client;
339
+ if (data.type !== "webauthn.get") return "WebAuthn clientDataJSON type is not webauthn.get";
340
+ if (data.challenge !== b64urlNoPad((0, import_node_crypto2.createHash)("sha256").update(canonical).digest())) {
341
+ return "WebAuthn challenge is not the digest of this request";
342
+ }
343
+ if (!w.origins.includes(data.origin)) return "WebAuthn origin is not one the key allows";
344
+ if (data.crossOrigin === true || data.topOrigin !== void 0) {
345
+ return "WebAuthn assertion was made in a cross-origin frame";
346
+ }
347
+ const auth = assertion.authenticatorData;
348
+ if (auth.length < 37) return "WebAuthn authenticatorData is too short";
349
+ if (!auth.subarray(0, 32).equals((0, import_node_crypto2.createHash)("sha256").update(w.rp_id, "utf-8").digest())) {
350
+ return "WebAuthn rpIdHash does not match the key's rp_id";
351
+ }
352
+ const flags = auth[32];
353
+ if ((flags & (UP | UV)) !== (UP | UV)) return "WebAuthn assertion lacks user presence or verification";
354
+ if (flags & BS && !(flags & BE)) return "WebAuthn assertion is backed up but not backup eligible";
355
+ if (flags & AT) return "WebAuthn assertion carries attested credential data";
356
+ if (flags & ED ? auth.length === 37 : auth.length !== 37) {
357
+ return "WebAuthn authenticatorData does not end where its flags say";
358
+ }
359
+ try {
360
+ const key = (0, import_node_crypto2.createPublicKey)({
361
+ key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y },
362
+ format: "jwk"
363
+ });
364
+ const signed = Buffer.concat([auth, (0, import_node_crypto2.createHash)("sha256").update(assertion.clientDataJSON).digest()]);
365
+ return (0, import_node_crypto2.verify)("sha256", signed, { key, dsaEncoding: "der" }, assertion.signature) ? null : "Signature failed verification";
366
+ } catch {
367
+ return "Signature failed verification";
368
+ }
369
+ }
242
370
  function deriveKeypair(uri) {
243
371
  const seed = deriveSeed(uri);
244
372
  const privateKey = privateKeyFromSeed(seed);
@@ -355,6 +483,7 @@ var E = {
355
483
  SIG_KEY_REVOKED: -32072,
356
484
  SIG_ROTATION_KEY_MISMATCH: -32073,
357
485
  SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
486
+ SIG_ALREADY_ACCEPTED: -32075,
358
487
  // audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
359
488
  SCITT_UNREACHABLE: -32080,
360
489
  SCITT_STATEMENT_REJECTED: -32081,
@@ -387,6 +516,44 @@ function isValidEnvelope(env) {
387
516
  return e.jsonrpc === "2.0";
388
517
  }
389
518
 
519
+ // src/params.ts
520
+ var NAMES = {
521
+ string: "a string",
522
+ strings: "a list of strings",
523
+ boolean: "a boolean",
524
+ object: "an object",
525
+ list: "a list",
526
+ integer: "an integer"
527
+ };
528
+ function isKind(value, kind) {
529
+ switch (kind) {
530
+ case "string":
531
+ return typeof value === "string";
532
+ case "strings":
533
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
534
+ case "boolean":
535
+ return typeof value === "boolean";
536
+ case "object":
537
+ return typeof value === "object" && value !== null && !Array.isArray(value);
538
+ case "list":
539
+ return Array.isArray(value);
540
+ case "integer":
541
+ return typeof value === "number" && Number.isInteger(value);
542
+ }
543
+ }
544
+ function paramsError(params, checks) {
545
+ for (const [name, kind] of checks) {
546
+ let value = params;
547
+ for (const part of name.split(".")) {
548
+ value = typeof value === "object" && value !== null && !Array.isArray(value) ? value[part] : void 0;
549
+ }
550
+ if (value !== void 0 && value !== null && !isKind(value, kind)) {
551
+ return { error: rpcError(E.PARAMS, `${name} must be ${NAMES[kind]}`) };
552
+ }
553
+ }
554
+ return null;
555
+ }
556
+
390
557
  // src/audit.ts
391
558
  function entryRecord(entry) {
392
559
  if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
@@ -430,7 +597,8 @@ var UNRECORDED_CODES = /* @__PURE__ */ new Set([
430
597
  E.SIG_VERIFY_FAILED,
431
598
  E.SIG_KEY_NOT_FOUND,
432
599
  E.SIG_KEY_REVOKED,
433
- E.SIG_ROTATION_KEY_MISMATCH
600
+ E.SIG_ROTATION_KEY_MISMATCH,
601
+ E.SIG_ALREADY_ACCEPTED
434
602
  ]);
435
603
  var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
436
604
  function refusalIsRecorded(method, error, privileged) {
@@ -553,77 +721,116 @@ function deepEqual(a, b) {
553
721
  }
554
722
  return true;
555
723
  }
556
- function applyOne(doc, op) {
724
+ function addAt(doc, path, value, size, copied = false, movedFrom) {
725
+ const parts = splitPath(path);
726
+ if (parts.length === 0) {
727
+ if (size) {
728
+ size.reset(value);
729
+ if (copied || movedFrom !== void 0) size.carry(size);
730
+ }
731
+ return value;
732
+ }
733
+ const [parent, key] = navigate(doc, parts);
734
+ let newKey;
735
+ if (Array.isArray(parent)) {
736
+ if (key === "-") {
737
+ parent.push(value);
738
+ } else {
739
+ const idx = key;
740
+ if (idx < 0 || idx > parent.length) {
741
+ throw new PatchError(`Index out of range for add: ${path}`);
742
+ }
743
+ parent.splice(idx, 0, value);
744
+ }
745
+ } else if (parent !== null && typeof parent === "object") {
746
+ if (Object.prototype.hasOwnProperty.call(parent, key)) size?.take(parent[key]);
747
+ else newKey = key;
748
+ parent[key] = value;
749
+ } else {
750
+ throw new PatchError(`Cannot add into ${jsonType(parent)}`);
751
+ }
752
+ if (size) {
753
+ if (movedFrom === void 0) {
754
+ const inserted = size.insert(value, parts.length, newKey);
755
+ if (copied) size.carry(inserted);
756
+ } else {
757
+ if (newKey !== void 0) size.bytes += utf8Length(newKey);
758
+ if (parts.length > movedFrom) {
759
+ const moved = sizeOf(value);
760
+ size.depth = Math.max(size.depth, parts.length + moved.depth);
761
+ size.carry(moved);
762
+ }
763
+ }
764
+ }
765
+ return doc;
766
+ }
767
+ function removeAt(doc, path, size, moved = false) {
768
+ const parts = splitPath(path);
769
+ if (parts.length === 0) throw new PatchError("Cannot remove root.");
770
+ const [parent, key] = navigate(doc, parts);
771
+ if (Array.isArray(parent)) {
772
+ const idx = key === "-" ? -1 : key;
773
+ if (idx < 0 || idx >= parent.length) {
774
+ throw new PatchError(`Index out of range for remove: ${path}`);
775
+ }
776
+ if (size && !moved) size.take(parent[idx]);
777
+ parent.splice(idx, 1);
778
+ } else if (parent !== null && typeof parent === "object") {
779
+ if (!Object.prototype.hasOwnProperty.call(parent, key)) {
780
+ throw new PatchError(`Path not found for remove: ${path}`);
781
+ }
782
+ if (size) {
783
+ if (moved) size.bytes -= utf8Length(key);
784
+ else size.take(parent[key], key);
785
+ }
786
+ delete parent[key];
787
+ } else {
788
+ throw new PatchError(`Cannot remove from ${jsonType(parent)}`);
789
+ }
790
+ return doc;
791
+ }
792
+ function applyOne(doc, op, size) {
557
793
  const kind = op.op;
558
794
  const path = op.path ?? "";
559
795
  if (kind === "add") {
560
796
  if (!("value" in op)) throw new PatchError("'add' requires 'value'");
561
- const parts = splitPath(path);
562
- if (parts.length === 0) return op.value;
563
- const [parent, key] = navigate(doc, parts);
564
- if (Array.isArray(parent)) {
565
- if (key === "-") {
566
- parent.push(op.value);
567
- } else {
568
- const idx = key;
569
- if (idx < 0 || idx > parent.length) {
570
- throw new PatchError(`Index out of range for add: ${path}`);
571
- }
572
- parent.splice(idx, 0, op.value);
573
- }
574
- } else if (parent !== null && typeof parent === "object") {
575
- parent[key] = op.value;
576
- } else {
577
- throw new PatchError(`Cannot add into ${jsonType(parent)}`);
578
- }
579
- return doc;
797
+ return addAt(doc, path, clone(op.value), size);
580
798
  }
581
799
  if (kind === "replace") {
582
800
  if (!("value" in op)) throw new PatchError("'replace' requires 'value'");
583
801
  const parts = splitPath(path);
584
- if (parts.length === 0) return op.value;
802
+ const value = clone(op.value);
803
+ if (parts.length === 0) {
804
+ size?.reset(value);
805
+ return value;
806
+ }
585
807
  const [parent, key] = navigate(doc, parts);
586
808
  if (Array.isArray(parent)) {
587
809
  const idx = key === "-" ? -1 : key;
588
810
  if (idx < 0 || idx >= parent.length) {
589
811
  throw new PatchError(`Path not found for replace: ${path}`);
590
812
  }
591
- parent[idx] = op.value;
813
+ size?.take(parent[idx]);
814
+ parent[idx] = value;
592
815
  } else if (parent !== null && typeof parent === "object") {
593
816
  if (!Object.prototype.hasOwnProperty.call(parent, key)) {
594
817
  throw new PatchError(`Path not found for replace: ${path}`);
595
818
  }
596
- parent[key] = op.value;
819
+ size?.take(parent[key]);
820
+ parent[key] = value;
597
821
  } else {
598
822
  throw new PatchError(`Cannot replace in ${jsonType(parent)}`);
599
823
  }
824
+ size?.insert(value, parts.length);
600
825
  return doc;
601
826
  }
602
827
  if (kind === "remove") {
603
- const parts = splitPath(path);
604
- if (parts.length === 0) throw new PatchError("Cannot remove root.");
605
- const [parent, key] = navigate(doc, parts);
606
- if (Array.isArray(parent)) {
607
- const idx = key === "-" ? -1 : key;
608
- if (idx < 0 || idx >= parent.length) {
609
- throw new PatchError(`Index out of range for remove: ${path}`);
610
- }
611
- parent.splice(idx, 1);
612
- } else if (parent !== null && typeof parent === "object") {
613
- if (!Object.prototype.hasOwnProperty.call(parent, key)) {
614
- throw new PatchError(`Path not found for remove: ${path}`);
615
- }
616
- delete parent[key];
617
- } else {
618
- throw new PatchError(`Cannot remove from ${jsonType(parent)}`);
619
- }
620
- return doc;
828
+ return removeAt(doc, path, size);
621
829
  }
622
830
  if (kind === "copy") {
623
831
  const src = op.from;
624
832
  if (src === void 0) throw new PatchError("'copy' requires 'from'");
625
- const value = clone(getAt(doc, src));
626
- return applyOne(doc, { op: "add", path, value });
833
+ return addAt(doc, path, clone(getAt(doc, src)), size, true);
627
834
  }
628
835
  if (kind === "move") {
629
836
  const src = op.from;
@@ -631,9 +838,9 @@ function applyOne(doc, op) {
631
838
  if (path.startsWith(src + "/")) {
632
839
  throw new PatchError("'move' cannot move a location into its own child.");
633
840
  }
634
- const value = clone(getAt(doc, src));
635
- doc = applyOne(doc, { op: "remove", path: src });
636
- return applyOne(doc, { op: "add", path, value });
841
+ const value = getAt(doc, src);
842
+ doc = removeAt(doc, src, size, true);
843
+ return addAt(doc, path, value, size, false, splitPath(src).length);
637
844
  }
638
845
  if (kind === "test") {
639
846
  if (!("value" in op)) throw new PatchError("'test' requires 'value'");
@@ -645,29 +852,122 @@ function applyOne(doc, op) {
645
852
  }
646
853
  var MAX_PATCH_OPS = 1e3;
647
854
  var MAX_DOCUMENT_NODES = 1e5;
648
- function nodeCount(value) {
649
- if (Array.isArray(value)) {
650
- let n = 1;
651
- for (const v of value) n += nodeCount(v);
652
- return n;
653
- }
654
- if (value !== null && typeof value === "object") {
655
- let n = 1;
656
- for (const v of Object.values(value)) n += nodeCount(v);
657
- return n;
855
+ var MAX_DOCUMENT_DEPTH = 64;
856
+ var MAX_CARRIED_TIMES = 3;
857
+ var DEFAULT_MAX_PATCHED_BYTES = 1048576;
858
+ function utf8Length(s) {
859
+ const buffer = globalThis.Buffer;
860
+ if (typeof buffer === "function") {
861
+ return buffer.byteLength(s, "utf8");
862
+ }
863
+ let n = 0;
864
+ for (let i = 0; i < s.length; i++) {
865
+ const c = s.charCodeAt(i);
866
+ if (c < 128) n += 1;
867
+ else if (c < 2048) n += 2;
868
+ else if (c >= 55296 && c <= 56319 && i + 1 < s.length) {
869
+ const d = s.charCodeAt(i + 1);
870
+ if (d >= 56320 && d <= 57343) {
871
+ n += 4;
872
+ i++;
873
+ } else n += 3;
874
+ } else n += 3;
875
+ }
876
+ return n;
877
+ }
878
+ function sizeOf(value) {
879
+ if (value === null || typeof value !== "object") {
880
+ return { nodes: 1, bytes: typeof value === "string" ? utf8Length(value) : 0, depth: 1 };
881
+ }
882
+ let nodes = 0;
883
+ let bytes = 0;
884
+ let depth = 0;
885
+ const values = [value];
886
+ const levels = [1];
887
+ const visit = (x, level) => {
888
+ if (x !== null && typeof x === "object") {
889
+ values.push(x);
890
+ levels.push(level);
891
+ return;
892
+ }
893
+ nodes += 1;
894
+ if (level > depth) depth = level;
895
+ if (typeof x === "string") bytes += utf8Length(x);
896
+ };
897
+ while (values.length > 0) {
898
+ const v = values.pop();
899
+ const level = levels.pop();
900
+ nodes += 1;
901
+ if (level > depth) depth = level;
902
+ if (Array.isArray(v)) {
903
+ for (const x of v) visit(x, level + 1);
904
+ } else {
905
+ const o = v;
906
+ for (const k of Object.keys(o)) {
907
+ bytes += utf8Length(k);
908
+ visit(o[k], level + 1);
909
+ }
910
+ }
658
911
  }
659
- return 1;
912
+ return { nodes, bytes, depth };
660
913
  }
661
- function applyJsonPatch(doc, ops) {
914
+ var Size = class {
915
+ nodes = 0;
916
+ bytes = 0;
917
+ depth = 0;
918
+ /** What copies, and moves to a deeper place or onto the whole document, have carried. */
919
+ carriedNodes = 0;
920
+ carriedBytes = 0;
921
+ constructor(doc) {
922
+ this.reset(doc);
923
+ }
924
+ reset(doc) {
925
+ const s = sizeOf(doc);
926
+ this.nodes = s.nodes;
927
+ this.bytes = s.bytes;
928
+ this.depth = s.depth;
929
+ }
930
+ /** `value` goes in `segments` levels below the top, under a new `key`. */
931
+ insert(value, segments, key) {
932
+ const s = sizeOf(value);
933
+ this.nodes += s.nodes;
934
+ this.bytes += s.bytes + (key === void 0 ? 0 : utf8Length(key));
935
+ this.depth = Math.max(this.depth, segments + s.depth);
936
+ return s;
937
+ }
938
+ carry(s) {
939
+ this.carriedNodes += s.nodes;
940
+ this.carriedBytes += s.bytes;
941
+ }
942
+ /** `value` comes out, with its `key`. */
943
+ take(value, key) {
944
+ const s = sizeOf(value);
945
+ this.nodes -= s.nodes;
946
+ this.bytes -= s.bytes + (key === void 0 ? 0 : utf8Length(key));
947
+ }
948
+ };
949
+ function applyJsonPatch(doc, ops, limits = {}) {
950
+ const maxBytes = limits.maxStringBytes ?? DEFAULT_MAX_PATCHED_BYTES;
662
951
  if (ops.length > MAX_PATCH_OPS) {
663
952
  throw new PatchError(`Patch has too many operations (${ops.length} > ${MAX_PATCH_OPS})`);
664
953
  }
665
954
  let out = JSON.parse(JSON.stringify(doc));
955
+ const size = new Size(out);
666
956
  for (const op of ops) {
667
- out = applyOne(out, op);
668
- if (nodeCount(out) > MAX_DOCUMENT_NODES) {
957
+ out = applyOne(out, op, size);
958
+ if (size.depth > MAX_DOCUMENT_DEPTH) size.depth = sizeOf(out).depth;
959
+ if (size.nodes > MAX_DOCUMENT_NODES) {
669
960
  throw new PatchError(`Patched document exceeds the node limit (${MAX_DOCUMENT_NODES})`);
670
961
  }
962
+ if (size.bytes > maxBytes) {
963
+ throw new PatchError(`Patched document exceeds the size limit (${maxBytes} bytes of strings and keys)`);
964
+ }
965
+ if (size.depth > MAX_DOCUMENT_DEPTH) {
966
+ throw new PatchError(`Patched document nests deeper than ${MAX_DOCUMENT_DEPTH} levels`);
967
+ }
968
+ if (size.carriedNodes > MAX_CARRIED_TIMES * MAX_DOCUMENT_NODES || size.carriedBytes > MAX_CARRIED_TIMES * maxBytes) {
969
+ throw new PatchError(`Patch copies or moves more than its limit (${MAX_CARRIED_TIMES * MAX_DOCUMENT_NODES} values or ${MAX_CARRIED_TIMES * maxBytes} bytes of strings and keys)`);
970
+ }
671
971
  }
672
972
  return out;
673
973
  }
@@ -715,7 +1015,8 @@ var COLLECTIONS = [
715
1015
  "deliberations",
716
1016
  "handoffs",
717
1017
  "snapshots",
718
- "route_decisions"
1018
+ "route_decisions",
1019
+ "invitations"
719
1020
  ];
720
1021
  var IDEMPOTENCY_KEYS = "idempotency_keys";
721
1022
  var StoreConflictError = class extends Error {
@@ -726,7 +1027,7 @@ var StoreConflictError = class extends Error {
726
1027
  base;
727
1028
  constructor(workspace, held, base) {
728
1029
  super(
729
- `The store holds ${held} audit entries for workspace ${workspace} and this write expected ${base}, so nothing was written. Another coordinator may be writing to this workspace, the store may have been changed outside this coordinator, or this coordinator could not load the workspace when it started. Restart this coordinator to reload.`
1030
+ `The store holds ${held} audit entries for workspace ${workspace} and this write expected ${base}, so nothing was written. Another coordinator may be writing to this workspace, or the store may have been changed outside this coordinator. A coordinator reloads the workspace and answers the call as failed, and the call may be sent again.`
730
1031
  );
731
1032
  this.name = "StoreConflictError";
732
1033
  this.workspace = workspace;
@@ -803,20 +1104,24 @@ function assembleSnapshot(meta, entries, objects) {
803
1104
  var MemoryStore = class {
804
1105
  held = /* @__PURE__ */ new Map();
805
1106
  load() {
806
- const out = [];
807
- for (const [id, h] of this.held) {
808
- const objects = [];
809
- for (const [collection, items] of h.objects) {
810
- for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
811
- }
812
- out.push({
813
- id,
814
- data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
815
- version: h.version,
816
- updated_at: h.updated_at
817
- });
1107
+ return [...this.held.keys()].map((id) => this.loadOne(id));
1108
+ }
1109
+ loadOne(id) {
1110
+ const h = this.held.get(id);
1111
+ if (!h) return void 0;
1112
+ const objects = [];
1113
+ for (const [collection, items] of h.objects) {
1114
+ for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
818
1115
  }
819
- return out;
1116
+ return {
1117
+ id,
1118
+ data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
1119
+ version: h.version,
1120
+ updated_at: h.updated_at
1121
+ };
1122
+ }
1123
+ version(id) {
1124
+ return this.held.get(id)?.entries.length;
820
1125
  }
821
1126
  save(record) {
822
1127
  this.apply(changeFromRecord(record));
@@ -943,7 +1248,8 @@ function makeApi(coord) {
943
1248
  workspace: {
944
1249
  create: (p) => c("workspace.create", p),
945
1250
  describe: (p) => c("workspace.describe", p),
946
- set_profiles: (p) => c("workspace.set_profiles", p)
1251
+ set_profiles: (p) => c("workspace.set_profiles", p),
1252
+ invite: (p) => c("workspace.invite", p)
947
1253
  },
948
1254
  participant: {
949
1255
  join: (p) => c("participant.join", p),
@@ -959,6 +1265,7 @@ function makeApi(coord) {
959
1265
  },
960
1266
  review: {
961
1267
  request: (p) => c("review.request", p),
1268
+ acknowledge: (p) => c("review.acknowledge", p),
962
1269
  depth: (p) => c("review.depth", p)
963
1270
  },
964
1271
  decide: {
@@ -1032,10 +1339,17 @@ function registerAuditScitt(coord) {
1032
1339
  coord.handlers.set("audit.submit_to_scitt", (p) => {
1033
1340
  const ws = coord.workspaces.get(p.workspace);
1034
1341
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1342
+ const bad = paramsError(p, [
1343
+ ["range", "object"],
1344
+ ["range.from_seq", "integer"],
1345
+ ["range.to_seq", "integer"],
1346
+ ["issuer", "string"]
1347
+ ]);
1348
+ if (bad) return bad;
1035
1349
  const range = p.range ?? {};
1036
1350
  const fromSeq = range.from_seq ?? 0;
1037
1351
  const toSeq = range.to_seq ?? ws.audit.length;
1038
- const issuer = p.issuer ?? "service:coordinator";
1352
+ const issuer = p.issuer || "service:coordinator";
1039
1353
  if (!coord.options.scittSubmitter) {
1040
1354
  const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
1041
1355
  return { result: {
@@ -1065,7 +1379,7 @@ function registerAuditScitt(coord) {
1065
1379
  });
1066
1380
  coord.handlers.set("audit.verify_receipt", (p) => {
1067
1381
  const receipt = p.receipt;
1068
- if (typeof receipt !== "object" || receipt === null) {
1382
+ if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
1069
1383
  return { error: rpcError(E.PARAMS, "receipt must be an object") };
1070
1384
  }
1071
1385
  if (coord.options.verifyScittReceipt) {
@@ -1079,7 +1393,7 @@ function registerAuditScitt(coord) {
1079
1393
  if (!ok) return { error: rpcError(E.SCITT_RECEIPT_INVALID, "Receipt did not verify") };
1080
1394
  return { result: { verified: true } };
1081
1395
  }
1082
- return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No verifyScittReceipt hook configured; receipt not verified") };
1396
+ return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No receipt verifier is configured; receipt not verified") };
1083
1397
  });
1084
1398
  coord.handlers.set("audit.verify_chain", (p) => {
1085
1399
  const ws = coord.workspaces.get(p.workspace);
@@ -1087,7 +1401,7 @@ function registerAuditScitt(coord) {
1087
1401
  if (!(ws.chain_enabled || coord.options.enableChain)) {
1088
1402
  return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
1089
1403
  }
1090
- if (p.from_seq !== void 0 || p.to_seq !== void 0) {
1404
+ if (p.from_seq != null || p.to_seq != null) {
1091
1405
  return { error: rpcError(
1092
1406
  E.PARAMS,
1093
1407
  "Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
@@ -1149,13 +1463,21 @@ function registerAuditScitt(coord) {
1149
1463
  // src/profiles/control.ts
1150
1464
  var VALID_MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
1151
1465
  var VALID_SCOPES = /* @__PURE__ */ new Set(["task", "participant", "workspace"]);
1466
+ var SCOPE_ERROR = "scope must be task, participant or workspace";
1152
1467
  function registerControl(coord) {
1153
1468
  coord.handlers.set("control.pause", (p) => {
1154
1469
  const ws = coord.workspaces.get(p.workspace);
1155
1470
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1471
+ const bad = paramsError(p, [
1472
+ ["scope", "string"],
1473
+ ["participant_uri", "string"],
1474
+ ["uri", "string"],
1475
+ ["in_flight_policy", "string"]
1476
+ ]);
1477
+ if (bad) return bad;
1156
1478
  const scope = p.scope || "task";
1157
1479
  if (!VALID_SCOPES.has(scope)) {
1158
- return { error: rpcError(E.PARAMS, `scope must be task/participant/workspace`) };
1480
+ return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
1159
1481
  }
1160
1482
  if (scope === "task") {
1161
1483
  const task = ws.tasks.get(p.task_id);
@@ -1173,7 +1495,8 @@ function registerControl(coord) {
1173
1495
  }
1174
1496
  if (scope === "participant") {
1175
1497
  const uri = p.participant_uri || p.uri;
1176
- if (!uri || !ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1498
+ if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
1499
+ if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1177
1500
  ws.members.get(uri).paused = true;
1178
1501
  return { result: {
1179
1502
  scope: "participant",
@@ -1188,9 +1511,11 @@ function registerControl(coord) {
1188
1511
  coord.handlers.set("control.resume", (p) => {
1189
1512
  const ws = coord.workspaces.get(p.workspace);
1190
1513
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1514
+ const bad = paramsError(p, [["scope", "string"], ["participant_uri", "string"], ["uri", "string"]]);
1515
+ if (bad) return bad;
1191
1516
  const scope = p.scope || "task";
1192
1517
  if (!VALID_SCOPES.has(scope)) {
1193
- return { error: rpcError(E.PARAMS, `scope must be task/participant/workspace`) };
1518
+ return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
1194
1519
  }
1195
1520
  if (scope === "task") {
1196
1521
  const task = ws.tasks.get(p.task_id);
@@ -1208,7 +1533,9 @@ function registerControl(coord) {
1208
1533
  }
1209
1534
  if (scope === "participant") {
1210
1535
  const uri = p.participant_uri || p.uri;
1211
- if (!uri || !ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1536
+ if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
1537
+ if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1538
+ if (uri === p.from) return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not lift its own pause") };
1212
1539
  ws.members.get(uri).paused = false;
1213
1540
  return { result: { scope: "participant", participant_uri: uri, paused: false } };
1214
1541
  }
@@ -1223,6 +1550,12 @@ function registerControl(coord) {
1223
1550
  if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
1224
1551
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
1225
1552
  }
1553
+ if (openRequiredReviewAuthors(ws, task).includes(p.from)) {
1554
+ return { error: rpcError(
1555
+ E.NOT_AUTHORISED,
1556
+ "The authors of the work under review cannot end its review without a decision"
1557
+ ) };
1558
+ }
1226
1559
  task.state = "cancelled";
1227
1560
  task.updated_at = coord.now();
1228
1561
  task.history.push({ ts: task.updated_at, from: p.from, state: "cancelled", note: p.reason });
@@ -1231,6 +1564,8 @@ function registerControl(coord) {
1231
1564
  coord.handlers.set("control.snapshot", (p) => {
1232
1565
  const ws = coord.workspaces.get(p.workspace);
1233
1566
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1567
+ const bad = paramsError(p, [["include", "strings"], ["label", "string"]]);
1568
+ if (bad) return bad;
1234
1569
  if (Array.isArray(p.include) && p.include.length === 0) {
1235
1570
  return { error: rpcError(E.PARAMS, "include must not be empty") };
1236
1571
  }
@@ -1285,6 +1620,14 @@ function registerControl(coord) {
1285
1620
  coord.handlers.set("control.rollback", (p) => {
1286
1621
  const ws = coord.workspaces.get(p.workspace);
1287
1622
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1623
+ const bad = paramsError(p, [
1624
+ ["to_snapshot_artefact_id", "string"],
1625
+ ["snapshot_artefact_id", "string"],
1626
+ ["snapshot_id", "string"],
1627
+ ["what_to_restore", "strings"],
1628
+ ["reason", "string"]
1629
+ ]);
1630
+ if (bad) return bad;
1288
1631
  const snapId = p.to_snapshot_artefact_id || p.snapshot_artefact_id || p.snapshot_id;
1289
1632
  if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
1290
1633
  const snap = ws.snapshots.get(snapId);
@@ -1306,7 +1649,8 @@ function registerControl(coord) {
1306
1649
  for (const [uri, snapM] of snapByUri) {
1307
1650
  if (ws.members.has(uri)) {
1308
1651
  const cur = ws.members.get(uri);
1309
- cur.role = snapM.role ?? cur.role;
1652
+ const role = snapM.role ?? cur.role;
1653
+ if (role !== "admin" && cur.role !== "admin") cur.role = role;
1310
1654
  cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
1311
1655
  }
1312
1656
  }
@@ -1325,10 +1669,18 @@ function registerControl(coord) {
1325
1669
  const oldId = p.task_id;
1326
1670
  const old = ws.tasks.get(oldId);
1327
1671
  if (!old) return { error: rpcError(E.PARAMS, "Unknown task") };
1672
+ if (openRequiredReviewAuthors(ws, old).includes(p.from)) {
1673
+ return { error: rpcError(
1674
+ E.NOT_AUTHORISED,
1675
+ "The authors of the work under review cannot end its review without a decision"
1676
+ ) };
1677
+ }
1328
1678
  const newSpec = p.successor_task ?? {};
1329
- if (!newSpec.kind) {
1679
+ if (typeof newSpec.kind !== "string" || !newSpec.kind) {
1330
1680
  return { error: rpcError(E.PARAMS, "successor_task must include kind") };
1331
1681
  }
1682
+ const bad = paramsError(p, [["successor_task.assignee", "string"], ["reason", "string"]]);
1683
+ if (bad) return bad;
1332
1684
  const assignee = newSpec.assignee || old.assignee;
1333
1685
  if (typeof assignee !== "string" || !ws.members.has(assignee)) {
1334
1686
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
@@ -1375,6 +1727,7 @@ function registerControl(coord) {
1375
1727
  } else if ("review_required" in newSpec) {
1376
1728
  newTask.review_required = !!newSpec.review_required;
1377
1729
  }
1730
+ newTask.prior_authors = successorAuthors(old, p.from);
1378
1731
  ws.tasks.set(newId, newTask);
1379
1732
  old.state = "superseded";
1380
1733
  old.superseded_by = newId;
@@ -1385,26 +1738,37 @@ function registerControl(coord) {
1385
1738
  state: "superseded",
1386
1739
  note: `-> ${newId}: ${p.reason || ""}`
1387
1740
  });
1388
- return { result: { superseded_task_id: old.id, new_task_id: newId, reason: p.reason } };
1741
+ return { result: {
1742
+ superseded_task_id: old.id,
1743
+ new_task_id: newId,
1744
+ ...p.reason != null ? { reason: p.reason } : {}
1745
+ } };
1389
1746
  });
1390
1747
  coord.handlers.set("control.set_mode_ceiling", (p) => {
1391
1748
  const ws = coord.workspaces.get(p.workspace);
1392
1749
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1750
+ const bad = paramsError(p, [["reason", "string"]]);
1751
+ if (bad) return bad;
1393
1752
  const m = p.new_ceiling ?? p.mode_ceiling;
1394
1753
  if (!VALID_MODES.has(m)) {
1395
- return { error: rpcError(E.PARAMS, `new_ceiling must be shadow/trial/production`) };
1754
+ return { error: rpcError(E.PARAMS, "new_ceiling must be shadow, trial or production") };
1396
1755
  }
1397
1756
  ws.mode_ceiling = m;
1398
- return { result: { mode_ceiling: ws.mode_ceiling, reason: p.reason } };
1757
+ return { result: { mode_ceiling: ws.mode_ceiling, ...p.reason != null ? { reason: p.reason } : {} } };
1399
1758
  });
1400
1759
  }
1401
1760
 
1402
1761
  // src/profiles/deliberation.ts
1762
+ var RESERVED_IDS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1763
+ var QUORUM_ARG = /^[0-9]+$/;
1764
+ var THRESHOLD_ARG = /^[0-9]+(\.[0-9]+)?$/;
1403
1765
  function parseRule(rule) {
1404
- if (rule.includes(":")) {
1405
- const [kind, arg] = rule.split(":", 2);
1406
- if (kind === "quorum") return { kind, params: { n: parseInt(arg, 10) } };
1407
- if (kind === "weighted_vote" || kind === "weighted_vote_with_veto") {
1766
+ const colon = rule.indexOf(":");
1767
+ if (colon >= 0) {
1768
+ const kind = rule.slice(0, colon);
1769
+ const arg = rule.slice(colon + 1);
1770
+ if (kind === "quorum" && QUORUM_ARG.test(arg)) return { kind, params: { n: parseInt(arg, 10) } };
1771
+ if ((kind === "weighted_vote" || kind === "weighted_vote_with_veto") && THRESHOLD_ARG.test(arg)) {
1408
1772
  return { kind, params: { threshold: parseFloat(arg) } };
1409
1773
  }
1410
1774
  }
@@ -1489,6 +1853,23 @@ function registerDeliberation(coord) {
1489
1853
  for (const f of ["from", "rule"]) {
1490
1854
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1491
1855
  }
1856
+ for (const name of ["to", "participants"]) {
1857
+ const v = p[name];
1858
+ if (v != null && typeof v !== "string" && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) {
1859
+ return { error: rpcError(E.PARAMS, `${name} must be a string or a list of strings`) };
1860
+ }
1861
+ }
1862
+ if (typeof p.rule !== "string") return { error: rpcError(E.PARAMS, "rule must be a string") };
1863
+ const bad = paramsError(p, [["weights", "object"], ["veto", "object"]]);
1864
+ if (bad) return bad;
1865
+ const weights = p.weights ?? {};
1866
+ if (!Object.values(weights).every((w) => typeof w === "number" && Number.isInteger(w))) {
1867
+ return { error: rpcError(E.PARAMS, "weights must map participants to integers") };
1868
+ }
1869
+ const veto = p.veto ?? {};
1870
+ if (!Object.values(veto).every((v) => typeof v === "boolean")) {
1871
+ return { error: rpcError(E.PARAMS, "veto must map participants to booleans") };
1872
+ }
1492
1873
  const to = p.to ?? p.participants;
1493
1874
  const participants = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
1494
1875
  if (!participants.length) {
@@ -1500,6 +1881,18 @@ function registerDeliberation(coord) {
1500
1881
  const msg = e instanceof Error ? e.message : String(e);
1501
1882
  return { error: rpcError(E.DELIB_UNKNOWN_RULE, msg) };
1502
1883
  }
1884
+ const suppliedDeliberationId = p.deliberation_id;
1885
+ if (suppliedDeliberationId !== void 0 && suppliedDeliberationId !== null && suppliedDeliberationId !== "") {
1886
+ if (typeof suppliedDeliberationId !== "string") {
1887
+ return { error: rpcError(E.PARAMS, "deliberation_id must be a string") };
1888
+ }
1889
+ if (RESERVED_IDS.has(suppliedDeliberationId)) {
1890
+ return { error: rpcError(E.PARAMS, `deliberation_id cannot be ${JSON.stringify(suppliedDeliberationId)}`) };
1891
+ }
1892
+ if (ws.deliberations.has(suppliedDeliberationId)) {
1893
+ return { error: rpcError(E.PARAMS, `deliberation_id already in use: ${suppliedDeliberationId}`) };
1894
+ }
1895
+ }
1503
1896
  const id = p.deliberation_id || coord.ids.deliberationId();
1504
1897
  const delib = {
1505
1898
  id,
@@ -1527,6 +1920,8 @@ function registerDeliberation(coord) {
1527
1920
  if (delib.state !== "open") {
1528
1921
  return { error: rpcError(E.DELIB_CLOSED_OR_LAPSED, "Deliberation not open") };
1529
1922
  }
1923
+ const bad = paramsError(p, [["comment", "string"], ["text", "string"]]);
1924
+ if (bad) return bad;
1530
1925
  delib.comments.push({
1531
1926
  ts: coord.now(),
1532
1927
  from: p.from,
@@ -1549,6 +1944,8 @@ function registerDeliberation(coord) {
1549
1944
  if (v !== "yea" && v !== "nay" && v !== "abstain") {
1550
1945
  return { error: rpcError(E.PARAMS, "vote must be yea/nay/abstain") };
1551
1946
  }
1947
+ const bad = paramsError(p, [["veto_invoked", "boolean"]]);
1948
+ if (bad) return bad;
1552
1949
  const voter = p.from;
1553
1950
  if (!delib.participants.includes(voter)) {
1554
1951
  return { error: rpcError(
@@ -1582,6 +1979,7 @@ function registerDeliberation(coord) {
1582
1979
  }
1583
1980
 
1584
1981
  // src/profiles/handoff.ts
1982
+ var RESERVED_IDS2 = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1585
1983
  function registerHandoff(coord) {
1586
1984
  coord.handlers.set("handoff.propose", (p) => {
1587
1985
  const ws = coord.workspaces.get(p.workspace);
@@ -1589,6 +1987,9 @@ function registerHandoff(coord) {
1589
1987
  for (const f of ["from", "to", "tasks"]) {
1590
1988
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1591
1989
  }
1990
+ if (typeof p.to !== "string") return { error: rpcError(E.PARAMS, "to must be a string") };
1991
+ const bad = paramsError(p, [["context_links", "strings"]]);
1992
+ if (bad) return bad;
1592
1993
  const proposer = p.from;
1593
1994
  const recipient = p.to;
1594
1995
  if (typeof recipient === "string" && !recipient.startsWith("group:")) {
@@ -1605,6 +2006,11 @@ function registerHandoff(coord) {
1605
2006
  }
1606
2007
  const proposed = [];
1607
2008
  for (const t of tasksIn) {
2009
+ if (t === null || typeof t !== "object" || Array.isArray(t) || typeof t.task_id !== "string") {
2010
+ return { error: rpcError(E.PARAMS, "each entry in tasks must be an object with a string task_id") };
2011
+ }
2012
+ const badItem = paramsError(t, [["blockers", "strings"]]);
2013
+ if (badItem) return badItem;
1608
2014
  const tid = t.task_id;
1609
2015
  if (!tid || !ws.tasks.has(tid)) {
1610
2016
  return { error: rpcError(E.PARAMS, `Unknown task in handoff: ${tid}`) };
@@ -1624,6 +2030,18 @@ function registerHandoff(coord) {
1624
2030
  blockers: t.blockers ?? []
1625
2031
  });
1626
2032
  }
2033
+ const suppliedHandoffId = p.handoff_id;
2034
+ if (suppliedHandoffId !== void 0 && suppliedHandoffId !== null && suppliedHandoffId !== "") {
2035
+ if (typeof suppliedHandoffId !== "string") {
2036
+ return { error: rpcError(E.PARAMS, "handoff_id must be a string") };
2037
+ }
2038
+ if (RESERVED_IDS2.has(suppliedHandoffId)) {
2039
+ return { error: rpcError(E.PARAMS, `handoff_id cannot be ${JSON.stringify(suppliedHandoffId)}`) };
2040
+ }
2041
+ if (ws.handoffs.has(suppliedHandoffId)) {
2042
+ return { error: rpcError(E.PARAMS, `handoff_id already in use: ${suppliedHandoffId}`) };
2043
+ }
2044
+ }
1627
2045
  const id = p.handoff_id || coord.ids.handoffId();
1628
2046
  const ho = {
1629
2047
  id,
@@ -1674,6 +2092,8 @@ function registerHandoff(coord) {
1674
2092
  if (ws.members.get(acceptor).paused) {
1675
2093
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
1676
2094
  }
2095
+ const bad = paramsError(p, [["accepted_task_ids", "strings"]]);
2096
+ if (bad) return bad;
1677
2097
  if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
1678
2098
  return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
1679
2099
  }
@@ -1684,9 +2104,18 @@ function registerHandoff(coord) {
1684
2104
  return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
1685
2105
  }
1686
2106
  }
2107
+ for (const tid of acceptedIds) {
2108
+ if (ws.tasks.get(tid)?.assignee !== ho.proposer) {
2109
+ return { error: rpcError(
2110
+ E.HANDOFF_TASKS_NOT_ASSIGNED_TO_PROPOSER,
2111
+ `Task ${tid} is not currently assigned to ${ho.proposer}`
2112
+ ) };
2113
+ }
2114
+ }
1687
2115
  const now = coord.now();
1688
2116
  for (const tid of acceptedIds) {
1689
2117
  const task = ws.tasks.get(tid);
2118
+ if (acceptor !== task.assignee) task.prior_authors = unionOf(task.prior_authors ?? [], heldBy(task, ho.proposer));
1690
2119
  task.assignee = acceptor;
1691
2120
  task.updated_at = now;
1692
2121
  task.history.push({
@@ -1727,16 +2156,20 @@ function registerHandoff(coord) {
1727
2156
 
1728
2157
  // src/profiles/routing.ts
1729
2158
  var CRIT = { low: 0, medium: 1, high: 2, critical: 3 };
1730
- function defaultDepth(hints) {
2159
+ var DECIMAL = /^-?[0-9]+(\.[0-9]+)?$/;
2160
+ function confidenceOf(hints) {
2161
+ const v = hints.confidence;
2162
+ if (typeof v === "number" && Number.isInteger(v)) return v;
2163
+ if (typeof v === "string" && DECIMAL.test(v)) return Number(v);
2164
+ return 1;
2165
+ }
2166
+ function criticalityOf(hints) {
1731
2167
  const crit = hints.criticality ?? "medium";
1732
- let conf = 1;
1733
- try {
1734
- conf = Number(hints.confidence ?? 1);
1735
- } catch {
1736
- conf = 1;
1737
- }
1738
- if (isNaN(conf)) conf = 1;
1739
- const r = CRIT[crit] ?? 1;
2168
+ return typeof crit === "string" && Object.prototype.hasOwnProperty.call(CRIT, crit) ? CRIT[crit] : 1;
2169
+ }
2170
+ function defaultDepth(hints) {
2171
+ const conf = confidenceOf(hints);
2172
+ const r = criticalityOf(hints);
1740
2173
  if (r >= 3) return { depth: "full", summary: "criticality=critical: full review" };
1741
2174
  if (r === 2 && conf < 0.7) return { depth: "full", summary: "criticality=high and confidence<0.7: full review" };
1742
2175
  if (r === 0 && conf >= 0.95) return { depth: "skip", summary: "very low criticality + very high confidence: skip" };
@@ -1802,6 +2235,8 @@ function registerRouting(coord) {
1802
2235
  if (ws.members.get(selected).paused) {
1803
2236
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
1804
2237
  }
2238
+ const leaving = heldBy(task, p.from);
2239
+ if (selected !== task.assignee && leaving.length) task.prior_authors = unionOf(task.prior_authors ?? [], leaving);
1805
2240
  task.assignee = selected;
1806
2241
  task.updated_at = coord.now();
1807
2242
  task.history.push({
@@ -1832,6 +2267,8 @@ function registerRouting(coord) {
1832
2267
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1833
2268
  const task = ws.tasks.get(p.task_id);
1834
2269
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2270
+ const bad = paramsError(p, [["artefact_routing_hints", "object"]]);
2271
+ if (bad) return bad;
1835
2272
  const hints = {
1836
2273
  ...task.routing_hints ?? {},
1837
2274
  ...p.artefact_routing_hints ?? {}
@@ -1889,6 +2326,8 @@ function registerRouting(coord) {
1889
2326
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1890
2327
  const task = ws.tasks.get(p.task_id);
1891
2328
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2329
+ const bad = paramsError(p, [["default_escalation_target", "string"]]);
2330
+ if (bad) return bad;
1892
2331
  const hints = { ...task.routing_hints ?? {} };
1893
2332
  let escalate, to, rule;
1894
2333
  if (coord.options.escalationPolicy) {
@@ -1902,15 +2341,8 @@ function registerRouting(coord) {
1902
2341
  return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `escalation policy error: ${msg}`) };
1903
2342
  }
1904
2343
  } else {
1905
- const crit = hints.criticality ?? "medium";
1906
- let conf = 1;
1907
- try {
1908
- conf = Number(hints.confidence ?? 1);
1909
- } catch {
1910
- conf = 1;
1911
- }
1912
- if (isNaN(conf)) conf = 1;
1913
- const r = CRIT[crit] ?? 1;
2344
+ const conf = confidenceOf(hints);
2345
+ const r = criticalityOf(hints);
1914
2346
  escalate = r >= 3 || r >= 2 && conf < 0.6;
1915
2347
  to = p.default_escalation_target;
1916
2348
  rule = {
@@ -1967,6 +2399,9 @@ function registerSecuritySigned(coord) {
1967
2399
  for (const f of ["from", "old_kid", "new_jwk"]) {
1968
2400
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1969
2401
  }
2402
+ for (const f of ["from", "old_kid"]) {
2403
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
2404
+ }
1970
2405
  const member = ws.members.get(p.from);
1971
2406
  if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown participant: ${p.from}`) };
1972
2407
  const oldKid = p.old_kid;
@@ -1977,6 +2412,9 @@ function registerSecuritySigned(coord) {
1977
2412
  if (!newJwk || typeof newJwk.kid !== "string") {
1978
2413
  return { error: rpcError(E.PARAMS, "new_jwk must include kid") };
1979
2414
  }
2415
+ if (member.keys.some((k) => k.kid === newJwk.kid)) {
2416
+ return { error: rpcError(E.PARAMS, `${p.from} already holds a key ${newJwk.kid}; a new key takes a kid of its own`) };
2417
+ }
1980
2418
  const now = coord.now();
1981
2419
  oldKey.valid_until = now;
1982
2420
  member.keys.push({
@@ -1992,6 +2430,11 @@ function registerSecuritySigned(coord) {
1992
2430
  for (const f of ["target_uri", "kid"]) {
1993
2431
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1994
2432
  }
2433
+ for (const f of ["target_uri", "kid"]) {
2434
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
2435
+ }
2436
+ const bad = paramsError(p, [["from", "string"], ["reason", "string"]]);
2437
+ if (bad) return bad;
1995
2438
  const sender = p.from;
1996
2439
  if (sender !== p.target_uri) {
1997
2440
  const caller = sender ? ws.members.get(sender) : void 0;
@@ -2014,6 +2457,7 @@ function registerSecuritySigned(coord) {
2014
2457
  }
2015
2458
 
2016
2459
  // src/profiles/whisper.ts
2460
+ var RESERVED_IDS3 = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
2017
2461
  function parseIso(ts) {
2018
2462
  return new Date(ts).getTime();
2019
2463
  }
@@ -2033,11 +2477,25 @@ function registerWhisper(coord) {
2033
2477
  if (!ws.tasks.has(p.task_id)) {
2034
2478
  return { error: rpcError(E.PARAMS, "Unknown task") };
2035
2479
  }
2036
- if (p.options !== void 0 && p.options !== null && !Array.isArray(p.options)) {
2037
- return { error: rpcError(E.PARAMS, "options must be a list") };
2480
+ const bad = paramsError(p, [["options", "list"], ["urgency", "string"]]);
2481
+ if (bad) return bad;
2482
+ if (typeof p.deadline_ms !== "number" || !Number.isInteger(p.deadline_ms)) {
2483
+ return { error: rpcError(E.PARAMS, "deadline_ms must be an integer") };
2038
2484
  }
2039
2485
  const to = p.to;
2040
2486
  const askee = Array.isArray(to) ? to : [to];
2487
+ const suppliedWhisperId = p.whisper_id;
2488
+ if (suppliedWhisperId !== void 0 && suppliedWhisperId !== null && suppliedWhisperId !== "") {
2489
+ if (typeof suppliedWhisperId !== "string") {
2490
+ return { error: rpcError(E.PARAMS, "whisper_id must be a string") };
2491
+ }
2492
+ if (RESERVED_IDS3.has(suppliedWhisperId)) {
2493
+ return { error: rpcError(E.PARAMS, `whisper_id cannot be ${JSON.stringify(suppliedWhisperId)}`) };
2494
+ }
2495
+ if (ws.whispers.has(suppliedWhisperId)) {
2496
+ return { error: rpcError(E.PARAMS, `whisper_id already in use: ${suppliedWhisperId}`) };
2497
+ }
2498
+ }
2041
2499
  const id = p.whisper_id || coord.ids.artefactId();
2042
2500
  const prompt = {
2043
2501
  id,
@@ -2085,7 +2543,7 @@ function registerWhisper(coord) {
2085
2543
  if (!hasOption) {
2086
2544
  return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
2087
2545
  }
2088
- const valid = new Set(prompt.options.map((o) => o.id));
2546
+ const valid = new Set(prompt.options.filter((o) => o !== null && typeof o === "object" && !Array.isArray(o)).map((o) => o.id));
2089
2547
  if (!valid.has(answerOption)) {
2090
2548
  return { error: rpcError(
2091
2549
  E.WHISPER_OPTION_NOT_IN_SET,
@@ -2103,12 +2561,13 @@ function registerWhisper(coord) {
2103
2561
  prompt.comment = p.comment;
2104
2562
  return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
2105
2563
  });
2106
- coord.checkWhisperLapses = (workspaceId, now) => {
2564
+ coord.checkWhisperLapses = (workspaceId, now) => coord.outsideCall(workspaceId, () => lapse(workspaceId, now));
2565
+ function lapse(workspaceId, now) {
2107
2566
  const ws = coord.workspaces.get(workspaceId);
2108
2567
  if (!ws) return [];
2109
2568
  const cutoff = parseIso(now ?? coord.now());
2110
2569
  const emitted = [];
2111
- for (const prompt of ws.whispers.values()) {
2570
+ for (const prompt of rawValues(ws.whispers)) {
2112
2571
  if (prompt.state !== "pending") continue;
2113
2572
  const asked = parseIso(prompt.asked_at);
2114
2573
  const deadline = asked + prompt.deadline_ms;
@@ -2138,7 +2597,7 @@ function registerWhisper(coord) {
2138
2597
  emitted.push(notify);
2139
2598
  }
2140
2599
  return emitted;
2141
- };
2600
+ }
2142
2601
  }
2143
2602
 
2144
2603
  // src/coordinator.ts
@@ -2151,6 +2610,7 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
2151
2610
  "control.rollback",
2152
2611
  "control.set_mode_ceiling",
2153
2612
  "workspace.set_profiles",
2613
+ "workspace.invite",
2154
2614
  "participant.rotate_key",
2155
2615
  "participant.revoke_key"
2156
2616
  ]);
@@ -2194,12 +2654,9 @@ function missing(params, fields) {
2194
2654
  for (const f of fields) if (!(f in params)) return f;
2195
2655
  return null;
2196
2656
  }
2197
- function tagsError(p) {
2198
- const tags = p.tags;
2199
- if (tags !== void 0 && (!Array.isArray(tags) || !tags.every((t) => typeof t === "string"))) {
2200
- return { error: rpcError(E.PARAMS, "tags must be a list of strings") };
2201
- }
2202
- return null;
2657
+ var MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
2658
+ function isObject(value) {
2659
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2203
2660
  }
2204
2661
  function reviewRuleSupported(rule) {
2205
2662
  if (rule === "any_one_approves" || rule === "all_approve") return true;
@@ -2220,8 +2677,214 @@ function reviewSatisfied(review) {
2220
2677
  if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
2221
2678
  return approvers.size >= 1;
2222
2679
  }
2680
+ function advertises(ws, name) {
2681
+ return ws.profiles.some((p) => p === name || p.startsWith(name + "/"));
2682
+ }
2683
+ function knownProfile(id) {
2684
+ const m = /^([a-z][a-z0-9-]*)\/(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(id);
2685
+ if (!m || !Object.prototype.hasOwnProperty.call(PROFILE_VERSIONS, m[1])) return false;
2686
+ return PROFILE_VERSIONS[m[1]].split(".")[0] === m[2];
2687
+ }
2688
+ var LASTING_PROFILES = ["security-signed", "identity-oidc", "identity-vc"];
2689
+ function profilesError(profiles) {
2690
+ const unknown = profiles.find((x) => !knownProfile(x));
2691
+ if (unknown === void 0) return null;
2692
+ return { error: rpcError(
2693
+ E.PARAMS,
2694
+ `Unknown profile ${JSON.stringify(unknown)}: a profile id is <name>/<major>.<minor> for a profile the catalogue lists`
2695
+ ) };
2696
+ }
2697
+ var RESERVED_KEYS = ["__proto__", "constructor", "prototype"];
2698
+ var PARTICIPANT_TYPES = ["human", "agent", "service", "group", "workspace"];
2699
+ var ADMIN_TYPES = ["human", "service"];
2700
+ function participantError(type, uri, field) {
2701
+ if (!PARTICIPANT_TYPES.includes(type)) {
2702
+ return { error: rpcError(E.PARAMS, "type must be human, agent, service, group or workspace") };
2703
+ }
2704
+ if (!uri.startsWith(`${type}:`)) {
2705
+ return { error: rpcError(E.PARAMS, `${field} must start with "${type}:" for type ${type}`) };
2706
+ }
2707
+ if (type === "group" || type === "workspace") {
2708
+ return { error: rpcError(E.PARAMS, "group: and workspace: URIs name address scopes, which cannot join") };
2709
+ }
2710
+ return null;
2711
+ }
2712
+ function inForce(key, now) {
2713
+ return !(key.revoked_at !== void 0 && now >= key.revoked_at) && !(key.valid_until !== void 0 && now >= key.valid_until);
2714
+ }
2715
+ function sameKey(a, b) {
2716
+ const material = (k) => jwkThumbprint(k) ?? canonicalize(k).toString("utf-8");
2717
+ return a.kid === b.kid && material(a) === material(b);
2718
+ }
2719
+ function credentialKeys(cnf, nonce, joinKeys) {
2720
+ if (isObject(cnf)) {
2721
+ const kid = typeof cnf.kid === "string" && cnf.kid !== "" ? cnf.kid : jwkThumbprint(cnf);
2722
+ if (kid === void 0) return null;
2723
+ const key = { ...cnf, kid };
2724
+ return joinKeys.every((j) => sameKey(key, j)) ? [key] : null;
2725
+ }
2726
+ if (typeof nonce === "string") return joinKeys.every((j) => jwkThumbprint(j) === nonce) ? joinKeys : null;
2727
+ return void 0;
2728
+ }
2729
+ function rawInvitation(ws, uri) {
2730
+ return Map.prototype.get.call(ws.invitations, uri);
2731
+ }
2732
+ function awaitsFirstMember(ws) {
2733
+ if (ws.members.size > 0) return false;
2734
+ return !ws.audit.some((e) => e.outcome == null && entryCall(e)?.method === "participant.join");
2735
+ }
2736
+ function signingKeys(jwks) {
2737
+ const keys = isObject(jwks) && Array.isArray(jwks.keys) ? jwks.keys : [];
2738
+ return keys.filter((j) => isObject(j) && typeof j.kid === "string" && j.kid !== "");
2739
+ }
2740
+ function recordedForm(envelope) {
2741
+ if (envelope.method !== "participant.join" || !isObject(envelope.params)) return envelope;
2742
+ const params = { ...envelope.params };
2743
+ if (typeof params.oidc_token === "string") params.oidc_token = sha256Hex(params.oidc_token);
2744
+ if (isObject(params.vc_presentation)) params.vc_presentation = contentHash(params.vc_presentation);
2745
+ return { ...envelope, params };
2746
+ }
2747
+ function storeFailed() {
2748
+ return rpcError(E.INTERNAL, "Internal error", { reason: "store" });
2749
+ }
2750
+ function targetOf(envelope) {
2751
+ const params = isObject(envelope) ? envelope.params : void 0;
2752
+ const id = isObject(params) ? params.workspace : void 0;
2753
+ return typeof id === "string" && id !== "" ? id : void 0;
2754
+ }
2755
+ var KEPT_NULLS = { whispers: ["default_if_lapsed"] };
2756
+ function dropNulls(o, keep = []) {
2757
+ for (const key of Object.keys(o)) if (o[key] === null && !keep.includes(key)) delete o[key];
2758
+ }
2759
+ function shapeObject(name, item) {
2760
+ if (name === "route_decisions") {
2761
+ const extra = item.extra;
2762
+ delete item.extra;
2763
+ if (isObject(extra)) Object.assign(item, extra);
2764
+ item.kind = "route_decision";
2765
+ }
2766
+ dropNulls(item, KEPT_NULLS[name]);
2767
+ const each = (o, field, f) => {
2768
+ if (!Array.isArray(o[field])) return;
2769
+ for (const x of o[field]) {
2770
+ if (isObject(x)) {
2771
+ dropNulls(x);
2772
+ f?.(x);
2773
+ }
2774
+ }
2775
+ };
2776
+ if (name === "members") each(item, "keys");
2777
+ if (name === "tasks") {
2778
+ each(item, "history", (h) => {
2779
+ if (h.from === void 0 && typeof h.from_ === "string") h.from = h.from_;
2780
+ delete h.from_;
2781
+ });
2782
+ if (isObject(item.review)) {
2783
+ dropNulls(item.review);
2784
+ each(item.review, "decisions");
2785
+ }
2786
+ }
2787
+ if (name === "deliberations") each(item, "votes");
2788
+ if (name === "handoffs") {
2789
+ each(item, "tasks");
2790
+ if (Array.isArray(item.accepted_task_ids) && item.accepted_task_ids.length === 0) delete item.accepted_task_ids;
2791
+ }
2792
+ return item;
2793
+ }
2794
+ function storedCopy(name, item) {
2795
+ return shapeObject(name, structuredClone(item));
2796
+ }
2797
+ function shapeSnapshot(w) {
2798
+ const out = { ...w };
2799
+ dropNulls(out);
2800
+ for (const name of COLLECTIONS) {
2801
+ if (Array.isArray(out[name])) {
2802
+ out[name] = out[name].map((x) => isObject(x) ? storedCopy(name, x) : x);
2803
+ }
2804
+ }
2805
+ return out;
2806
+ }
2807
+ function reviewRequired(ws, task) {
2808
+ return task.review_required === true || task.mode === "trial" && advertises(ws, "modes");
2809
+ }
2810
+ function rawMember(ws, uri) {
2811
+ return typeof uri === "string" ? Map.prototype.get.call(ws.members, uri) : void 0;
2812
+ }
2813
+ function unionOf(...lists) {
2814
+ const out = [];
2815
+ for (const list of lists) for (const uri of list) if (!out.includes(uri)) out.push(uri);
2816
+ return out;
2817
+ }
2818
+ function authorsOf(task, completer) {
2819
+ const authors = [task.assignee];
2820
+ if (typeof completer === "string" && completer !== task.assignee) authors.push(completer);
2821
+ return unionOf(authors, Array.isArray(task.prior_authors) ? task.prior_authors : []);
2822
+ }
2823
+ function stateBefore(history, i) {
2824
+ for (let j = i - 1; j >= 0; j--) {
2825
+ if (history[j].state !== "paused") return history[j].state;
2826
+ }
2827
+ return void 0;
2828
+ }
2829
+ function workCompletion(history, i) {
2830
+ if (history[i].state !== "completed") return false;
2831
+ const before = stateBefore(history, i);
2832
+ return before !== "review_requested" && before !== "completed";
2833
+ }
2834
+ function lastCompleter(task) {
2835
+ for (let i = task.history.length - 1; i >= 0; i--) {
2836
+ if (workCompletion(task.history, i)) return task.history[i].from;
2837
+ }
2838
+ return void 0;
2839
+ }
2840
+ var OPENED_ON_COMPLETE = "review required; opened on task.complete";
2841
+ var NEW_ROUND_ON_COMPLETE = "a new round of the review; opened on task.complete";
2842
+ function storedAuthors(task) {
2843
+ let requester;
2844
+ const withRequester = (authors) => unionOf(authors, requester !== void 0 ? [requester] : []);
2845
+ for (let i = task.history.length - 1; i >= 0; i--) {
2846
+ const h = task.history[i];
2847
+ const opens = h.state === "review_requested" && stateBefore(task.history, i) !== "review_requested";
2848
+ if (opens && (h.note === OPENED_ON_COMPLETE || h.note === NEW_ROUND_ON_COMPLETE)) return withRequester(authorsOf(task, h.from));
2849
+ if (opens && requester === void 0) requester = h.from;
2850
+ if (workCompletion(task.history, i)) return withRequester(authorsOf(task, h.from));
2851
+ }
2852
+ return withRequester(authorsOf(task, void 0));
2853
+ }
2854
+ var ENDED = ["completed", "declined", "cancelled", "superseded", "escalated"];
2855
+ function reviewHolds(ws, task) {
2856
+ const seen = /* @__PURE__ */ new Set();
2857
+ for (let t = task; t && !seen.has(t.id); t = typeof t.supersedes === "string" ? ws.tasks.get(t.supersedes) : void 0) {
2858
+ seen.add(t.id);
2859
+ if (!reviewRequired(ws, t)) return [];
2860
+ if (t.review) {
2861
+ const decided = t.review.decisions.some((d) => d.kind === "reject" || d.kind === "override") || reviewSatisfied(t.review);
2862
+ return decided ? [] : t.review.authors;
2863
+ }
2864
+ }
2865
+ return [];
2866
+ }
2867
+ function openRequiredReviewAuthors(ws, task) {
2868
+ return ENDED.includes(task.state) ? [] : reviewHolds(ws, task);
2869
+ }
2870
+ function heldBy(task, mover) {
2871
+ return mover === task.assignee ? [task.assignee] : [];
2872
+ }
2873
+ function successorAuthors(old, mover) {
2874
+ const completer = lastCompleter(old);
2875
+ return unionOf(
2876
+ old.review ? old.review.authors : [],
2877
+ heldBy(old, mover),
2878
+ completer !== void 0 ? [completer] : [],
2879
+ Array.isArray(old.prior_authors) ? old.prior_authors : []
2880
+ );
2881
+ }
2882
+ function isScope(uri) {
2883
+ return typeof uri === "string" && (uri.startsWith("workspace:") || uri.startsWith("group:"));
2884
+ }
2223
2885
  function reply(env, body) {
2224
- const out = { jsonrpc: "2.0", id: env.id };
2886
+ const sent = env !== null && typeof env === "object" && !Array.isArray(env) ? env.id : void 0;
2887
+ const out = { jsonrpc: "2.0", id: sent ?? null };
2225
2888
  if (body.error !== void 0) out.error = body.error;
2226
2889
  else out.result = body.result;
2227
2890
  return out;
@@ -2236,7 +2899,7 @@ function indexSigned(index, entry) {
2236
2899
  return;
2237
2900
  }
2238
2901
  if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
2239
- else index.accepted.add(digest);
2902
+ else if (!index.accepted.has(digest)) index.accepted.set(digest, entry.seq);
2240
2903
  }
2241
2904
  function isThenable(value) {
2242
2905
  return typeof value?.then === "function";
@@ -2264,13 +2927,21 @@ var Coordinator = class {
2264
2927
  unflushed = /* @__PURE__ */ new Set();
2265
2928
  /** Workspaces with a write queued behind an async write in flight. */
2266
2929
  flushQueued = /* @__PURE__ */ new Set();
2930
+ /**
2931
+ * Workspaces whose store copy may differ from memory after a synchronous
2932
+ * write failed or conflicted. Each is reloaded from the store before the
2933
+ * next call on it, which is refused while the reload fails.
2934
+ */
2935
+ held = /* @__PURE__ */ new Set();
2936
+ /** Entries appended and not yet written, which the audit listeners hear of once they are. */
2937
+ unannounced = /* @__PURE__ */ new WeakMap();
2267
2938
  clockMs;
2268
2939
  frozenNow;
2269
2940
  auditListeners = [];
2270
2941
  /**
2271
2942
  * The signed calls on each workspace's log, by what their senders signed:
2272
- * recorded refusals with their seq and code, and accepted calls. Rebuilt
2273
- * from the log when missing, so a snapshot never carries it.
2943
+ * recorded refusals with their seq and code, and accepted calls with their
2944
+ * seq. Rebuilt from the log when missing, so a snapshot never carries it.
2274
2945
  */
2275
2946
  signedIndexes = /* @__PURE__ */ new WeakMap();
2276
2947
  /** Method handler registry; profiles plug into this. */
@@ -2406,6 +3077,7 @@ var Coordinator = class {
2406
3077
  change = this.takeChange(ws, p);
2407
3078
  } catch (err) {
2408
3079
  p.replace = true;
3080
+ if (this.options.storeErrors !== "log") this.held.add(ws.id);
2409
3081
  this.reportStoreError(err, { id: ws.id, version: ws.audit.length });
2410
3082
  return;
2411
3083
  }
@@ -2414,6 +3086,7 @@ var Coordinator = class {
2414
3086
  const done = () => {
2415
3087
  p.seq = taken.version;
2416
3088
  if (taken.replace) p.replace = false;
3089
+ this.announce(ws, taken.version);
2417
3090
  };
2418
3091
  const failed = (err) => {
2419
3092
  this.giveBack(ws, p, taken);
@@ -2423,13 +3096,34 @@ var Coordinator = class {
2423
3096
  try {
2424
3097
  result = this.store.apply(taken);
2425
3098
  } catch (err) {
3099
+ if (err instanceof StoreConflictError || this.options.storeErrors !== "log") this.held.add(ws.id);
2426
3100
  failed(err);
2427
3101
  return;
2428
3102
  }
2429
3103
  if (isThenable(result)) return Promise.resolve(result).then(done, failed);
2430
3104
  done();
2431
3105
  }
2432
- /** The change since the last write, taking the recorded keys with it. */
3106
+ /** Tell the audit listeners of the entries now written, up to `upTo`. */
3107
+ announce(ws, upTo) {
3108
+ const queued = this.unannounced.get(ws);
3109
+ if (!queued?.length) return;
3110
+ const now = queued.filter((e) => e.seq < upTo);
3111
+ this.unannounced.set(ws, queued.filter((e) => e.seq >= upTo));
3112
+ for (const entry of now) {
3113
+ for (const l of this.auditListeners) {
3114
+ try {
3115
+ l(ws, entry);
3116
+ } catch {
3117
+ }
3118
+ }
3119
+ }
3120
+ }
3121
+ /**
3122
+ * The change since the last write, taking the recorded keys with it. Each
3123
+ * object is written as a copy in the shape a restore gives back, so a
3124
+ * coordinator restarted on the store holds what this one holds, and the
3125
+ * workspace's own objects are left as they are.
3126
+ */
2433
3127
  takeChange(ws, p) {
2434
3128
  const replace = p.replace;
2435
3129
  const objects = {};
@@ -2438,12 +3132,15 @@ var Coordinator = class {
2438
3132
  if (replace) {
2439
3133
  const all = {};
2440
3134
  for (const [key, value] of Map.prototype.entries.call(map)) {
2441
- all[key] = value;
3135
+ all[key] = storedCopy(name, value);
2442
3136
  }
2443
3137
  objects[name] = all;
2444
3138
  } else if (map.dirty.size) {
2445
3139
  const changed = {};
2446
- for (const key of map.dirty) changed[key] = map.peek(key) ?? null;
3140
+ for (const key of map.dirty) {
3141
+ const value = map.peek(key);
3142
+ changed[key] = value === void 0 ? null : storedCopy(name, value);
3143
+ }
2447
3144
  objects[name] = changed;
2448
3145
  }
2449
3146
  map.dirty.clear();
@@ -2495,24 +3192,28 @@ var Coordinator = class {
2495
3192
  this.reportStoreError(err, { id: ws.id, version: next });
2496
3193
  return;
2497
3194
  }
3195
+ const upTo = ws.audit.length;
2498
3196
  const pending = this.saveChains.get(ws.id);
2499
3197
  if (pending) {
2500
- this.trackSave(pending.then(() => this.store.save(record)), record);
3198
+ this.trackSave(pending.then(() => this.store.save(record)), record, () => this.announce(ws, upTo));
2501
3199
  return;
2502
3200
  }
2503
3201
  let result;
2504
3202
  try {
2505
3203
  result = this.store.save(record);
2506
3204
  } catch (err) {
3205
+ if (this.options.storeErrors !== "log") this.held.add(ws.id);
2507
3206
  this.reportStoreError(err, record);
2508
3207
  return;
2509
3208
  }
2510
3209
  if (isThenable(result)) {
2511
- this.trackSave(Promise.resolve(result), record);
3210
+ this.trackSave(Promise.resolve(result), record, () => this.announce(ws, upTo));
3211
+ return;
2512
3212
  }
3213
+ this.announce(ws, upTo);
2513
3214
  }
2514
- trackSave(save, record) {
2515
- const guarded = save.catch((err) => this.reportStoreError(err, record));
3215
+ trackSave(save, record, saved) {
3216
+ const guarded = save.then(saved, (err) => this.reportStoreError(err, record));
2516
3217
  this.saveChains.set(record.id, guarded);
2517
3218
  void guarded.then(() => {
2518
3219
  if (this.saveChains.get(record.id) === guarded) {
@@ -2520,6 +3221,17 @@ var Coordinator = class {
2520
3221
  }
2521
3222
  });
2522
3223
  }
3224
+ reportInternalError(err, method, workspace) {
3225
+ const call2 = { method, ...workspace !== void 0 ? { workspace } : {} };
3226
+ if (this.options.onInternalError) {
3227
+ try {
3228
+ this.options.onInternalError(err, call2);
3229
+ } catch {
3230
+ }
3231
+ return;
3232
+ }
3233
+ console.error(`chap coordinator: ${method} failed with an internal error:`, err);
3234
+ }
2523
3235
  reportStoreError(err, record) {
2524
3236
  if (this.options.onStoreError) {
2525
3237
  try {
@@ -2529,7 +3241,9 @@ var Coordinator = class {
2529
3241
  return;
2530
3242
  }
2531
3243
  const message = err instanceof Error ? err.message : String(err);
2532
- console.error(`chap coordinator: writing workspace ${record.id} to the store failed: ${message}`);
3244
+ const line = `chap coordinator: writing workspace ${record.id} to the store failed: ${message}`;
3245
+ if (err instanceof StoreConflictError) console.warn(line);
3246
+ else console.error(line);
2533
3247
  }
2534
3248
  async drainSaves() {
2535
3249
  while (this.saveChains.size) {
@@ -2580,16 +3294,25 @@ var Coordinator = class {
2580
3294
  return null;
2581
3295
  }
2582
3296
  /**
2583
- * Assert that `sender` was addressed in the task's review. Applied to
2584
- * review-decision methods (decide.* and abstain.declare): to act on a
2585
- * review you must be one of the reviewers it was addressed to (the
2586
- * `to` set on review.request). Membership is the floor; this is the
2587
- * eligibility ceiling for decisions. Returns an error fragment if not
2588
- * eligible, else null.
3297
+ * Assert that `sender` may decide the task's open review. Applied to
3298
+ * review-decision methods (decide.* and abstain.declare) and to
3299
+ * review.acknowledge: to act on a review you must not be an author of the
3300
+ * work under review, must be a human member where the task requires
3301
+ * review, and must be one of the reviewers it was addressed to (the `to`
3302
+ * set on review.request).
3303
+ * Membership is the floor; this is the eligibility ceiling for decisions.
3304
+ * Returns an error fragment if not eligible, else null.
2589
3305
  */
2590
- requireReviewer(task, sender) {
3306
+ requireReviewer(ws, task, sender) {
2591
3307
  const review = task.review;
2592
- if (!review || !review.requested_to || review.requested_to.length === 0) {
3308
+ if (!review) return null;
3309
+ if (typeof sender === "string" && review.authors.includes(sender)) {
3310
+ return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot decide it") };
3311
+ }
3312
+ if (reviewRequired(ws, task) && rawMember(ws, sender)?.type !== "human") {
3313
+ return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${String(sender)} is not a human member`) };
3314
+ }
3315
+ if (!review.requested_to || review.requested_to.length === 0) {
2593
3316
  return null;
2594
3317
  }
2595
3318
  if (review.requested_to.some((r) => typeof r === "string" && (r.startsWith("workspace:") || r.startsWith("group:")))) {
@@ -2618,23 +3341,31 @@ var Coordinator = class {
2618
3341
  routing_policy_uri: ws.routing_policy_uri,
2619
3342
  step_up_window_sec: ws.step_up_window_sec,
2620
3343
  min_acr: ws.min_acr,
3344
+ signature_policy: ws.signature_policy,
3345
+ join_policy: ws.join_policy,
3346
+ review_rule: ws.review_rule,
2621
3347
  chain_head: ws.chain_head,
2622
3348
  chain_enabled: ws.chain_enabled
2623
3349
  };
2624
3350
  }
2625
- /** One workspace as `snapshot()` holds it. Reads nothing as a change. */
3351
+ /**
3352
+ * One workspace as `snapshot()` holds it: each object of its collections a
3353
+ * copy in the shape the store keeps. Reads nothing as a change.
3354
+ */
2626
3355
  snapshotOf(ws) {
2627
3356
  const { chain_head, chain_enabled, ...own } = this.metaOf(ws);
3357
+ const stored = (name) => Array.from(rawValues(ws[name])).map((v) => storedCopy(name, v));
2628
3358
  return {
2629
3359
  ...own,
2630
- members: Array.from(rawValues(ws.members)),
2631
- tasks: Array.from(rawValues(ws.tasks)),
2632
- overrides: Array.from(rawValues(ws.overrides)),
2633
- whispers: Array.from(rawValues(ws.whispers)),
2634
- deliberations: Array.from(rawValues(ws.deliberations)),
2635
- handoffs: Array.from(rawValues(ws.handoffs)),
2636
- snapshots: Array.from(rawValues(ws.snapshots)),
2637
- route_decisions: Array.from(rawValues(ws.route_decisions)),
3360
+ members: stored("members"),
3361
+ tasks: stored("tasks"),
3362
+ overrides: stored("overrides"),
3363
+ whispers: stored("whispers"),
3364
+ deliberations: stored("deliberations"),
3365
+ handoffs: stored("handoffs"),
3366
+ snapshots: stored("snapshots"),
3367
+ route_decisions: stored("route_decisions"),
3368
+ invitations: stored("invitations"),
2638
3369
  idempotency_keys: { ...ws.idempotency_keys },
2639
3370
  audit: ws.audit,
2640
3371
  chain_head,
@@ -2662,7 +3393,8 @@ var Coordinator = class {
2662
3393
  }
2663
3394
  }
2664
3395
  /** One workspace from a snapshot entry, its maps tracked. */
2665
- rebuild(w) {
3396
+ rebuild(snapshot) {
3397
+ const w = shapeSnapshot(snapshot);
2666
3398
  const ws = {
2667
3399
  id: w.id,
2668
3400
  created: w.created,
@@ -2673,6 +3405,11 @@ var Coordinator = class {
2673
3405
  routing_policy_uri: w.routing_policy_uri,
2674
3406
  step_up_window_sec: w.step_up_window_sec ?? 300,
2675
3407
  min_acr: w.min_acr,
3408
+ // A workspace written before the policy existed verified every call,
3409
+ // and one written before the join policy existed admitted anyone.
3410
+ signature_policy: w.signature_policy === "humans" ? "humans" : "all",
3411
+ join_policy: w.join_policy === "invite" ? "invite" : "open",
3412
+ review_rule: typeof w.review_rule === "string" && reviewRuleSupported(w.review_rule) ? w.review_rule : void 0,
2676
3413
  members: this.newMap(),
2677
3414
  tasks: this.newMap(),
2678
3415
  overrides: this.newMap(),
@@ -2681,6 +3418,7 @@ var Coordinator = class {
2681
3418
  handoffs: this.newMap(),
2682
3419
  snapshots: this.newMap(),
2683
3420
  route_decisions: this.newMap(),
3421
+ invitations: this.newMap(),
2684
3422
  idempotency_keys: w.idempotency_keys ?? {},
2685
3423
  audit: w.audit ?? [],
2686
3424
  chain_head: w.chain_head,
@@ -2689,9 +3427,16 @@ var Coordinator = class {
2689
3427
  for (const m of w.members ?? []) {
2690
3428
  if (!Array.isArray(m.keys)) m.keys = [];
2691
3429
  if (m.paused === void 0) m.paused = false;
3430
+ const scheme = typeof m.uri === "string" ? m.uri.split(":")[0] : "";
3431
+ if (PARTICIPANT_TYPES.includes(scheme)) m.type = scheme;
3432
+ if (m.role === "admin" && !ADMIN_TYPES.includes(m.type)) m.role = "participant";
2692
3433
  ws.members.set(m.uri, m);
2693
3434
  }
2694
- for (const t of w.tasks ?? []) ws.tasks.set(t.id, t);
3435
+ for (const t of w.tasks ?? []) {
3436
+ if (t.review && !Array.isArray(t.review.authors)) t.review.authors = storedAuthors(t);
3437
+ if (t.review && !Array.isArray(t.review.acknowledgements)) t.review.acknowledgements = [];
3438
+ ws.tasks.set(t.id, t);
3439
+ }
2695
3440
  for (const o of w.overrides ?? []) ws.overrides.set(o.id, o);
2696
3441
  for (const x of w.whispers ?? [])
2697
3442
  ws.whispers.set(x.id, x);
@@ -2735,20 +3480,119 @@ var Coordinator = class {
2735
3480
  }
2736
3481
  for (const x of w.route_decisions ?? [])
2737
3482
  ws.route_decisions.set(x.id, x);
3483
+ for (const x of w.invitations ?? []) ws.invitations.set(x.uri, x);
2738
3484
  return ws;
2739
3485
  }
2740
3486
  // -- dispatch ------------------------------------------------------
2741
3487
  dispatch(envelope) {
2742
3488
  this.frozenNow = this.advanceClock();
2743
3489
  this.tracker.depth++;
3490
+ let response;
3491
+ let target;
3492
+ let ran = false;
2744
3493
  try {
2745
- const response = this._dispatch(envelope);
2746
- if (response.error) this.recordRefusal(envelope, response.error);
2747
- return response;
3494
+ target = targetOf(envelope);
3495
+ const refused = target === void 0 ? null : this.keepUp(target);
3496
+ if (refused) {
3497
+ response = reply(envelope, { error: refused });
3498
+ } else {
3499
+ ran = true;
3500
+ response = this._dispatch(envelope);
3501
+ if (response.error) this.recordRefusal(envelope, response.error);
3502
+ else if (target === void 0 && envelope.method === "workspace.create") {
3503
+ target = response.result?.workspace;
3504
+ }
3505
+ }
2748
3506
  } finally {
2749
3507
  if (--this.tracker.depth === 0) this.flushUnflushed();
2750
3508
  this.frozenNow = void 0;
2751
3509
  }
3510
+ if (ran && target !== void 0 && this.tracker.depth === 0 && this.held.has(target)) {
3511
+ this.reload(target);
3512
+ return reply(envelope, { error: storeFailed() });
3513
+ }
3514
+ return response;
3515
+ }
3516
+ /**
3517
+ * Work that changes a workspace outside any call, a whisper lapse check
3518
+ * for one, kept to the rules a call keeps (SPECIFICATION 10.3): the
3519
+ * workspace is first brought up to date with the store, and left alone
3520
+ * as long as it is held; what the work changed is written once, when it ends;
3521
+ * and where that write fails, the workspace goes back to what the store
3522
+ * holds and the work's results are dropped, since none took effect.
3523
+ */
3524
+ outsideCall(id, work) {
3525
+ if (this.tracker.depth > 0) return work();
3526
+ if (this.keepUp(id)) return [];
3527
+ this.tracker.depth++;
3528
+ let out;
3529
+ try {
3530
+ out = work();
3531
+ } finally {
3532
+ if (--this.tracker.depth === 0) this.flushUnflushed();
3533
+ }
3534
+ if (this.held.has(id)) {
3535
+ this.reload(id);
3536
+ return [];
3537
+ }
3538
+ return out;
3539
+ }
3540
+ /**
3541
+ * Before a call on a workspace: where the store holds a different number of
3542
+ * audit entries for it than this instance last wrote, another writer has
3543
+ * written it, and it is reloaded. A workspace held after a failed write is
3544
+ * reloaded as well, and the call refused while that fails.
3545
+ */
3546
+ keepUp(id) {
3547
+ if (this.held.has(id)) return this.reload(id) ? null : storeFailed();
3548
+ if (typeof this.store.version !== "function" || this.saveChains.has(id)) return null;
3549
+ let stored;
3550
+ try {
3551
+ stored = this.store.version(id);
3552
+ } catch {
3553
+ return null;
3554
+ }
3555
+ if (isThenable(stored)) return null;
3556
+ const ws = this.workspaces.get(id);
3557
+ const p = ws ? this.persisted.get(ws) : void 0;
3558
+ if (ws && (!p || p.replace)) return null;
3559
+ if (stored === p?.seq) return null;
3560
+ return this.reload(id) ? null : storeFailed();
3561
+ }
3562
+ /** Replace a workspace with what the store holds of it. False when it cannot be read. */
3563
+ reload(id) {
3564
+ let record;
3565
+ try {
3566
+ if (typeof this.store.loadOne === "function") {
3567
+ record = this.store.loadOne(id);
3568
+ } else {
3569
+ const all = this.store.load();
3570
+ if (isThenable(all)) return false;
3571
+ record = all.find((r) => r.id === id);
3572
+ }
3573
+ if (isThenable(record)) return false;
3574
+ } catch (err) {
3575
+ this.reportStoreError(err, { id, version: this.workspaces.get(id)?.audit.length ?? 0 });
3576
+ return false;
3577
+ }
3578
+ const old = this.workspaces.get(id);
3579
+ if (old) this.unflushed.delete(old);
3580
+ if (record) {
3581
+ let ws;
3582
+ try {
3583
+ ws = this.rebuild(record.data);
3584
+ } catch (err) {
3585
+ this.reportStoreError(err, { id, version: record.version });
3586
+ return false;
3587
+ }
3588
+ this.workspaces.set(id, ws);
3589
+ this.markWritten(ws);
3590
+ this.wsVersions.set(id, record.version);
3591
+ } else {
3592
+ this.workspaces.delete(id);
3593
+ }
3594
+ this.held.delete(id);
3595
+ return true;
2752
3596
  }
2753
3597
  _dispatch(envelope) {
2754
3598
  if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
@@ -2785,11 +3629,16 @@ var Coordinator = class {
2785
3629
  const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
2786
3630
  return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2787
3631
  }
3632
+ if (params.workspace !== void 0 && (typeof params.workspace !== "string" || !params.workspace)) {
3633
+ return reply(envelope, { error: rpcError(E.PARAMS, "workspace must be a non-empty string") });
3634
+ }
2788
3635
  if (isSigned(envelope)) {
2789
3636
  const wsId2 = params.workspace;
2790
3637
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2791
3638
  if (ws) {
2792
- const prior = this.signedIndex(ws).refused.get(signedDigest(envelope));
3639
+ const index = this.signedIndex(ws);
3640
+ const digest = signedDigest(recordedForm(envelope));
3641
+ const prior = index.refused.get(digest);
2793
3642
  if (prior) {
2794
3643
  return reply(envelope, { error: rpcError(
2795
3644
  prior.code,
@@ -2797,15 +3646,24 @@ var Coordinator = class {
2797
3646
  { refused_at_seq: prior.seq }
2798
3647
  ) });
2799
3648
  }
3649
+ const accepted = index.accepted.get(digest);
3650
+ if (accepted !== void 0) {
3651
+ return reply(envelope, { error: rpcError(
3652
+ E.SIG_ALREADY_ACCEPTED,
3653
+ `Accepted at seq ${accepted}; a signed request takes effect once`,
3654
+ { accepted_at_seq: accepted }
3655
+ ) });
3656
+ }
2800
3657
  }
2801
3658
  }
2802
- if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
3659
+ const target = typeof params.workspace === "string" ? this.workspaces.get(params.workspace) : void 0;
3660
+ if (this.requiresSignatures(target) && method !== "participant.join" && method !== "workspace.create" && !(!isSigned(envelope) && target !== void 0 && !this.mustSign(target, rawMember(target, params.from)?.type))) {
2803
3661
  const sigErr = this.verifySignature(envelope);
2804
3662
  if (sigErr) return reply(envelope, { error: sigErr });
2805
3663
  if (method === "participant.rotate_key") {
2806
3664
  const signingKid = envelope.sig.split(":")[1];
2807
3665
  const oldKid = params.old_kid;
2808
- if (oldKid !== void 0 && signingKid !== oldKid) {
3666
+ if (oldKid != null && signingKid !== oldKid) {
2809
3667
  return reply(envelope, { error: rpcError(
2810
3668
  E.SIG_ROTATION_KEY_MISMATCH,
2811
3669
  "Rotation must be signed with the old key"
@@ -2860,14 +3718,14 @@ var Coordinator = class {
2860
3718
  try {
2861
3719
  out = handler(ownCopy(params));
2862
3720
  } catch (e) {
2863
- const msg = e instanceof Error ? e.message : String(e);
2864
- return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error", { detail: msg }) });
3721
+ this.reportInternalError(e, method, params.workspace);
3722
+ return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error") });
2865
3723
  }
2866
3724
  if (out.error) return reply(envelope, { error: out.error });
2867
- const wsId = params.workspace;
3725
+ const wsId = method === "workspace.create" ? out.result.workspace : params.workspace;
2868
3726
  if (typeof wsId === "string" && !READ_ONLY_METHODS.has(method)) {
2869
3727
  const ws = this.workspaces.get(wsId);
2870
- if (ws) this.recordAudit(ws, envelope);
3728
+ if (ws) this.recordAudit(ws, recordedForm(envelope));
2871
3729
  }
2872
3730
  return reply(envelope, { result: ownCopy(out.result) });
2873
3731
  }
@@ -2907,7 +3765,7 @@ var Coordinator = class {
2907
3765
  signedIndex(ws) {
2908
3766
  let index = this.signedIndexes.get(ws);
2909
3767
  if (!index) {
2910
- index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Set() };
3768
+ index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Map() };
2911
3769
  for (const entry of ws.audit) indexSigned(index, entry);
2912
3770
  this.signedIndexes.set(ws, index);
2913
3771
  }
@@ -2923,12 +3781,9 @@ var Coordinator = class {
2923
3781
  ws.audit.push(entry);
2924
3782
  const index = this.signedIndexes.get(ws);
2925
3783
  if (index) indexSigned(index, entry);
2926
- for (const l of this.auditListeners) {
2927
- try {
2928
- l(ws, entry);
2929
- } catch {
2930
- }
2931
- }
3784
+ const queued = this.unannounced.get(ws);
3785
+ if (queued) queued.push(entry);
3786
+ else this.unannounced.set(ws, [entry]);
2932
3787
  this.persist(ws);
2933
3788
  }
2934
3789
  // -- signature verification (security-signed/1.0) -----------------
@@ -2938,7 +3793,12 @@ var Coordinator = class {
2938
3793
  return rpcError(E.SIG_VERIFY_FAILED, "Missing top-level `sig` field");
2939
3794
  }
2940
3795
  const parts = sig.split(":");
2941
- if (parts.length !== 3 || parts[0] !== "ed25519") {
3796
+ const scheme = parts[0];
3797
+ if (parts.length !== 3 || scheme !== "ed25519" && scheme !== "webauthn") {
3798
+ return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
3799
+ }
3800
+ const assertion = scheme === "webauthn" ? parseWebAuthnAssertion(parts[2]) : void 0;
3801
+ if (scheme === "webauthn" && !assertion) {
2942
3802
  return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
2943
3803
  }
2944
3804
  const kid = parts[1];
@@ -2972,6 +3832,14 @@ var Coordinator = class {
2972
3832
  if (revoked) return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
2973
3833
  return rpcError(E.SIG_KEY_NOT_FOUND, `No key ${kid} valid at ${ts} for ${sender}`);
2974
3834
  }
3835
+ if (assertion) {
3836
+ if (!isWebAuthnKey(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not a WebAuthn key`);
3837
+ const stripped = JSON.parse(JSON.stringify(envelope));
3838
+ delete stripped.sig;
3839
+ const failure = webAuthnFailure(canonicalize(stripped), assertion, key.jwk);
3840
+ return failure === null ? null : rpcError(E.SIG_VERIFY_FAILED, failure);
3841
+ }
3842
+ if (!isEd25519Key(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not an Ed25519 key`);
2975
3843
  try {
2976
3844
  const stripped = JSON.parse(JSON.stringify(envelope));
2977
3845
  delete stripped.sig;
@@ -2985,6 +3853,39 @@ var Coordinator = class {
2985
3853
  return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2986
3854
  }
2987
3855
  }
3856
+ /**
3857
+ * SPECIFICATION 6.5: an identity profile states that a verifier checks
3858
+ * people's credentials, so advertising one this coordinator has no
3859
+ * verifier for describes a guarantee it does not give.
3860
+ */
3861
+ unverifiedIdentity(profiles) {
3862
+ const named = (name) => profiles.some((x) => x === name || x.startsWith(name + "/"));
3863
+ if (named("identity-oidc") && this.options.verifyOidcToken === void 0) {
3864
+ return rpcError(
3865
+ E.PARAMS,
3866
+ "identity-oidc/1.0 is advertised but no token verifier is configured. A workspace that advertises it and does not enforce it describes a guarantee it does not give."
3867
+ );
3868
+ }
3869
+ if (named("identity-vc") && this.options.verifyVc === void 0) {
3870
+ return rpcError(
3871
+ E.PARAMS,
3872
+ "identity-vc/1.0 is advertised but no presentation verifier is configured. A workspace that advertises it and does not enforce it describes a guarantee it does not give."
3873
+ );
3874
+ }
3875
+ return null;
3876
+ }
3877
+ /** Whether a workspace requires signatures: it advertises security-signed/1.0, or this coordinator is configured to. */
3878
+ requiresSignatures(ws) {
3879
+ return !!this.options.requireSignatures || ws !== void 0 && advertises(ws, "security-signed");
3880
+ }
3881
+ /**
3882
+ * Whether a participant of this type signs its calls in a workspace that
3883
+ * requires signatures: every participant under "all", and every one but an
3884
+ * agent under "humans". A non-member signs.
3885
+ */
3886
+ mustSign(ws, type) {
3887
+ return ws.signature_policy === "all" || type !== "agent";
3888
+ }
2988
3889
  /** Lookup the key valid for a given participant at a given time. */
2989
3890
  lookupKey(member, kid, ts) {
2990
3891
  for (const k of member.keys) {
@@ -3058,6 +3959,7 @@ var Coordinator = class {
3058
3959
  this.handlers.set("workspace.create", (p) => this.opWorkspaceCreate(p));
3059
3960
  this.handlers.set("workspace.describe", (p) => this.opWorkspaceDescribe(p));
3060
3961
  this.handlers.set("workspace.set_profiles", (p) => this.opWorkspaceSetProfiles(p));
3962
+ this.handlers.set("workspace.invite", (p) => this.opWorkspaceInvite(p));
3061
3963
  this.handlers.set("participant.join", (p) => this.opParticipantJoin(p));
3062
3964
  this.handlers.set("participant.leave", (p) => this.opParticipantLeave(p));
3063
3965
  this.handlers.set("task.create", (p) => this.opTaskCreate(p));
@@ -3065,6 +3967,7 @@ var Coordinator = class {
3065
3967
  this.handlers.set("task.complete", (p) => this.opTaskComplete(p));
3066
3968
  this.handlers.set("audit.read", (p) => this.opAuditRead(p));
3067
3969
  this.handlers.set("review.request", (p) => this.opReviewRequest(p));
3970
+ this.handlers.set("review.acknowledge", (p) => this.opReviewAcknowledge(p));
3068
3971
  this.handlers.set("decide.approve", (p) => this.opDecide(p, "approve"));
3069
3972
  this.handlers.set("decide.reject", (p) => this.opDecide(p, "reject"));
3070
3973
  this.handlers.set("decide.override", (p) => this.opDecideOverride(p));
@@ -3084,24 +3987,59 @@ var Coordinator = class {
3084
3987
  // Core method handlers
3085
3988
  // ==========================================================
3086
3989
  opWorkspaceCreate(p) {
3990
+ const built = this.buildWorkspace(p);
3991
+ if (built.error) return { error: built.error };
3992
+ this.addWorkspace(built.ws);
3993
+ return { result: { workspace: built.ws.id, created: built.ws.created } };
3994
+ }
3995
+ /**
3996
+ * The workspace a workspace.create with these params makes, or why it
3997
+ * cannot be made. Nothing is registered, so participant.join can judge a
3998
+ * join by the workspace it would make and make it only once the join
3999
+ * passes.
4000
+ */
4001
+ buildWorkspace(p) {
4002
+ const bad = paramsError(p, [
4003
+ ["profiles", "strings"],
4004
+ ["mode", "string"],
4005
+ ["mode_ceiling", "string"],
4006
+ ["routing_policy_uri", "string"],
4007
+ ["step_up_window_sec", "integer"],
4008
+ ["min_acr", "string"]
4009
+ ]);
4010
+ if (bad) return bad;
4011
+ for (const name of ["mode", "mode_ceiling"]) {
4012
+ if (p[name] != null && !MODES.has(p[name])) {
4013
+ return { error: rpcError(E.PARAMS, `${name} must be shadow, trial or production`) };
4014
+ }
4015
+ }
4016
+ const policy = p.signature_policy ?? this.options.signaturePolicy ?? "all";
4017
+ if (policy !== "all" && policy !== "humans") {
4018
+ return { error: rpcError(E.PARAMS, "signature_policy must be all or humans") };
4019
+ }
4020
+ const joinPolicy = p.join_policy ?? this.options.joinPolicy ?? "open";
4021
+ if (joinPolicy !== "open" && joinPolicy !== "invite") {
4022
+ return { error: rpcError(E.PARAMS, "join_policy must be open or invite") };
4023
+ }
4024
+ const reviewRule = p.review_rule ?? void 0;
4025
+ if (reviewRule !== void 0 && (typeof reviewRule !== "string" || !reviewRuleSupported(reviewRule))) {
4026
+ return { error: rpcError(E.PARAMS, "review_rule must be any_one_approves, all_approve or quorum:<n>") };
4027
+ }
3087
4028
  const id = p.workspace || this.ids.workspaceId();
3088
- if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
3089
4029
  if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
3090
- const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
3091
- for (const [profile, enforced, option] of [
3092
- ["security-signed/1.0", !!this.options.requireSignatures, "requireSignatures"],
3093
- ["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
3094
- ]) {
3095
- const name = profile.split("/")[0];
3096
- const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
3097
- if (enforced && !advertised) profiles.push(profile);
3098
- else if (advertised && !enforced) {
3099
- return { error: rpcError(
3100
- E.PARAMS,
3101
- `${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.`
3102
- ) };
3103
- }
4030
+ if (Array.isArray(p.profiles)) {
4031
+ const unknown = profilesError(p.profiles);
4032
+ if (unknown) return unknown;
3104
4033
  }
4034
+ const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
4035
+ const signed = profiles.some((x) => x === "security-signed" || x.startsWith("security-signed/"));
4036
+ if (this.options.requireSignatures && !signed) profiles.push("security-signed/1.0");
4037
+ const oidc = profiles.some((x) => x === "identity-oidc" || x.startsWith("identity-oidc/"));
4038
+ if (this.options.verifyOidcToken !== void 0 && !oidc) profiles.push("identity-oidc/1.0");
4039
+ const vc = profiles.some((x) => x === "identity-vc" || x.startsWith("identity-vc/"));
4040
+ if (this.options.verifyVc !== void 0 && !vc) profiles.push("identity-vc/1.0");
4041
+ const unverified = this.unverifiedIdentity(profiles);
4042
+ if (unverified) return { error: unverified };
3105
4043
  const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
3106
4044
  const ws = {
3107
4045
  id,
@@ -3110,9 +4048,14 @@ var Coordinator = class {
3110
4048
  profiles,
3111
4049
  mode: p.mode ?? "trial",
3112
4050
  mode_ceiling: p.mode_ceiling ?? "production",
3113
- routing_policy_uri: p.routing_policy_uri,
3114
- step_up_window_sec: typeof p.step_up_window_sec === "number" ? p.step_up_window_sec : 300,
3115
- min_acr: p.min_acr,
4051
+ // A param given as null gives no value, as in the Python reference: a
4052
+ // null min_acr sets no minimum.
4053
+ routing_policy_uri: p.routing_policy_uri ?? void 0,
4054
+ step_up_window_sec: p.step_up_window_sec ?? 300,
4055
+ min_acr: p.min_acr ?? void 0,
4056
+ signature_policy: policy,
4057
+ join_policy: joinPolicy,
4058
+ ...reviewRule !== void 0 ? { review_rule: reviewRule } : {},
3116
4059
  members: this.newMap(),
3117
4060
  tasks: this.newMap(),
3118
4061
  overrides: this.newMap(),
@@ -3121,14 +4064,17 @@ var Coordinator = class {
3121
4064
  handoffs: this.newMap(),
3122
4065
  snapshots: this.newMap(),
3123
4066
  route_decisions: this.newMap(),
4067
+ invitations: this.newMap(),
3124
4068
  idempotency_keys: {},
3125
4069
  audit: [],
3126
4070
  chain_enabled: chainEnabled,
3127
4071
  chain_head: chainEnabled ? ZERO_HASH : void 0
3128
4072
  };
3129
- this.workspaces.set(id, ws);
4073
+ return { ws };
4074
+ }
4075
+ addWorkspace(ws) {
4076
+ this.workspaces.set(ws.id, ws);
3130
4077
  this.persisted.set(ws, { seq: 0, replace: false, keys: /* @__PURE__ */ new Set() });
3131
- return { result: { workspace: id, created: ws.created } };
3132
4078
  }
3133
4079
  opWorkspaceDescribe(p) {
3134
4080
  const miss = missing(p, ["workspace"]);
@@ -3147,6 +4093,9 @@ var Coordinator = class {
3147
4093
  mode_ceiling: ws.mode_ceiling,
3148
4094
  max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
3149
4095
  step_up_window_sec: ws.step_up_window_sec,
4096
+ signature_policy: ws.signature_policy,
4097
+ join_policy: ws.join_policy,
4098
+ review_rule: ws.review_rule ?? "any_one_approves",
3150
4099
  profiles: ws.profiles,
3151
4100
  members: Array.from(rawValues(ws.members)).map(memberToDict),
3152
4101
  audit_count: ws.audit.length,
@@ -3162,6 +4111,11 @@ var Coordinator = class {
3162
4111
  opWorkspaceSetProfiles(p) {
3163
4112
  const miss = missing(p, ["workspace", "from", "profiles"]);
3164
4113
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4114
+ if (!Array.isArray(p.profiles) || !p.profiles.every((x) => typeof x === "string")) {
4115
+ return { error: rpcError(E.PARAMS, "profiles must be a list of strings") };
4116
+ }
4117
+ const unknown = profilesError(p.profiles);
4118
+ if (unknown) return unknown;
3165
4119
  const ws = this.workspaces.get(p.workspace);
3166
4120
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3167
4121
  const notMember = this.requireMember(ws, p.from);
@@ -3170,6 +4124,14 @@ var Coordinator = class {
3170
4124
  return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
3171
4125
  }
3172
4126
  const newProfiles = [...p.profiles];
4127
+ for (const name of LASTING_PROFILES) {
4128
+ const held = ws.profiles.find((x) => x === name || x.startsWith(name + "/"));
4129
+ if (held !== void 0 && !newProfiles.some((x) => x.startsWith(name + "/"))) {
4130
+ return { error: rpcError(E.NOT_AUTHORISED, `${held} cannot be removed once advertised`) };
4131
+ }
4132
+ }
4133
+ const unverified = this.unverifiedIdentity(newProfiles);
4134
+ if (unverified) return { error: unverified };
3173
4135
  if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
3174
4136
  ws.profiles = newProfiles;
3175
4137
  if (newProfiles.includes("audit-scitt/1.0") && !ws.chain_enabled) {
@@ -3181,20 +4143,76 @@ var Coordinator = class {
3181
4143
  opParticipantJoin(p) {
3182
4144
  const miss = missing(p, ["workspace", "from", "type"]);
3183
4145
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4146
+ for (const f of ["from", "type"]) {
4147
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
4148
+ }
4149
+ const type = p.type;
4150
+ const named = participantError(type, p.from, "from");
4151
+ if (named) return named;
4152
+ const bad = paramsError(p, [
4153
+ ["role", "string"],
4154
+ ["display_name", "string"],
4155
+ ["scopes", "strings"],
4156
+ ["capabilities", "object"],
4157
+ ["jwks", "object"],
4158
+ ["jwks.keys", "list"],
4159
+ ["oidc_token", "string"],
4160
+ ["vc_presentation", "object"]
4161
+ ]);
4162
+ if (bad) return bad;
4163
+ if (p.role === "admin" && !ADMIN_TYPES.includes(type)) {
4164
+ return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
4165
+ }
3184
4166
  let ws = this.workspaces.get(p.workspace);
4167
+ const made = ws === void 0;
3185
4168
  if (!ws) {
3186
- const create = this.opWorkspaceCreate({ workspace: p.workspace });
3187
- if (create.error) return create;
3188
- ws = this.workspaces.get(p.workspace);
4169
+ const built = this.buildWorkspace({ workspace: p.workspace });
4170
+ if (built.error) return { error: built.error };
4171
+ ws = built.ws;
3189
4172
  }
3190
4173
  const uri = p.from;
3191
4174
  const now = this.now();
3192
4175
  const existing = ws.members.get(uri);
3193
4176
  let tokenVerified = false;
4177
+ let holderVerified = false;
4178
+ let credentialBound = false;
4179
+ const invitation = existing ? void 0 : rawInvitation(ws, uri);
4180
+ const asked = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
4181
+ let role = asked ?? "participant";
4182
+ if (!existing) {
4183
+ const first = awaitsFirstMember(ws);
4184
+ if (ws.join_policy === "invite" && !invitation && !(first && ADMIN_TYPES.includes(type))) {
4185
+ return { error: rpcError(E.NOT_AUTHORISED, `${uri} has no invitation to this workspace`) };
4186
+ }
4187
+ if (invitation?.role !== void 0) {
4188
+ if (asked === "admin" && invitation.role !== "admin") {
4189
+ return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
4190
+ }
4191
+ role = invitation.role;
4192
+ } else if (first && ws.join_policy === "invite") {
4193
+ if (asked !== void 0 && asked !== "admin") {
4194
+ return { error: rpcError(
4195
+ E.PARAMS,
4196
+ "The first member of a workspace that admits by invitation is its admin"
4197
+ ) };
4198
+ }
4199
+ role = "admin";
4200
+ } else if (asked === "admin" && !first) {
4201
+ return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
4202
+ }
4203
+ }
4204
+ if (type === "human") {
4205
+ const oidc = this.options.verifyOidcToken !== void 0 && advertises(ws, "identity-oidc");
4206
+ const vc = this.options.verifyVc !== void 0 && advertises(ws, "identity-vc");
4207
+ const presented = oidc && typeof p.oidc_token === "string" || vc && isObject(p.vc_presentation);
4208
+ if ((oidc || vc) && !presented) {
4209
+ return { error: oidc ? rpcError(E.OIDC_TOKEN_INVALID, vc ? "An oidc_token or a vc_presentation is required to join as a human participant" : "An oidc_token is required to join as a human participant") : rpcError(E.VC_VP_INVALID, "A vc_presentation is required to join as a human participant") };
4210
+ }
4211
+ }
3194
4212
  const member = {
3195
4213
  uri,
3196
- type: p.type,
3197
- role: p.role || "participant",
4214
+ type,
4215
+ role,
3198
4216
  joined: now,
3199
4217
  display_name: p.display_name,
3200
4218
  capabilities: p.capabilities,
@@ -3202,6 +4220,9 @@ var Coordinator = class {
3202
4220
  keys: [],
3203
4221
  paused: false
3204
4222
  };
4223
+ if (invitation?.oidc_sub !== void 0 && (this.options.verifyOidcToken === void 0 || typeof p.oidc_token !== "string")) {
4224
+ return { error: rpcError(E.OIDC_TOKEN_INVALID, "An oidc_token is required: the invitation names a token subject") };
4225
+ }
3205
4226
  if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
3206
4227
  const claims = this.options.verifyOidcToken(p.oidc_token);
3207
4228
  if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
@@ -3210,6 +4231,9 @@ var Coordinator = class {
3210
4231
  if (boundUri != null && boundUri !== uri) {
3211
4232
  return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
3212
4233
  }
4234
+ if (invitation?.oidc_sub !== void 0 && sub !== invitation.oidc_sub) {
4235
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token subject does not match the invitation") };
4236
+ }
3213
4237
  if (existing) {
3214
4238
  if (existing.oidc_sub != null) {
3215
4239
  if (sub !== existing.oidc_sub) {
@@ -3218,19 +4242,23 @@ var Coordinator = class {
3218
4242
  } else if (boundUri !== uri) {
3219
4243
  return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
3220
4244
  }
4245
+ } else if (type === "human" && boundUri !== uri && invitation?.oidc_sub === void 0) {
4246
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this participant") };
3221
4247
  }
3222
4248
  member.oidc_sub = sub;
3223
4249
  tokenVerified = true;
3224
4250
  const at = claims.auth_time;
3225
- if (typeof at === "number") member.oidc_auth_time = at;
4251
+ if (typeof at === "number" && Number.isFinite(at) && Math.abs(at) <= Number.MAX_SAFE_INTEGER) member.oidc_auth_time = Math.floor(at);
3226
4252
  member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
3227
- const cnf = claims.cnf;
3228
- const cnfJwk = cnf?.jwk;
3229
- if (cnfJwk && typeof cnfJwk.kid === "string") {
3230
- member.keys.push({ jwk: cnfJwk, kid: cnfJwk.kid, valid_from: now });
4253
+ const cnf = isObject(claims.cnf) ? claims.cnf.jwk : void 0;
4254
+ const bound = credentialKeys(cnf, cnf == null ? claims.nonce : void 0, signingKeys(p.jwks));
4255
+ if (bound === null) return { error: rpcError(E.OIDC_CNF_MISMATCH, "The join's keys are not the key the token binds") };
4256
+ if (bound !== void 0) {
4257
+ credentialBound = true;
4258
+ for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
3231
4259
  }
3232
4260
  }
3233
- if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null && !Array.isArray(p.vc_presentation)) {
4261
+ if (this.options.verifyVc && isObject(p.vc_presentation)) {
3234
4262
  const subject = this.options.verifyVc(p.vc_presentation);
3235
4263
  if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
3236
4264
  const holder = [subject.holder, subject.id].find(
@@ -3240,21 +4268,54 @@ var Coordinator = class {
3240
4268
  return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
3241
4269
  }
3242
4270
  member.vc_holder = holder;
3243
- const vpJwk = subject.cnf_jwk;
3244
- if (vpJwk && typeof vpJwk.kid === "string") {
3245
- member.keys.push({ jwk: vpJwk, kid: vpJwk.kid, valid_from: now });
4271
+ holderVerified = true;
4272
+ const bound = credentialKeys(subject.cnf_jwk, void 0, signingKeys(p.jwks));
4273
+ if (bound === null) {
4274
+ return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "The join's keys are not the key the presentation binds") };
4275
+ }
4276
+ if (bound !== void 0) {
4277
+ credentialBound = true;
4278
+ for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
4279
+ }
4280
+ }
4281
+ if (!existing && type === "human" && (tokenVerified || holderVerified) && !credentialBound && !invitation?.jwks && signingKeys(p.jwks).length > 0) {
4282
+ return { error: tokenVerified ? rpcError(E.OIDC_CNF_MISMATCH, "The token binds no key: a person it verifies signs with a key it binds") : rpcError(
4283
+ E.VC_HOLDER_BINDING_INVALID,
4284
+ "The presentation binds no key: a person it verifies signs with a key it binds"
4285
+ ) };
4286
+ }
4287
+ const pinned = invitation?.jwks?.keys;
4288
+ if (pinned) {
4289
+ const strays = [...signingKeys(p.jwks), ...member.keys.map((k) => k.jwk)].filter((j) => !pinned.some((k) => sameKey(k, j)));
4290
+ if (strays.length) {
4291
+ return { error: rpcError(
4292
+ E.NOT_AUTHORISED,
4293
+ `${uri} joins with keys its invitation does not name`,
4294
+ { kids: [...new Set(strays.map((j) => j.kid))] }
4295
+ ) };
3246
4296
  }
4297
+ member.keys = pinned.map((j) => ({ jwk: j, kid: j.kid, valid_from: now }));
3247
4298
  }
3248
4299
  const attested = [...member.keys];
3249
- const jwks = p.jwks;
3250
- if (jwks?.keys && attested.length === 0) {
3251
- for (const j of jwks.keys) {
3252
- if (j.kid && !member.keys.some((k) => k.kid === j.kid)) {
4300
+ if (attested.length === 0) {
4301
+ for (const j of signingKeys(p.jwks)) {
4302
+ if (!member.keys.some((k) => k.kid === j.kid)) {
3253
4303
  member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
3254
4304
  }
3255
4305
  }
3256
4306
  }
3257
4307
  if (existing) {
4308
+ if (attested.length === 0) {
4309
+ const keys = signingKeys(p.jwks);
4310
+ const held = keys.some((j) => existing.keys.some((k) => sameKey(k.jwk, j)));
4311
+ if (keys.length > 0 && !held) {
4312
+ return { error: rpcError(
4313
+ E.NOT_AUTHORISED,
4314
+ `${uri} is already a member, with other keys; a new key arrives through participant.rotate_key`,
4315
+ { kids: [...new Set(keys.map((j) => j.kid))] }
4316
+ ) };
4317
+ }
4318
+ }
3258
4319
  if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
3259
4320
  if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
3260
4321
  if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
@@ -3265,18 +4326,126 @@ var Coordinator = class {
3265
4326
  }
3266
4327
  return { result: { joined: true, as: uri } };
3267
4328
  }
4329
+ if (this.requiresSignatures(ws) && this.mustSign(ws, type) && member.keys.length === 0) {
4330
+ return { error: rpcError(E.PARAMS, `${uri} must sign in this workspace, and joins with no key`) };
4331
+ }
4332
+ if (made) this.addWorkspace(ws);
3268
4333
  ws.members.set(uri, member);
4334
+ if (invitation) ws.invitations.delete(uri);
3269
4335
  return { result: { joined: true, as: uri } };
3270
4336
  }
4337
+ /**
4338
+ * SPECIFICATION 6.3.3: an admin invites a participant. For a URI that is
4339
+ * not a member it records an invitation, which the invitee's join takes
4340
+ * up. For a member that holds no key in force it registers the
4341
+ * invitation's keys: the way back for a member whose keys were all revoked,
4342
+ * or who joined a workspace that requires signatures with none.
4343
+ */
4344
+ opWorkspaceInvite(p) {
4345
+ const miss = missing(p, ["workspace", "from", "uri", "type"]);
4346
+ if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4347
+ for (const f of ["uri", "type"]) {
4348
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
4349
+ }
4350
+ const bad = paramsError(p, [
4351
+ ["role", "string"],
4352
+ ["jwks", "object"],
4353
+ ["jwks.keys", "list"],
4354
+ ["oidc_sub", "string"]
4355
+ ]);
4356
+ if (bad) return bad;
4357
+ const ws = this.workspaces.get(p.workspace);
4358
+ if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
4359
+ const notMember = this.requireMember(ws, p.from);
4360
+ if (notMember) return notMember;
4361
+ if (rawMember(ws, p.from).role !== "admin") {
4362
+ return { error: rpcError(E.NOT_AUTHORISED, "workspace.invite requires the admin role") };
4363
+ }
4364
+ const uri = p.uri;
4365
+ const type = p.type;
4366
+ const named = participantError(type, uri, "uri");
4367
+ if (named) return named;
4368
+ const role = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
4369
+ if (role === "admin" && !ADMIN_TYPES.includes(type)) {
4370
+ return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
4371
+ }
4372
+ const sub = typeof p.oidc_sub === "string" ? p.oidc_sub : void 0;
4373
+ if (sub !== void 0 && this.options.verifyOidcToken === void 0) {
4374
+ return { error: rpcError(E.PARAMS, "oidc_sub names a token subject, and this Coordinator has no token verifier") };
4375
+ }
4376
+ let pinned;
4377
+ if (p.jwks != null) {
4378
+ pinned = signingKeys(p.jwks);
4379
+ if (pinned.length === 0) return { error: rpcError(E.PARAMS, "jwks names no key: each key needs a kid") };
4380
+ }
4381
+ const now = this.now();
4382
+ const member = ws.members.get(uri);
4383
+ if (member) {
4384
+ if (member.keys.some((k) => inForce(k, now))) {
4385
+ return { error: rpcError(
4386
+ E.PARAMS,
4387
+ `${uri} is a member and holds a key in force; its keys change through participant.rotate_key`
4388
+ ) };
4389
+ }
4390
+ if (!pinned) {
4391
+ return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers the keys its jwks names`) };
4392
+ }
4393
+ if (this.options.verifyOidcToken !== void 0 && (member.type === "human" || member.oidc_sub !== void 0) || this.options.verifyVc !== void 0 && (member.type === "human" || member.vc_holder !== void 0)) {
4394
+ return { error: rpcError(
4395
+ E.PARAMS,
4396
+ `${uri} registers a new key by joining again with a token that binds it; an invitation does not register a person's keys here`
4397
+ ) };
4398
+ }
4399
+ if (role !== void 0 && role !== member.role || sub !== void 0 && sub !== member.oidc_sub) {
4400
+ return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers keys and changes nothing else`) };
4401
+ }
4402
+ const reused = pinned.find((j) => member.keys.some((k) => k.kid === j.kid));
4403
+ if (reused) return { error: rpcError(E.PARAMS, `Key ${reused.kid} was ${uri}'s; a new key takes a new kid`) };
4404
+ for (const j of pinned) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
4405
+ return { result: { invited: true, uri, registered: pinned.map((j) => j.kid) } };
4406
+ }
4407
+ if (pinned && (type === "human" || sub !== void 0) && (this.options.verifyOidcToken !== void 0 || this.options.verifyVc !== void 0)) {
4408
+ return { error: rpcError(
4409
+ E.PARAMS,
4410
+ `${uri} joins with a token that binds its key; an invitation does not register a person's keys here`
4411
+ ) };
4412
+ }
4413
+ const invitation = {
4414
+ uri,
4415
+ type,
4416
+ invited_by: p.from,
4417
+ invited_at: now
4418
+ };
4419
+ if (role !== void 0) invitation.role = role;
4420
+ if (pinned) invitation.jwks = { keys: pinned };
4421
+ if (sub !== void 0) invitation.oidc_sub = sub;
4422
+ ws.invitations.set(uri, invitation);
4423
+ return { result: { invited: true, uri } };
4424
+ }
3271
4425
  opParticipantLeave(p) {
3272
4426
  const ws = this.workspaces.get(p.workspace);
3273
4427
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
4428
+ if (rawMember(ws, p.from)?.paused) {
4429
+ return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not leave until another member lifts its pause") };
4430
+ }
3274
4431
  ws.members.delete(p.from);
3275
4432
  return { result: { left: true } };
3276
4433
  }
3277
4434
  opTaskCreate(p) {
3278
4435
  const miss = missing(p, ["workspace", "from", "kind", "input"]);
3279
4436
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4437
+ const bad = paramsError(p, [
4438
+ ["assignee", "string"],
4439
+ ["to", "string"],
4440
+ ["mode", "string"],
4441
+ ["review_required", "boolean"],
4442
+ ["routing_hints", "object"],
4443
+ ["idempotency_key", "string"]
4444
+ ]);
4445
+ if (bad) return bad;
4446
+ if (RESERVED_KEYS.includes(p.idempotency_key)) {
4447
+ return { error: rpcError(E.PARAMS, "idempotency_key must not be __proto__, constructor or prototype") };
4448
+ }
3280
4449
  const ws = this.workspaces.get(p.workspace);
3281
4450
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3282
4451
  const notMember = this.requireMember(ws, p.from);
@@ -3288,9 +4457,6 @@ var Coordinator = class {
3288
4457
  if (ws.members.get(assignee).paused) {
3289
4458
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
3290
4459
  }
3291
- if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
3292
- return { error: rpcError(E.PARAMS, "mode must be a string") };
3293
- }
3294
4460
  const requestedMode = p.mode || ws.mode;
3295
4461
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
3296
4462
  return { error: rpcError(
@@ -3298,9 +4464,6 @@ var Coordinator = class {
3298
4464
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
3299
4465
  ) };
3300
4466
  }
3301
- if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
3302
- return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
3303
- }
3304
4467
  const taskId = this.ids.taskId();
3305
4468
  const now = this.now();
3306
4469
  const task = {
@@ -3339,6 +4502,7 @@ var Coordinator = class {
3339
4502
  if (notMember) return notMember;
3340
4503
  const task = ws.tasks.get(p.task_id);
3341
4504
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
4505
+ if (typeof p.state !== "string") return { error: rpcError(E.PARAMS, "state must be a string") };
3342
4506
  const newState = p.state;
3343
4507
  const legal = {
3344
4508
  created: ["in_progress", "declined"],
@@ -3349,12 +4513,42 @@ var Coordinator = class {
3349
4513
  if (!legal[task.state]?.includes(newState)) {
3350
4514
  return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
3351
4515
  }
3352
- if (newState === "completed" && task.review_required) {
4516
+ if (newState === "completed" && reviewRequired(ws, task)) {
3353
4517
  return { error: rpcError(
3354
4518
  E.PARAMS,
3355
4519
  "This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
3356
4520
  ) };
3357
4521
  }
4522
+ if (newState === "completed" && task.review) {
4523
+ return { error: rpcError(
4524
+ E.PARAMS,
4525
+ "This task holds a review. Submit the output with task.complete, which opens a new round; a reviewer decision completes it."
4526
+ ) };
4527
+ }
4528
+ if (task.state === "review_requested" && !task.review.authors.includes(p.from)) {
4529
+ return { error: rpcError(
4530
+ E.NOT_AUTHORISED,
4531
+ "Only the authors of the work under review take it back from review"
4532
+ ) };
4533
+ }
4534
+ if (newState === "cancelled" && openRequiredReviewAuthors(ws, task).includes(p.from)) {
4535
+ return { error: rpcError(
4536
+ E.NOT_AUTHORISED,
4537
+ "The authors of the work under review cannot end its review without a decision"
4538
+ ) };
4539
+ }
4540
+ if (newState === "declined" && task.review && !task.review.decisions.some((d) => d.kind === "reject")) {
4541
+ return { error: rpcError(
4542
+ E.PARAMS,
4543
+ "This task holds a review no decision has closed; a reviewer's decision ends it"
4544
+ ) };
4545
+ }
4546
+ if (newState === "declined" && !task.review && openRequiredReviewAuthors(ws, task).length > 0) {
4547
+ return { error: rpcError(
4548
+ E.PARAMS,
4549
+ "This task carries on a review no decision has closed; completing it opens the review a reviewer decides"
4550
+ ) };
4551
+ }
3358
4552
  task.state = newState;
3359
4553
  task.updated_at = this.now();
3360
4554
  task.history.push({
@@ -3375,32 +4569,60 @@ var Coordinator = class {
3375
4569
  if (task.state !== "created" && task.state !== "in_progress") {
3376
4570
  return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
3377
4571
  }
3378
- if (task.review_required) {
4572
+ if (p.confidence != null && typeof p.confidence !== "number" && typeof p.confidence !== "string") {
4573
+ return { error: rpcError(E.PARAMS, "confidence must be a number or a string") };
4574
+ }
4575
+ const required = reviewRequired(ws, task);
4576
+ if (required && p.from !== task.assignee) {
4577
+ return { error: rpcError(E.NOT_AUTHORISED, "Only the assignee completes a task that requires review") };
4578
+ }
4579
+ if (required || task.review) {
3379
4580
  const now = this.now();
4581
+ const authors = authorsOf(task, p.from);
4582
+ let note = OPENED_ON_COMPLETE;
3380
4583
  if (!task.review) {
3381
- const producer = p.from;
3382
- const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
4584
+ const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => !authors.includes(uri) && member.type === "human").map(([uri]) => uri);
3383
4585
  if (eligible.length === 0) {
3384
4586
  return { error: rpcError(
3385
4587
  E.NOT_AUTHORISED,
3386
4588
  "Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
3387
4589
  ) };
3388
4590
  }
3389
- task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
4591
+ task.review = {
4592
+ requested_at: now,
4593
+ requested_to: eligible,
4594
+ rule: ws.review_rule ?? "any_one_approves",
4595
+ decisions: [],
4596
+ authors,
4597
+ acknowledgements: []
4598
+ };
4599
+ } else {
4600
+ const roundAuthors = unionOf(task.review.authors, authors);
4601
+ const reviewers = task.review.requested_to.filter((r) => !roundAuthors.includes(r) && !(required && rawMember(ws, r) !== void 0 && rawMember(ws, r).type !== "human"));
4602
+ if (reviewers.length === 0) {
4603
+ return { error: rpcError(
4604
+ E.NOT_AUTHORISED,
4605
+ "No reviewer of this task remains once the authors of the work are left out"
4606
+ ) };
4607
+ }
4608
+ task.review = {
4609
+ ...task.review,
4610
+ requested_at: now,
4611
+ requested_to: reviewers,
4612
+ decisions: [],
4613
+ authors: roundAuthors,
4614
+ acknowledgements: []
4615
+ };
4616
+ note = NEW_ROUND_ON_COMPLETE;
3390
4617
  }
3391
4618
  task.pending_artefact = p.output;
3392
4619
  task.state = "review_requested";
3393
4620
  task.updated_at = now;
3394
- task.history.push({
3395
- ts: now,
3396
- from: p.from,
3397
- state: "review_requested",
3398
- note: "review required; opened on task.complete"
3399
- });
4621
+ task.history.push({ ts: now, from: p.from, state: "review_requested", note });
3400
4622
  return { result: { state: "review_requested", review_id: task.id } };
3401
4623
  }
3402
4624
  task.output = p.output;
3403
- if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
4625
+ if (p.confidence != null) task.confidence = p.confidence;
3404
4626
  task.state = "completed";
3405
4627
  task.updated_at = this.now();
3406
4628
  task.history.push({ ts: task.updated_at, from: p.from, state: "completed" });
@@ -3413,8 +4635,16 @@ var Coordinator = class {
3413
4635
  const notMember = this.requireMember(ws, p.from);
3414
4636
  if (notMember) return notMember;
3415
4637
  }
3416
- const range = p.range ?? {};
3417
- const filter = p.filter ?? {};
4638
+ const bad = paramsError(p, [
4639
+ ["range.from_seq", "integer"],
4640
+ ["range.to_seq", "integer"],
4641
+ ["filter.method", "string"],
4642
+ ["filter.from", "string"],
4643
+ ["filter.task_id", "string"]
4644
+ ]);
4645
+ if (bad) return bad;
4646
+ const range = isObject(p.range) ? p.range : {};
4647
+ const filter = isObject(p.filter) ? p.filter : {};
3418
4648
  if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
3419
4649
  return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
3420
4650
  }
@@ -3465,37 +4695,73 @@ var Coordinator = class {
3465
4695
  return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
3466
4696
  }
3467
4697
  const to = p.to;
4698
+ if (to != null && typeof to !== "string" && !(Array.isArray(to) && to.every((r) => typeof r === "string"))) {
4699
+ return { error: rpcError(E.PARAMS, "to must be a string or a list of strings") };
4700
+ }
3468
4701
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
3469
4702
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
4703
+ const bad = paramsError(p, [["rule", "string"]]);
4704
+ if (bad) return bad;
3470
4705
  const now = this.now();
3471
4706
  const declaredRule = p.rule;
3472
4707
  const rule = declaredRule || "any_one_approves";
3473
4708
  if (!reviewRuleSupported(rule)) {
3474
4709
  return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
3475
4710
  }
3476
- if (task.state === "review_requested" && task.review) {
3477
- if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
4711
+ const open = task.state === "review_requested" ? task.review : void 0;
4712
+ if (open) {
4713
+ if (contentHash(task.pending_artefact ?? null) !== contentHash(p.artefact ?? null)) {
3478
4714
  return { error: rpcError(
3479
4715
  E.REVIEW_ALREADY_OPEN,
3480
4716
  "A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
3481
4717
  ) };
3482
4718
  }
3483
- if (declaredRule !== void 0 && declaredRule !== task.review.rule) {
4719
+ if (declaredRule != null && declaredRule !== open.rule) {
3484
4720
  return { error: rpcError(
3485
4721
  E.REVIEW_ALREADY_OPEN,
3486
- `Cannot change the decision rule of an open review (currently ${task.review.rule})`
4722
+ `Cannot change the decision rule of an open review (currently ${open.rule})`
4723
+ ) };
4724
+ }
4725
+ }
4726
+ const authors = open ? open.authors : unionOf(task.review ? task.review.authors : [], authorsOf(task, lastCompleter(task)));
4727
+ if (reviewRequired(ws, task)) {
4728
+ if (!open) {
4729
+ return { error: rpcError(
4730
+ E.NOT_AUTHORISED,
4731
+ "This task requires a person's review, which opens on task.complete; review.request only adds reviewers to it once it is open"
4732
+ ) };
4733
+ }
4734
+ if (authors.includes(p.from)) {
4735
+ return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot request its review") };
4736
+ }
4737
+ const scope = reviewers.find(isScope);
4738
+ if (scope !== void 0) {
4739
+ return { error: rpcError(
4740
+ E.PARAMS,
4741
+ `A review that requires a person names its reviewers, and ${scope} is an address scope`
3487
4742
  ) };
3488
4743
  }
3489
4744
  for (const r of reviewers) {
3490
- if (!task.review.requested_to.includes(r)) task.review.requested_to.push(r);
4745
+ if (authors.includes(r)) {
4746
+ return { error: rpcError(E.NOT_AUTHORISED, `The author of the work under review cannot review it: ${r}`) };
4747
+ }
4748
+ const member = rawMember(ws, r);
4749
+ if (member && member.type !== "human") {
4750
+ return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${r} is not a human member`) };
4751
+ }
4752
+ }
4753
+ }
4754
+ if (open) {
4755
+ for (const r of reviewers) {
4756
+ if (!open.requested_to.includes(r)) open.requested_to.push(r);
3491
4757
  }
3492
- if (p.deadline !== void 0) task.review.deadline = p.deadline;
4758
+ if (p.deadline != null) open.deadline = p.deadline;
3493
4759
  task.updated_at = now;
3494
4760
  return { result: {
3495
4761
  state: "review_requested",
3496
4762
  review_id: task.id,
3497
4763
  amended: true,
3498
- requested_to: [...task.review.requested_to]
4764
+ requested_to: [...open.requested_to]
3499
4765
  } };
3500
4766
  }
3501
4767
  task.state = "review_requested";
@@ -3505,12 +4771,41 @@ var Coordinator = class {
3505
4771
  requested_to: reviewers,
3506
4772
  rule,
3507
4773
  deadline: p.deadline,
3508
- decisions: []
4774
+ decisions: [],
4775
+ authors,
4776
+ acknowledgements: []
3509
4777
  };
3510
4778
  task.history.push({ ts: now, from: p.from, state: "review_requested" });
3511
4779
  task.pending_artefact = p.artefact;
3512
4780
  return { result: { state: "review_requested", review_id: task.id } };
3513
4781
  }
4782
+ /**
4783
+ * review/1.0 S3.6: a reviewer says they have begun the open review. Only
4784
+ * someone who may decide it can, so the log's record of who took a review
4785
+ * up, and when, names reviewers alone. It changes nothing else: the review
4786
+ * stays open and the acknowledgement counts towards no rule.
4787
+ */
4788
+ opReviewAcknowledge(p) {
4789
+ const miss = missing(p, ["workspace", "from", "task_id"]);
4790
+ if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4791
+ const ws = this.workspaces.get(p.workspace);
4792
+ if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
4793
+ const notMember = this.requireMember(ws, p.from);
4794
+ if (notMember) return notMember;
4795
+ const task = ws.tasks.get(p.task_id);
4796
+ if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
4797
+ if (task.state !== "review_requested") {
4798
+ return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
4799
+ }
4800
+ const notReviewer = this.requireReviewer(ws, task, p.from);
4801
+ if (notReviewer) return notReviewer;
4802
+ const review = task.review;
4803
+ if (review.acknowledgements.some((a) => a.reviewer === p.from)) {
4804
+ return { error: rpcError(E.PARAMS, "Already acknowledged in this round") };
4805
+ }
4806
+ review.acknowledgements.push({ reviewer: p.from, ts: this.now() });
4807
+ return { result: { acknowledged: true, requested_at: review.requested_at } };
4808
+ }
3514
4809
  /**
3515
4810
  * Verify an optional `approved_artefact_digest` against the artefact under
3516
4811
  * review (review/1.0 S3.2).
@@ -3529,7 +4824,7 @@ var Coordinator = class {
3529
4824
  if (typeof declared !== "string") {
3530
4825
  return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
3531
4826
  }
3532
- if (task.pending_artefact === void 0) {
4827
+ if (task.pending_artefact === void 0 || task.pending_artefact === null) {
3533
4828
  return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
3534
4829
  }
3535
4830
  const actual = contentHash(task.pending_artefact);
@@ -3545,8 +4840,8 @@ var Coordinator = class {
3545
4840
  opDecide(p, kind) {
3546
4841
  const miss = missing(p, ["workspace", "from", "task_id"]);
3547
4842
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
3548
- const badTags = tagsError(p);
3549
- if (badTags) return badTags;
4843
+ const bad = paramsError(p, [["tags", "strings"], ["request_revision", "boolean"]]);
4844
+ if (bad) return bad;
3550
4845
  const ws = this.workspaces.get(p.workspace);
3551
4846
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3552
4847
  const notMember = this.requireMember(ws, p.from);
@@ -3556,7 +4851,7 @@ var Coordinator = class {
3556
4851
  if (task.state !== "review_requested") {
3557
4852
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3558
4853
  }
3559
- const notReviewer = this.requireReviewer(task, p.from);
4854
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3560
4855
  if (notReviewer) return notReviewer;
3561
4856
  const digestError = this.checkArtefactDigest(p, task);
3562
4857
  if (digestError) return digestError;
@@ -3583,8 +4878,8 @@ var Coordinator = class {
3583
4878
  opDecideOverride(p) {
3584
4879
  const miss = missing(p, ["workspace", "from", "task_id", "diff", "rationale"]);
3585
4880
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
3586
- const badTags = tagsError(p);
3587
- if (badTags) return badTags;
4881
+ const bad = paramsError(p, [["tags", "strings"], ["policy_refs", "strings"]]);
4882
+ if (bad) return bad;
3588
4883
  const ws = this.workspaces.get(p.workspace);
3589
4884
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3590
4885
  const notMember = this.requireMember(ws, p.from);
@@ -3594,15 +4889,17 @@ var Coordinator = class {
3594
4889
  if (task.state !== "review_requested") {
3595
4890
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3596
4891
  }
3597
- const notReviewer = this.requireReviewer(task, p.from);
4892
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3598
4893
  if (notReviewer) return notReviewer;
3599
4894
  const digestError = this.checkArtefactDigest(p, task);
3600
4895
  if (digestError) return digestError;
3601
4896
  const base = task.pending_artefact;
3602
- if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
4897
+ if (base === void 0 || base === null) return { error: rpcError(E.PARAMS, "No base artefact for override") };
3603
4898
  let applied;
3604
4899
  try {
3605
- applied = applyJsonPatch(base, p.diff);
4900
+ applied = applyJsonPatch(base, p.diff, {
4901
+ maxStringBytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES
4902
+ });
3606
4903
  } catch (e) {
3607
4904
  const msg = e instanceof Error ? e.message : String(e);
3608
4905
  return { error: rpcError(E.PATCH_FAILED, msg) };
@@ -3641,6 +4938,8 @@ var Coordinator = class {
3641
4938
  opAbstainDeclare(p) {
3642
4939
  const miss = missing(p, ["workspace", "from", "task_id", "reason"]);
3643
4940
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4941
+ const bad = paramsError(p, [["category", "string"]]);
4942
+ if (bad) return bad;
3644
4943
  const ws = this.workspaces.get(p.workspace);
3645
4944
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3646
4945
  const notMember = this.requireMember(ws, p.from);
@@ -3650,7 +4949,7 @@ var Coordinator = class {
3650
4949
  if (task.state !== "review_requested") {
3651
4950
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3652
4951
  }
3653
- const notReviewer = this.requireReviewer(task, p.from);
4952
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3654
4953
  if (notReviewer) return notReviewer;
3655
4954
  const now = this.now();
3656
4955
  task.review.decisions.push({
@@ -3682,6 +4981,8 @@ var Coordinator = class {
3682
4981
  return { error: rpcError(E.PARAMS, "new_task must be an object") };
3683
4982
  }
3684
4983
  const nt = p.new_task;
4984
+ const bad = paramsError(p, [["new_task.kind", "string"]]);
4985
+ if (bad) return bad;
3685
4986
  const assignee = nt.assignee;
3686
4987
  if (typeof assignee !== "string" || !ws.members.has(assignee)) {
3687
4988
  return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
@@ -3716,9 +5017,10 @@ var Coordinator = class {
3716
5017
  history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
3717
5018
  paused: false
3718
5019
  };
3719
- if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
5020
+ if (orig.review_required || orig.review !== void 0 || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
3720
5021
  newTask.review_required = true;
3721
5022
  }
5023
+ newTask.prior_authors = successorAuthors(orig, p.from);
3722
5024
  ws.tasks.set(newId, newTask);
3723
5025
  orig.state = "escalated";
3724
5026
  orig.updated_at = now;
@@ -3735,8 +5037,8 @@ function memberToDict(m) {
3735
5037
  joined: m.joined
3736
5038
  };
3737
5039
  if (m.display_name) out.display_name = m.display_name;
3738
- if (m.capabilities) out.capabilities = m.capabilities;
3739
- if (m.scopes) out.scopes = m.scopes;
5040
+ if (m.capabilities && Object.keys(m.capabilities).length) out.capabilities = m.capabilities;
5041
+ if (m.scopes?.length) out.scopes = m.scopes;
3740
5042
  if (m.keys.length) {
3741
5043
  out.jwks = { keys: m.keys.filter((k) => !k.revoked_at && !k.valid_until).map((k) => k.jwk) };
3742
5044
  out.key_history = m.keys;
@@ -3748,7 +5050,6 @@ function memberToDict(m) {
3748
5050
  }
3749
5051
 
3750
5052
  // src/policy.ts
3751
- var CRIT2 = { low: 0, medium: 1, high: 2, critical: 3 };
3752
5053
  function makeDefaultPolicy(seniorTarget) {
3753
5054
  const routingPolicy = (task, candidates) => {
3754
5055
  const eligible = candidates;
@@ -3766,15 +5067,8 @@ function makeDefaultPolicy(seniorTarget) {
3766
5067
  };
3767
5068
  };
3768
5069
  const reviewDepthPolicy = (_task, hints) => {
3769
- const crit = hints.criticality ?? "medium";
3770
- let conf = 1;
3771
- try {
3772
- conf = Number(hints.confidence ?? 1);
3773
- } catch {
3774
- conf = 1;
3775
- }
3776
- if (isNaN(conf)) conf = 1;
3777
- const r = CRIT2[crit] ?? 1;
5070
+ const conf = confidenceOf(hints);
5071
+ const r = criticalityOf(hints);
3778
5072
  if (r >= 3) return {
3779
5073
  depth: "full",
3780
5074
  rationale: { policy_id: "default", summary: "criticality=critical: full review" }
@@ -3799,15 +5093,8 @@ function makeDefaultPolicy(seniorTarget) {
3799
5093
  };
3800
5094
  };
3801
5095
  const escalationPolicy = (_task, hints) => {
3802
- const crit = hints.criticality ?? "medium";
3803
- let conf = 1;
3804
- try {
3805
- conf = Number(hints.confidence ?? 1);
3806
- } catch {
3807
- conf = 1;
3808
- }
3809
- if (isNaN(conf)) conf = 1;
3810
- const r = CRIT2[crit] ?? 1;
5096
+ const conf = confidenceOf(hints);
5097
+ const r = criticalityOf(hints);
3811
5098
  const escalate = r >= 3 || r >= 2 && conf < 0.6;
3812
5099
  return {
3813
5100
  escalate,
@@ -3827,6 +5114,19 @@ function wrapMcpToolCall(coord, workspace, options) {
3827
5114
  if (!workspace) throw new Error("workspace is required");
3828
5115
  if (!options.caller) throw new Error("caller is required");
3829
5116
  if (!options.tool) throw new Error("tool is required");
5117
+ if (options.reviewRequired) {
5118
+ const described = coord.dispatch({
5119
+ jsonrpc: "2.0",
5120
+ id: `wrap-${options.tool}-describe`,
5121
+ method: "workspace.describe",
5122
+ params: { workspace, from: options.caller }
5123
+ });
5124
+ if (described.error) throw new Error(`workspace.describe failed: ${JSON.stringify(described.error)}`);
5125
+ const members = described.result.members;
5126
+ if (!members.some((m) => m.type === "human" && m.uri !== options.caller)) {
5127
+ throw new Error("review_required needs a human member other than the caller to review the result; nothing was recorded");
5128
+ }
5129
+ }
3830
5130
  const inputHash = contentHash(options.args);
3831
5131
  const outputHash = contentHash(options.result);
3832
5132
  const kind = options.taskKind ?? `mcp_call:${options.tool}`;
@@ -3845,6 +5145,7 @@ function wrapMcpToolCall(coord, workspace, options) {
3845
5145
  input: options.args
3846
5146
  };
3847
5147
  if (options.routingHints) createParams.routing_hints = { ...options.routingHints };
5148
+ if (options.reviewRequired) createParams.review_required = true;
3848
5149
  const createResp = coord.dispatch({
3849
5150
  jsonrpc: "2.0",
3850
5151
  id: `wrap-${options.tool}-create`,
@@ -3874,7 +5175,7 @@ function wrapMcpToolCall(coord, workspace, options) {
3874
5175
  method: "task.complete",
3875
5176
  params: completeParams
3876
5177
  });
3877
- if (completeResp.error) throw new Error(`task.complete failed: ${JSON.stringify(completeResp.error)}`);
5178
+ if (completeResp.error) throw new Error(`task.complete failed for ${taskId}: ${JSON.stringify(completeResp.error)}`);
3878
5179
  return { task_id: taskId, input_hash: inputHash, output_hash: outputHash };
3879
5180
  }
3880
5181
  function wrapA2aMessageExchange(coord, workspace, options) {
@@ -3939,6 +5240,7 @@ function wrapA2aMessageExchange(coord, workspace, options) {
3939
5240
  0 && (module.exports = {
3940
5241
  Coordinator,
3941
5242
  CoordinatorError,
5243
+ DEFAULT_MAX_PATCHED_BYTES,
3942
5244
  E,
3943
5245
  IdFactory,
3944
5246
  MemoryStore,