@brightbeamai/chap-coordinator 0.3.4 → 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.
package/dist/index.cjs CHANGED
@@ -62,7 +62,7 @@ module.exports = __toCommonJS(src_exports);
62
62
  var import_node_crypto = require("crypto");
63
63
  var ZERO_HASH = "sha256:" + "0".repeat(64);
64
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.';
65
- 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.";
66
66
  var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
67
67
  function assertNoLoneSurrogate(s) {
68
68
  for (let i = 0; i < s.length; i++) {
@@ -83,7 +83,7 @@ function canonicalString(s) {
83
83
  function canonicalNumber(n) {
84
84
  if (!Number.isFinite(n)) {
85
85
  throw new Error(
86
- "Non-finite numbers are not permitted in a CHAP canonical value"
86
+ "Non-finite numbers are not permitted in a CHAP canonical value."
87
87
  );
88
88
  }
89
89
  if (!Number.isInteger(n)) {
@@ -156,6 +156,7 @@ var OWNING_PROFILE = Object.freeze({
156
156
  "participant.leave": "core/1.0",
157
157
  "participant.revoke_key": "core/1.0",
158
158
  "participant.rotate_key": "core/1.0",
159
+ "review.acknowledge": "review/1.0",
159
160
  "review.depth": "routing/1.0",
160
161
  "review.request": "review/1.0",
161
162
  "task.complete": "core/1.0",
@@ -166,6 +167,7 @@ var OWNING_PROFILE = Object.freeze({
166
167
  "whisper.ask": "whisper/1.0",
167
168
  "workspace.create": "core/1.0",
168
169
  "workspace.describe": "core/1.0",
170
+ "workspace.invite": "core/1.0",
169
171
  "workspace.set_profiles": "core/1.0"
170
172
  });
171
173
  var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
@@ -174,6 +176,20 @@ var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
174
176
  "audit.verify_chain",
175
177
  "audit.verify_receipt"
176
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
+ });
177
193
 
178
194
  // src/crypto.ts
179
195
  var import_node_crypto2 = require("crypto");
@@ -225,21 +241,132 @@ function signEnvelope(canonical, privateKey, kid) {
225
241
  const sig = (0, import_node_crypto2.sign)(null, canonical, privateKey);
226
242
  return `ed25519:${kid}:${sig.toString("base64")}`;
227
243
  }
244
+ function strictBase64(s) {
245
+ const bytes = Buffer.from(s, "base64");
246
+ return bytes.toString("base64") === s ? bytes : void 0;
247
+ }
228
248
  function verifyEnvelope(canonical, sigTag, publicKey) {
229
249
  if (!sigTag.startsWith("ed25519:")) return false;
230
250
  const parts = sigTag.split(":");
231
251
  if (parts.length < 2) return false;
232
- const sigB64 = parts[parts.length - 1];
252
+ const sig = strictBase64(parts[parts.length - 1]);
253
+ if (sig === void 0) return false;
233
254
  try {
234
- const sig = Buffer.from(sigB64, "base64");
235
255
  return (0, import_node_crypto2.verify)(null, canonical, publicKey, sig);
236
256
  } catch {
237
257
  return false;
238
258
  }
239
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
+ }
240
265
  function publicKeyFromJwk(jwk) {
241
266
  return publicKeyFromRaw(b64urlDecode(jwk.x));
242
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
+ }
243
370
  function deriveKeypair(uri) {
244
371
  const seed = deriveSeed(uri);
245
372
  const privateKey = privateKeyFromSeed(seed);
@@ -356,6 +483,7 @@ var E = {
356
483
  SIG_KEY_REVOKED: -32072,
357
484
  SIG_ROTATION_KEY_MISMATCH: -32073,
358
485
  SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
486
+ SIG_ALREADY_ACCEPTED: -32075,
359
487
  // audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
360
488
  SCITT_UNREACHABLE: -32080,
361
489
  SCITT_STATEMENT_REJECTED: -32081,
@@ -388,6 +516,44 @@ function isValidEnvelope(env) {
388
516
  return e.jsonrpc === "2.0";
389
517
  }
390
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
+
391
557
  // src/audit.ts
392
558
  function entryRecord(entry) {
393
559
  if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
@@ -431,7 +597,8 @@ var UNRECORDED_CODES = /* @__PURE__ */ new Set([
431
597
  E.SIG_VERIFY_FAILED,
432
598
  E.SIG_KEY_NOT_FOUND,
433
599
  E.SIG_KEY_REVOKED,
434
- E.SIG_ROTATION_KEY_MISMATCH
600
+ E.SIG_ROTATION_KEY_MISMATCH,
601
+ E.SIG_ALREADY_ACCEPTED
435
602
  ]);
436
603
  var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
437
604
  function refusalIsRecorded(method, error, privileged) {
@@ -848,7 +1015,8 @@ var COLLECTIONS = [
848
1015
  "deliberations",
849
1016
  "handoffs",
850
1017
  "snapshots",
851
- "route_decisions"
1018
+ "route_decisions",
1019
+ "invitations"
852
1020
  ];
853
1021
  var IDEMPOTENCY_KEYS = "idempotency_keys";
854
1022
  var StoreConflictError = class extends Error {
@@ -859,7 +1027,7 @@ var StoreConflictError = class extends Error {
859
1027
  base;
860
1028
  constructor(workspace, held, base) {
861
1029
  super(
862
- `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.`
863
1031
  );
864
1032
  this.name = "StoreConflictError";
865
1033
  this.workspace = workspace;
@@ -936,20 +1104,24 @@ function assembleSnapshot(meta, entries, objects) {
936
1104
  var MemoryStore = class {
937
1105
  held = /* @__PURE__ */ new Map();
938
1106
  load() {
939
- const out = [];
940
- for (const [id, h] of this.held) {
941
- const objects = [];
942
- for (const [collection, items] of h.objects) {
943
- for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
944
- }
945
- out.push({
946
- id,
947
- data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
948
- version: h.version,
949
- updated_at: h.updated_at
950
- });
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)]);
951
1115
  }
952
- 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;
953
1125
  }
954
1126
  save(record) {
955
1127
  this.apply(changeFromRecord(record));
@@ -1076,7 +1248,8 @@ function makeApi(coord) {
1076
1248
  workspace: {
1077
1249
  create: (p) => c("workspace.create", p),
1078
1250
  describe: (p) => c("workspace.describe", p),
1079
- set_profiles: (p) => c("workspace.set_profiles", p)
1251
+ set_profiles: (p) => c("workspace.set_profiles", p),
1252
+ invite: (p) => c("workspace.invite", p)
1080
1253
  },
1081
1254
  participant: {
1082
1255
  join: (p) => c("participant.join", p),
@@ -1092,6 +1265,7 @@ function makeApi(coord) {
1092
1265
  },
1093
1266
  review: {
1094
1267
  request: (p) => c("review.request", p),
1268
+ acknowledge: (p) => c("review.acknowledge", p),
1095
1269
  depth: (p) => c("review.depth", p)
1096
1270
  },
1097
1271
  decide: {
@@ -1165,10 +1339,17 @@ function registerAuditScitt(coord) {
1165
1339
  coord.handlers.set("audit.submit_to_scitt", (p) => {
1166
1340
  const ws = coord.workspaces.get(p.workspace);
1167
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;
1168
1349
  const range = p.range ?? {};
1169
1350
  const fromSeq = range.from_seq ?? 0;
1170
1351
  const toSeq = range.to_seq ?? ws.audit.length;
1171
- const issuer = p.issuer ?? "service:coordinator";
1352
+ const issuer = p.issuer || "service:coordinator";
1172
1353
  if (!coord.options.scittSubmitter) {
1173
1354
  const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
1174
1355
  return { result: {
@@ -1198,7 +1379,7 @@ function registerAuditScitt(coord) {
1198
1379
  });
1199
1380
  coord.handlers.set("audit.verify_receipt", (p) => {
1200
1381
  const receipt = p.receipt;
1201
- if (typeof receipt !== "object" || receipt === null) {
1382
+ if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
1202
1383
  return { error: rpcError(E.PARAMS, "receipt must be an object") };
1203
1384
  }
1204
1385
  if (coord.options.verifyScittReceipt) {
@@ -1212,7 +1393,7 @@ function registerAuditScitt(coord) {
1212
1393
  if (!ok) return { error: rpcError(E.SCITT_RECEIPT_INVALID, "Receipt did not verify") };
1213
1394
  return { result: { verified: true } };
1214
1395
  }
1215
- 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") };
1216
1397
  });
1217
1398
  coord.handlers.set("audit.verify_chain", (p) => {
1218
1399
  const ws = coord.workspaces.get(p.workspace);
@@ -1220,7 +1401,7 @@ function registerAuditScitt(coord) {
1220
1401
  if (!(ws.chain_enabled || coord.options.enableChain)) {
1221
1402
  return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
1222
1403
  }
1223
- if (p.from_seq !== void 0 || p.to_seq !== void 0) {
1404
+ if (p.from_seq != null || p.to_seq != null) {
1224
1405
  return { error: rpcError(
1225
1406
  E.PARAMS,
1226
1407
  "Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
@@ -1282,13 +1463,21 @@ function registerAuditScitt(coord) {
1282
1463
  // src/profiles/control.ts
1283
1464
  var VALID_MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
1284
1465
  var VALID_SCOPES = /* @__PURE__ */ new Set(["task", "participant", "workspace"]);
1466
+ var SCOPE_ERROR = "scope must be task, participant or workspace";
1285
1467
  function registerControl(coord) {
1286
1468
  coord.handlers.set("control.pause", (p) => {
1287
1469
  const ws = coord.workspaces.get(p.workspace);
1288
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;
1289
1478
  const scope = p.scope || "task";
1290
1479
  if (!VALID_SCOPES.has(scope)) {
1291
- return { error: rpcError(E.PARAMS, `scope must be task/participant/workspace`) };
1480
+ return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
1292
1481
  }
1293
1482
  if (scope === "task") {
1294
1483
  const task = ws.tasks.get(p.task_id);
@@ -1306,7 +1495,8 @@ function registerControl(coord) {
1306
1495
  }
1307
1496
  if (scope === "participant") {
1308
1497
  const uri = p.participant_uri || p.uri;
1309
- 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}`) };
1310
1500
  ws.members.get(uri).paused = true;
1311
1501
  return { result: {
1312
1502
  scope: "participant",
@@ -1321,9 +1511,11 @@ function registerControl(coord) {
1321
1511
  coord.handlers.set("control.resume", (p) => {
1322
1512
  const ws = coord.workspaces.get(p.workspace);
1323
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;
1324
1516
  const scope = p.scope || "task";
1325
1517
  if (!VALID_SCOPES.has(scope)) {
1326
- return { error: rpcError(E.PARAMS, `scope must be task/participant/workspace`) };
1518
+ return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
1327
1519
  }
1328
1520
  if (scope === "task") {
1329
1521
  const task = ws.tasks.get(p.task_id);
@@ -1341,7 +1533,9 @@ function registerControl(coord) {
1341
1533
  }
1342
1534
  if (scope === "participant") {
1343
1535
  const uri = p.participant_uri || p.uri;
1344
- 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") };
1345
1539
  ws.members.get(uri).paused = false;
1346
1540
  return { result: { scope: "participant", participant_uri: uri, paused: false } };
1347
1541
  }
@@ -1356,6 +1550,12 @@ function registerControl(coord) {
1356
1550
  if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
1357
1551
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
1358
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
+ }
1359
1559
  task.state = "cancelled";
1360
1560
  task.updated_at = coord.now();
1361
1561
  task.history.push({ ts: task.updated_at, from: p.from, state: "cancelled", note: p.reason });
@@ -1364,6 +1564,8 @@ function registerControl(coord) {
1364
1564
  coord.handlers.set("control.snapshot", (p) => {
1365
1565
  const ws = coord.workspaces.get(p.workspace);
1366
1566
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1567
+ const bad = paramsError(p, [["include", "strings"], ["label", "string"]]);
1568
+ if (bad) return bad;
1367
1569
  if (Array.isArray(p.include) && p.include.length === 0) {
1368
1570
  return { error: rpcError(E.PARAMS, "include must not be empty") };
1369
1571
  }
@@ -1418,6 +1620,14 @@ function registerControl(coord) {
1418
1620
  coord.handlers.set("control.rollback", (p) => {
1419
1621
  const ws = coord.workspaces.get(p.workspace);
1420
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;
1421
1631
  const snapId = p.to_snapshot_artefact_id || p.snapshot_artefact_id || p.snapshot_id;
1422
1632
  if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
1423
1633
  const snap = ws.snapshots.get(snapId);
@@ -1439,7 +1649,8 @@ function registerControl(coord) {
1439
1649
  for (const [uri, snapM] of snapByUri) {
1440
1650
  if (ws.members.has(uri)) {
1441
1651
  const cur = ws.members.get(uri);
1442
- cur.role = snapM.role ?? cur.role;
1652
+ const role = snapM.role ?? cur.role;
1653
+ if (role !== "admin" && cur.role !== "admin") cur.role = role;
1443
1654
  cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
1444
1655
  }
1445
1656
  }
@@ -1458,10 +1669,18 @@ function registerControl(coord) {
1458
1669
  const oldId = p.task_id;
1459
1670
  const old = ws.tasks.get(oldId);
1460
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
+ }
1461
1678
  const newSpec = p.successor_task ?? {};
1462
- if (!newSpec.kind) {
1679
+ if (typeof newSpec.kind !== "string" || !newSpec.kind) {
1463
1680
  return { error: rpcError(E.PARAMS, "successor_task must include kind") };
1464
1681
  }
1682
+ const bad = paramsError(p, [["successor_task.assignee", "string"], ["reason", "string"]]);
1683
+ if (bad) return bad;
1465
1684
  const assignee = newSpec.assignee || old.assignee;
1466
1685
  if (typeof assignee !== "string" || !ws.members.has(assignee)) {
1467
1686
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
@@ -1508,6 +1727,7 @@ function registerControl(coord) {
1508
1727
  } else if ("review_required" in newSpec) {
1509
1728
  newTask.review_required = !!newSpec.review_required;
1510
1729
  }
1730
+ newTask.prior_authors = successorAuthors(old, p.from);
1511
1731
  ws.tasks.set(newId, newTask);
1512
1732
  old.state = "superseded";
1513
1733
  old.superseded_by = newId;
@@ -1518,27 +1738,37 @@ function registerControl(coord) {
1518
1738
  state: "superseded",
1519
1739
  note: `-> ${newId}: ${p.reason || ""}`
1520
1740
  });
1521
- 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
+ } };
1522
1746
  });
1523
1747
  coord.handlers.set("control.set_mode_ceiling", (p) => {
1524
1748
  const ws = coord.workspaces.get(p.workspace);
1525
1749
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1750
+ const bad = paramsError(p, [["reason", "string"]]);
1751
+ if (bad) return bad;
1526
1752
  const m = p.new_ceiling ?? p.mode_ceiling;
1527
1753
  if (!VALID_MODES.has(m)) {
1528
- 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") };
1529
1755
  }
1530
1756
  ws.mode_ceiling = m;
1531
- return { result: { mode_ceiling: ws.mode_ceiling, reason: p.reason } };
1757
+ return { result: { mode_ceiling: ws.mode_ceiling, ...p.reason != null ? { reason: p.reason } : {} } };
1532
1758
  });
1533
1759
  }
1534
1760
 
1535
1761
  // src/profiles/deliberation.ts
1536
1762
  var RESERVED_IDS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1763
+ var QUORUM_ARG = /^[0-9]+$/;
1764
+ var THRESHOLD_ARG = /^[0-9]+(\.[0-9]+)?$/;
1537
1765
  function parseRule(rule) {
1538
- if (rule.includes(":")) {
1539
- const [kind, arg] = rule.split(":", 2);
1540
- if (kind === "quorum") return { kind, params: { n: parseInt(arg, 10) } };
1541
- 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)) {
1542
1772
  return { kind, params: { threshold: parseFloat(arg) } };
1543
1773
  }
1544
1774
  }
@@ -1623,6 +1853,23 @@ function registerDeliberation(coord) {
1623
1853
  for (const f of ["from", "rule"]) {
1624
1854
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1625
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
+ }
1626
1873
  const to = p.to ?? p.participants;
1627
1874
  const participants = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
1628
1875
  if (!participants.length) {
@@ -1673,6 +1920,8 @@ function registerDeliberation(coord) {
1673
1920
  if (delib.state !== "open") {
1674
1921
  return { error: rpcError(E.DELIB_CLOSED_OR_LAPSED, "Deliberation not open") };
1675
1922
  }
1923
+ const bad = paramsError(p, [["comment", "string"], ["text", "string"]]);
1924
+ if (bad) return bad;
1676
1925
  delib.comments.push({
1677
1926
  ts: coord.now(),
1678
1927
  from: p.from,
@@ -1695,6 +1944,8 @@ function registerDeliberation(coord) {
1695
1944
  if (v !== "yea" && v !== "nay" && v !== "abstain") {
1696
1945
  return { error: rpcError(E.PARAMS, "vote must be yea/nay/abstain") };
1697
1946
  }
1947
+ const bad = paramsError(p, [["veto_invoked", "boolean"]]);
1948
+ if (bad) return bad;
1698
1949
  const voter = p.from;
1699
1950
  if (!delib.participants.includes(voter)) {
1700
1951
  return { error: rpcError(
@@ -1736,6 +1987,9 @@ function registerHandoff(coord) {
1736
1987
  for (const f of ["from", "to", "tasks"]) {
1737
1988
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1738
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;
1739
1993
  const proposer = p.from;
1740
1994
  const recipient = p.to;
1741
1995
  if (typeof recipient === "string" && !recipient.startsWith("group:")) {
@@ -1752,6 +2006,11 @@ function registerHandoff(coord) {
1752
2006
  }
1753
2007
  const proposed = [];
1754
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;
1755
2014
  const tid = t.task_id;
1756
2015
  if (!tid || !ws.tasks.has(tid)) {
1757
2016
  return { error: rpcError(E.PARAMS, `Unknown task in handoff: ${tid}`) };
@@ -1833,6 +2092,8 @@ function registerHandoff(coord) {
1833
2092
  if (ws.members.get(acceptor).paused) {
1834
2093
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
1835
2094
  }
2095
+ const bad = paramsError(p, [["accepted_task_ids", "strings"]]);
2096
+ if (bad) return bad;
1836
2097
  if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
1837
2098
  return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
1838
2099
  }
@@ -1843,9 +2104,18 @@ function registerHandoff(coord) {
1843
2104
  return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
1844
2105
  }
1845
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
+ }
1846
2115
  const now = coord.now();
1847
2116
  for (const tid of acceptedIds) {
1848
2117
  const task = ws.tasks.get(tid);
2118
+ if (acceptor !== task.assignee) task.prior_authors = unionOf(task.prior_authors ?? [], heldBy(task, ho.proposer));
1849
2119
  task.assignee = acceptor;
1850
2120
  task.updated_at = now;
1851
2121
  task.history.push({
@@ -1886,16 +2156,20 @@ function registerHandoff(coord) {
1886
2156
 
1887
2157
  // src/profiles/routing.ts
1888
2158
  var CRIT = { low: 0, medium: 1, high: 2, critical: 3 };
1889
- 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) {
1890
2167
  const crit = hints.criticality ?? "medium";
1891
- let conf = 1;
1892
- try {
1893
- conf = Number(hints.confidence ?? 1);
1894
- } catch {
1895
- conf = 1;
1896
- }
1897
- if (isNaN(conf)) conf = 1;
1898
- 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);
1899
2173
  if (r >= 3) return { depth: "full", summary: "criticality=critical: full review" };
1900
2174
  if (r === 2 && conf < 0.7) return { depth: "full", summary: "criticality=high and confidence<0.7: full review" };
1901
2175
  if (r === 0 && conf >= 0.95) return { depth: "skip", summary: "very low criticality + very high confidence: skip" };
@@ -1961,6 +2235,8 @@ function registerRouting(coord) {
1961
2235
  if (ws.members.get(selected).paused) {
1962
2236
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
1963
2237
  }
2238
+ const leaving = heldBy(task, p.from);
2239
+ if (selected !== task.assignee && leaving.length) task.prior_authors = unionOf(task.prior_authors ?? [], leaving);
1964
2240
  task.assignee = selected;
1965
2241
  task.updated_at = coord.now();
1966
2242
  task.history.push({
@@ -1991,6 +2267,8 @@ function registerRouting(coord) {
1991
2267
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1992
2268
  const task = ws.tasks.get(p.task_id);
1993
2269
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2270
+ const bad = paramsError(p, [["artefact_routing_hints", "object"]]);
2271
+ if (bad) return bad;
1994
2272
  const hints = {
1995
2273
  ...task.routing_hints ?? {},
1996
2274
  ...p.artefact_routing_hints ?? {}
@@ -2048,6 +2326,8 @@ function registerRouting(coord) {
2048
2326
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2049
2327
  const task = ws.tasks.get(p.task_id);
2050
2328
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2329
+ const bad = paramsError(p, [["default_escalation_target", "string"]]);
2330
+ if (bad) return bad;
2051
2331
  const hints = { ...task.routing_hints ?? {} };
2052
2332
  let escalate, to, rule;
2053
2333
  if (coord.options.escalationPolicy) {
@@ -2061,15 +2341,8 @@ function registerRouting(coord) {
2061
2341
  return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `escalation policy error: ${msg}`) };
2062
2342
  }
2063
2343
  } else {
2064
- const crit = hints.criticality ?? "medium";
2065
- let conf = 1;
2066
- try {
2067
- conf = Number(hints.confidence ?? 1);
2068
- } catch {
2069
- conf = 1;
2070
- }
2071
- if (isNaN(conf)) conf = 1;
2072
- const r = CRIT[crit] ?? 1;
2344
+ const conf = confidenceOf(hints);
2345
+ const r = criticalityOf(hints);
2073
2346
  escalate = r >= 3 || r >= 2 && conf < 0.6;
2074
2347
  to = p.default_escalation_target;
2075
2348
  rule = {
@@ -2126,6 +2399,9 @@ function registerSecuritySigned(coord) {
2126
2399
  for (const f of ["from", "old_kid", "new_jwk"]) {
2127
2400
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
2128
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
+ }
2129
2405
  const member = ws.members.get(p.from);
2130
2406
  if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown participant: ${p.from}`) };
2131
2407
  const oldKid = p.old_kid;
@@ -2136,6 +2412,9 @@ function registerSecuritySigned(coord) {
2136
2412
  if (!newJwk || typeof newJwk.kid !== "string") {
2137
2413
  return { error: rpcError(E.PARAMS, "new_jwk must include kid") };
2138
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
+ }
2139
2418
  const now = coord.now();
2140
2419
  oldKey.valid_until = now;
2141
2420
  member.keys.push({
@@ -2151,6 +2430,11 @@ function registerSecuritySigned(coord) {
2151
2430
  for (const f of ["target_uri", "kid"]) {
2152
2431
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
2153
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;
2154
2438
  const sender = p.from;
2155
2439
  if (sender !== p.target_uri) {
2156
2440
  const caller = sender ? ws.members.get(sender) : void 0;
@@ -2193,8 +2477,10 @@ function registerWhisper(coord) {
2193
2477
  if (!ws.tasks.has(p.task_id)) {
2194
2478
  return { error: rpcError(E.PARAMS, "Unknown task") };
2195
2479
  }
2196
- if (p.options !== void 0 && p.options !== null && !Array.isArray(p.options)) {
2197
- 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") };
2198
2484
  }
2199
2485
  const to = p.to;
2200
2486
  const askee = Array.isArray(to) ? to : [to];
@@ -2257,7 +2543,7 @@ function registerWhisper(coord) {
2257
2543
  if (!hasOption) {
2258
2544
  return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
2259
2545
  }
2260
- 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));
2261
2547
  if (!valid.has(answerOption)) {
2262
2548
  return { error: rpcError(
2263
2549
  E.WHISPER_OPTION_NOT_IN_SET,
@@ -2275,12 +2561,13 @@ function registerWhisper(coord) {
2275
2561
  prompt.comment = p.comment;
2276
2562
  return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
2277
2563
  });
2278
- coord.checkWhisperLapses = (workspaceId, now) => {
2564
+ coord.checkWhisperLapses = (workspaceId, now) => coord.outsideCall(workspaceId, () => lapse(workspaceId, now));
2565
+ function lapse(workspaceId, now) {
2279
2566
  const ws = coord.workspaces.get(workspaceId);
2280
2567
  if (!ws) return [];
2281
2568
  const cutoff = parseIso(now ?? coord.now());
2282
2569
  const emitted = [];
2283
- for (const prompt of ws.whispers.values()) {
2570
+ for (const prompt of rawValues(ws.whispers)) {
2284
2571
  if (prompt.state !== "pending") continue;
2285
2572
  const asked = parseIso(prompt.asked_at);
2286
2573
  const deadline = asked + prompt.deadline_ms;
@@ -2310,7 +2597,7 @@ function registerWhisper(coord) {
2310
2597
  emitted.push(notify);
2311
2598
  }
2312
2599
  return emitted;
2313
- };
2600
+ }
2314
2601
  }
2315
2602
 
2316
2603
  // src/coordinator.ts
@@ -2323,6 +2610,7 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
2323
2610
  "control.rollback",
2324
2611
  "control.set_mode_ceiling",
2325
2612
  "workspace.set_profiles",
2613
+ "workspace.invite",
2326
2614
  "participant.rotate_key",
2327
2615
  "participant.revoke_key"
2328
2616
  ]);
@@ -2366,12 +2654,9 @@ function missing(params, fields) {
2366
2654
  for (const f of fields) if (!(f in params)) return f;
2367
2655
  return null;
2368
2656
  }
2369
- function tagsError(p) {
2370
- const tags = p.tags;
2371
- if (tags !== void 0 && (!Array.isArray(tags) || !tags.every((t) => typeof t === "string"))) {
2372
- return { error: rpcError(E.PARAMS, "tags must be a list of strings") };
2373
- }
2374
- 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);
2375
2660
  }
2376
2661
  function reviewRuleSupported(rule) {
2377
2662
  if (rule === "any_one_approves" || rule === "all_approve") return true;
@@ -2392,8 +2677,214 @@ function reviewSatisfied(review) {
2392
2677
  if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
2393
2678
  return approvers.size >= 1;
2394
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
+ }
2395
2885
  function reply(env, body) {
2396
- 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 };
2397
2888
  if (body.error !== void 0) out.error = body.error;
2398
2889
  else out.result = body.result;
2399
2890
  return out;
@@ -2408,7 +2899,7 @@ function indexSigned(index, entry) {
2408
2899
  return;
2409
2900
  }
2410
2901
  if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
2411
- else index.accepted.add(digest);
2902
+ else if (!index.accepted.has(digest)) index.accepted.set(digest, entry.seq);
2412
2903
  }
2413
2904
  function isThenable(value) {
2414
2905
  return typeof value?.then === "function";
@@ -2436,13 +2927,21 @@ var Coordinator = class {
2436
2927
  unflushed = /* @__PURE__ */ new Set();
2437
2928
  /** Workspaces with a write queued behind an async write in flight. */
2438
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();
2439
2938
  clockMs;
2440
2939
  frozenNow;
2441
2940
  auditListeners = [];
2442
2941
  /**
2443
2942
  * The signed calls on each workspace's log, by what their senders signed:
2444
- * recorded refusals with their seq and code, and accepted calls. Rebuilt
2445
- * 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.
2446
2945
  */
2447
2946
  signedIndexes = /* @__PURE__ */ new WeakMap();
2448
2947
  /** Method handler registry; profiles plug into this. */
@@ -2578,6 +3077,7 @@ var Coordinator = class {
2578
3077
  change = this.takeChange(ws, p);
2579
3078
  } catch (err) {
2580
3079
  p.replace = true;
3080
+ if (this.options.storeErrors !== "log") this.held.add(ws.id);
2581
3081
  this.reportStoreError(err, { id: ws.id, version: ws.audit.length });
2582
3082
  return;
2583
3083
  }
@@ -2586,6 +3086,7 @@ var Coordinator = class {
2586
3086
  const done = () => {
2587
3087
  p.seq = taken.version;
2588
3088
  if (taken.replace) p.replace = false;
3089
+ this.announce(ws, taken.version);
2589
3090
  };
2590
3091
  const failed = (err) => {
2591
3092
  this.giveBack(ws, p, taken);
@@ -2595,13 +3096,34 @@ var Coordinator = class {
2595
3096
  try {
2596
3097
  result = this.store.apply(taken);
2597
3098
  } catch (err) {
3099
+ if (err instanceof StoreConflictError || this.options.storeErrors !== "log") this.held.add(ws.id);
2598
3100
  failed(err);
2599
3101
  return;
2600
3102
  }
2601
3103
  if (isThenable(result)) return Promise.resolve(result).then(done, failed);
2602
3104
  done();
2603
3105
  }
2604
- /** 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
+ */
2605
3127
  takeChange(ws, p) {
2606
3128
  const replace = p.replace;
2607
3129
  const objects = {};
@@ -2610,12 +3132,15 @@ var Coordinator = class {
2610
3132
  if (replace) {
2611
3133
  const all = {};
2612
3134
  for (const [key, value] of Map.prototype.entries.call(map)) {
2613
- all[key] = value;
3135
+ all[key] = storedCopy(name, value);
2614
3136
  }
2615
3137
  objects[name] = all;
2616
3138
  } else if (map.dirty.size) {
2617
3139
  const changed = {};
2618
- 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
+ }
2619
3144
  objects[name] = changed;
2620
3145
  }
2621
3146
  map.dirty.clear();
@@ -2667,24 +3192,28 @@ var Coordinator = class {
2667
3192
  this.reportStoreError(err, { id: ws.id, version: next });
2668
3193
  return;
2669
3194
  }
3195
+ const upTo = ws.audit.length;
2670
3196
  const pending = this.saveChains.get(ws.id);
2671
3197
  if (pending) {
2672
- this.trackSave(pending.then(() => this.store.save(record)), record);
3198
+ this.trackSave(pending.then(() => this.store.save(record)), record, () => this.announce(ws, upTo));
2673
3199
  return;
2674
3200
  }
2675
3201
  let result;
2676
3202
  try {
2677
3203
  result = this.store.save(record);
2678
3204
  } catch (err) {
3205
+ if (this.options.storeErrors !== "log") this.held.add(ws.id);
2679
3206
  this.reportStoreError(err, record);
2680
3207
  return;
2681
3208
  }
2682
3209
  if (isThenable(result)) {
2683
- this.trackSave(Promise.resolve(result), record);
3210
+ this.trackSave(Promise.resolve(result), record, () => this.announce(ws, upTo));
3211
+ return;
2684
3212
  }
3213
+ this.announce(ws, upTo);
2685
3214
  }
2686
- trackSave(save, record) {
2687
- 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));
2688
3217
  this.saveChains.set(record.id, guarded);
2689
3218
  void guarded.then(() => {
2690
3219
  if (this.saveChains.get(record.id) === guarded) {
@@ -2692,6 +3221,17 @@ var Coordinator = class {
2692
3221
  }
2693
3222
  });
2694
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
+ }
2695
3235
  reportStoreError(err, record) {
2696
3236
  if (this.options.onStoreError) {
2697
3237
  try {
@@ -2701,7 +3241,9 @@ var Coordinator = class {
2701
3241
  return;
2702
3242
  }
2703
3243
  const message = err instanceof Error ? err.message : String(err);
2704
- 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);
2705
3247
  }
2706
3248
  async drainSaves() {
2707
3249
  while (this.saveChains.size) {
@@ -2752,16 +3294,25 @@ var Coordinator = class {
2752
3294
  return null;
2753
3295
  }
2754
3296
  /**
2755
- * Assert that `sender` was addressed in the task's review. Applied to
2756
- * review-decision methods (decide.* and abstain.declare): to act on a
2757
- * review you must be one of the reviewers it was addressed to (the
2758
- * `to` set on review.request). Membership is the floor; this is the
2759
- * eligibility ceiling for decisions. Returns an error fragment if not
2760
- * 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.
2761
3305
  */
2762
- requireReviewer(task, sender) {
3306
+ requireReviewer(ws, task, sender) {
2763
3307
  const review = task.review;
2764
- 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) {
2765
3316
  return null;
2766
3317
  }
2767
3318
  if (review.requested_to.some((r) => typeof r === "string" && (r.startsWith("workspace:") || r.startsWith("group:")))) {
@@ -2790,23 +3341,31 @@ var Coordinator = class {
2790
3341
  routing_policy_uri: ws.routing_policy_uri,
2791
3342
  step_up_window_sec: ws.step_up_window_sec,
2792
3343
  min_acr: ws.min_acr,
3344
+ signature_policy: ws.signature_policy,
3345
+ join_policy: ws.join_policy,
3346
+ review_rule: ws.review_rule,
2793
3347
  chain_head: ws.chain_head,
2794
3348
  chain_enabled: ws.chain_enabled
2795
3349
  };
2796
3350
  }
2797
- /** 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
+ */
2798
3355
  snapshotOf(ws) {
2799
3356
  const { chain_head, chain_enabled, ...own } = this.metaOf(ws);
3357
+ const stored = (name) => Array.from(rawValues(ws[name])).map((v) => storedCopy(name, v));
2800
3358
  return {
2801
3359
  ...own,
2802
- members: Array.from(rawValues(ws.members)),
2803
- tasks: Array.from(rawValues(ws.tasks)),
2804
- overrides: Array.from(rawValues(ws.overrides)),
2805
- whispers: Array.from(rawValues(ws.whispers)),
2806
- deliberations: Array.from(rawValues(ws.deliberations)),
2807
- handoffs: Array.from(rawValues(ws.handoffs)),
2808
- snapshots: Array.from(rawValues(ws.snapshots)),
2809
- 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"),
2810
3369
  idempotency_keys: { ...ws.idempotency_keys },
2811
3370
  audit: ws.audit,
2812
3371
  chain_head,
@@ -2834,7 +3393,8 @@ var Coordinator = class {
2834
3393
  }
2835
3394
  }
2836
3395
  /** One workspace from a snapshot entry, its maps tracked. */
2837
- rebuild(w) {
3396
+ rebuild(snapshot) {
3397
+ const w = shapeSnapshot(snapshot);
2838
3398
  const ws = {
2839
3399
  id: w.id,
2840
3400
  created: w.created,
@@ -2845,6 +3405,11 @@ var Coordinator = class {
2845
3405
  routing_policy_uri: w.routing_policy_uri,
2846
3406
  step_up_window_sec: w.step_up_window_sec ?? 300,
2847
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,
2848
3413
  members: this.newMap(),
2849
3414
  tasks: this.newMap(),
2850
3415
  overrides: this.newMap(),
@@ -2853,6 +3418,7 @@ var Coordinator = class {
2853
3418
  handoffs: this.newMap(),
2854
3419
  snapshots: this.newMap(),
2855
3420
  route_decisions: this.newMap(),
3421
+ invitations: this.newMap(),
2856
3422
  idempotency_keys: w.idempotency_keys ?? {},
2857
3423
  audit: w.audit ?? [],
2858
3424
  chain_head: w.chain_head,
@@ -2861,9 +3427,16 @@ var Coordinator = class {
2861
3427
  for (const m of w.members ?? []) {
2862
3428
  if (!Array.isArray(m.keys)) m.keys = [];
2863
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";
2864
3433
  ws.members.set(m.uri, m);
2865
3434
  }
2866
- 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
+ }
2867
3440
  for (const o of w.overrides ?? []) ws.overrides.set(o.id, o);
2868
3441
  for (const x of w.whispers ?? [])
2869
3442
  ws.whispers.set(x.id, x);
@@ -2907,20 +3480,119 @@ var Coordinator = class {
2907
3480
  }
2908
3481
  for (const x of w.route_decisions ?? [])
2909
3482
  ws.route_decisions.set(x.id, x);
3483
+ for (const x of w.invitations ?? []) ws.invitations.set(x.uri, x);
2910
3484
  return ws;
2911
3485
  }
2912
3486
  // -- dispatch ------------------------------------------------------
2913
3487
  dispatch(envelope) {
2914
3488
  this.frozenNow = this.advanceClock();
2915
3489
  this.tracker.depth++;
3490
+ let response;
3491
+ let target;
3492
+ let ran = false;
2916
3493
  try {
2917
- const response = this._dispatch(envelope);
2918
- if (response.error) this.recordRefusal(envelope, response.error);
2919
- 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
+ }
2920
3506
  } finally {
2921
3507
  if (--this.tracker.depth === 0) this.flushUnflushed();
2922
3508
  this.frozenNow = void 0;
2923
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;
2924
3596
  }
2925
3597
  _dispatch(envelope) {
2926
3598
  if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
@@ -2957,11 +3629,16 @@ var Coordinator = class {
2957
3629
  const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
2958
3630
  return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2959
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
+ }
2960
3635
  if (isSigned(envelope)) {
2961
3636
  const wsId2 = params.workspace;
2962
3637
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2963
3638
  if (ws) {
2964
- 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);
2965
3642
  if (prior) {
2966
3643
  return reply(envelope, { error: rpcError(
2967
3644
  prior.code,
@@ -2969,15 +3646,24 @@ var Coordinator = class {
2969
3646
  { refused_at_seq: prior.seq }
2970
3647
  ) });
2971
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
+ }
2972
3657
  }
2973
3658
  }
2974
- 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))) {
2975
3661
  const sigErr = this.verifySignature(envelope);
2976
3662
  if (sigErr) return reply(envelope, { error: sigErr });
2977
3663
  if (method === "participant.rotate_key") {
2978
3664
  const signingKid = envelope.sig.split(":")[1];
2979
3665
  const oldKid = params.old_kid;
2980
- if (oldKid !== void 0 && signingKid !== oldKid) {
3666
+ if (oldKid != null && signingKid !== oldKid) {
2981
3667
  return reply(envelope, { error: rpcError(
2982
3668
  E.SIG_ROTATION_KEY_MISMATCH,
2983
3669
  "Rotation must be signed with the old key"
@@ -3032,14 +3718,14 @@ var Coordinator = class {
3032
3718
  try {
3033
3719
  out = handler(ownCopy(params));
3034
3720
  } catch (e) {
3035
- const msg = e instanceof Error ? e.message : String(e);
3036
- 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") });
3037
3723
  }
3038
3724
  if (out.error) return reply(envelope, { error: out.error });
3039
- const wsId = params.workspace;
3725
+ const wsId = method === "workspace.create" ? out.result.workspace : params.workspace;
3040
3726
  if (typeof wsId === "string" && !READ_ONLY_METHODS.has(method)) {
3041
3727
  const ws = this.workspaces.get(wsId);
3042
- if (ws) this.recordAudit(ws, envelope);
3728
+ if (ws) this.recordAudit(ws, recordedForm(envelope));
3043
3729
  }
3044
3730
  return reply(envelope, { result: ownCopy(out.result) });
3045
3731
  }
@@ -3079,7 +3765,7 @@ var Coordinator = class {
3079
3765
  signedIndex(ws) {
3080
3766
  let index = this.signedIndexes.get(ws);
3081
3767
  if (!index) {
3082
- index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Set() };
3768
+ index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Map() };
3083
3769
  for (const entry of ws.audit) indexSigned(index, entry);
3084
3770
  this.signedIndexes.set(ws, index);
3085
3771
  }
@@ -3095,12 +3781,9 @@ var Coordinator = class {
3095
3781
  ws.audit.push(entry);
3096
3782
  const index = this.signedIndexes.get(ws);
3097
3783
  if (index) indexSigned(index, entry);
3098
- for (const l of this.auditListeners) {
3099
- try {
3100
- l(ws, entry);
3101
- } catch {
3102
- }
3103
- }
3784
+ const queued = this.unannounced.get(ws);
3785
+ if (queued) queued.push(entry);
3786
+ else this.unannounced.set(ws, [entry]);
3104
3787
  this.persist(ws);
3105
3788
  }
3106
3789
  // -- signature verification (security-signed/1.0) -----------------
@@ -3110,7 +3793,12 @@ var Coordinator = class {
3110
3793
  return rpcError(E.SIG_VERIFY_FAILED, "Missing top-level `sig` field");
3111
3794
  }
3112
3795
  const parts = sig.split(":");
3113
- 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) {
3114
3802
  return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
3115
3803
  }
3116
3804
  const kid = parts[1];
@@ -3144,6 +3832,14 @@ var Coordinator = class {
3144
3832
  if (revoked) return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
3145
3833
  return rpcError(E.SIG_KEY_NOT_FOUND, `No key ${kid} valid at ${ts} for ${sender}`);
3146
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`);
3147
3843
  try {
3148
3844
  const stripped = JSON.parse(JSON.stringify(envelope));
3149
3845
  delete stripped.sig;
@@ -3157,6 +3853,39 @@ var Coordinator = class {
3157
3853
  return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
3158
3854
  }
3159
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
+ }
3160
3889
  /** Lookup the key valid for a given participant at a given time. */
3161
3890
  lookupKey(member, kid, ts) {
3162
3891
  for (const k of member.keys) {
@@ -3230,6 +3959,7 @@ var Coordinator = class {
3230
3959
  this.handlers.set("workspace.create", (p) => this.opWorkspaceCreate(p));
3231
3960
  this.handlers.set("workspace.describe", (p) => this.opWorkspaceDescribe(p));
3232
3961
  this.handlers.set("workspace.set_profiles", (p) => this.opWorkspaceSetProfiles(p));
3962
+ this.handlers.set("workspace.invite", (p) => this.opWorkspaceInvite(p));
3233
3963
  this.handlers.set("participant.join", (p) => this.opParticipantJoin(p));
3234
3964
  this.handlers.set("participant.leave", (p) => this.opParticipantLeave(p));
3235
3965
  this.handlers.set("task.create", (p) => this.opTaskCreate(p));
@@ -3237,6 +3967,7 @@ var Coordinator = class {
3237
3967
  this.handlers.set("task.complete", (p) => this.opTaskComplete(p));
3238
3968
  this.handlers.set("audit.read", (p) => this.opAuditRead(p));
3239
3969
  this.handlers.set("review.request", (p) => this.opReviewRequest(p));
3970
+ this.handlers.set("review.acknowledge", (p) => this.opReviewAcknowledge(p));
3240
3971
  this.handlers.set("decide.approve", (p) => this.opDecide(p, "approve"));
3241
3972
  this.handlers.set("decide.reject", (p) => this.opDecide(p, "reject"));
3242
3973
  this.handlers.set("decide.override", (p) => this.opDecideOverride(p));
@@ -3256,24 +3987,59 @@ var Coordinator = class {
3256
3987
  // Core method handlers
3257
3988
  // ==========================================================
3258
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
+ }
3259
4028
  const id = p.workspace || this.ids.workspaceId();
3260
- if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
3261
4029
  if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
3262
- const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
3263
- for (const [profile, enforced, option] of [
3264
- ["security-signed/1.0", !!this.options.requireSignatures, "requireSignatures"],
3265
- ["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
3266
- ]) {
3267
- const name = profile.split("/")[0];
3268
- const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
3269
- if (enforced && !advertised) profiles.push(profile);
3270
- else if (advertised && !enforced) {
3271
- return { error: rpcError(
3272
- E.PARAMS,
3273
- `${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.`
3274
- ) };
3275
- }
4030
+ if (Array.isArray(p.profiles)) {
4031
+ const unknown = profilesError(p.profiles);
4032
+ if (unknown) return unknown;
3276
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 };
3277
4043
  const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
3278
4044
  const ws = {
3279
4045
  id,
@@ -3282,9 +4048,14 @@ var Coordinator = class {
3282
4048
  profiles,
3283
4049
  mode: p.mode ?? "trial",
3284
4050
  mode_ceiling: p.mode_ceiling ?? "production",
3285
- routing_policy_uri: p.routing_policy_uri,
3286
- step_up_window_sec: typeof p.step_up_window_sec === "number" ? p.step_up_window_sec : 300,
3287
- 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 } : {},
3288
4059
  members: this.newMap(),
3289
4060
  tasks: this.newMap(),
3290
4061
  overrides: this.newMap(),
@@ -3293,14 +4064,17 @@ var Coordinator = class {
3293
4064
  handoffs: this.newMap(),
3294
4065
  snapshots: this.newMap(),
3295
4066
  route_decisions: this.newMap(),
4067
+ invitations: this.newMap(),
3296
4068
  idempotency_keys: {},
3297
4069
  audit: [],
3298
4070
  chain_enabled: chainEnabled,
3299
4071
  chain_head: chainEnabled ? ZERO_HASH : void 0
3300
4072
  };
3301
- this.workspaces.set(id, ws);
4073
+ return { ws };
4074
+ }
4075
+ addWorkspace(ws) {
4076
+ this.workspaces.set(ws.id, ws);
3302
4077
  this.persisted.set(ws, { seq: 0, replace: false, keys: /* @__PURE__ */ new Set() });
3303
- return { result: { workspace: id, created: ws.created } };
3304
4078
  }
3305
4079
  opWorkspaceDescribe(p) {
3306
4080
  const miss = missing(p, ["workspace"]);
@@ -3319,6 +4093,9 @@ var Coordinator = class {
3319
4093
  mode_ceiling: ws.mode_ceiling,
3320
4094
  max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
3321
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",
3322
4099
  profiles: ws.profiles,
3323
4100
  members: Array.from(rawValues(ws.members)).map(memberToDict),
3324
4101
  audit_count: ws.audit.length,
@@ -3334,6 +4111,11 @@ var Coordinator = class {
3334
4111
  opWorkspaceSetProfiles(p) {
3335
4112
  const miss = missing(p, ["workspace", "from", "profiles"]);
3336
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;
3337
4119
  const ws = this.workspaces.get(p.workspace);
3338
4120
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3339
4121
  const notMember = this.requireMember(ws, p.from);
@@ -3342,6 +4124,14 @@ var Coordinator = class {
3342
4124
  return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
3343
4125
  }
3344
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 };
3345
4135
  if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
3346
4136
  ws.profiles = newProfiles;
3347
4137
  if (newProfiles.includes("audit-scitt/1.0") && !ws.chain_enabled) {
@@ -3353,20 +4143,76 @@ var Coordinator = class {
3353
4143
  opParticipantJoin(p) {
3354
4144
  const miss = missing(p, ["workspace", "from", "type"]);
3355
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
+ }
3356
4166
  let ws = this.workspaces.get(p.workspace);
4167
+ const made = ws === void 0;
3357
4168
  if (!ws) {
3358
- const create = this.opWorkspaceCreate({ workspace: p.workspace });
3359
- if (create.error) return create;
3360
- 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;
3361
4172
  }
3362
4173
  const uri = p.from;
3363
4174
  const now = this.now();
3364
4175
  const existing = ws.members.get(uri);
3365
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
+ }
3366
4212
  const member = {
3367
4213
  uri,
3368
- type: p.type,
3369
- role: p.role || "participant",
4214
+ type,
4215
+ role,
3370
4216
  joined: now,
3371
4217
  display_name: p.display_name,
3372
4218
  capabilities: p.capabilities,
@@ -3374,6 +4220,9 @@ var Coordinator = class {
3374
4220
  keys: [],
3375
4221
  paused: false
3376
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
+ }
3377
4226
  if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
3378
4227
  const claims = this.options.verifyOidcToken(p.oidc_token);
3379
4228
  if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
@@ -3382,6 +4231,9 @@ var Coordinator = class {
3382
4231
  if (boundUri != null && boundUri !== uri) {
3383
4232
  return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
3384
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
+ }
3385
4237
  if (existing) {
3386
4238
  if (existing.oidc_sub != null) {
3387
4239
  if (sub !== existing.oidc_sub) {
@@ -3390,19 +4242,23 @@ var Coordinator = class {
3390
4242
  } else if (boundUri !== uri) {
3391
4243
  return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
3392
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") };
3393
4247
  }
3394
4248
  member.oidc_sub = sub;
3395
4249
  tokenVerified = true;
3396
4250
  const at = claims.auth_time;
3397
- 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);
3398
4252
  member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
3399
- const cnf = claims.cnf;
3400
- const cnfJwk = cnf?.jwk;
3401
- if (cnfJwk && typeof cnfJwk.kid === "string") {
3402
- 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 });
3403
4259
  }
3404
4260
  }
3405
- 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)) {
3406
4262
  const subject = this.options.verifyVc(p.vc_presentation);
3407
4263
  if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
3408
4264
  const holder = [subject.holder, subject.id].find(
@@ -3412,21 +4268,54 @@ var Coordinator = class {
3412
4268
  return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
3413
4269
  }
3414
4270
  member.vc_holder = holder;
3415
- const vpJwk = subject.cnf_jwk;
3416
- if (vpJwk && typeof vpJwk.kid === "string") {
3417
- 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 });
3418
4279
  }
3419
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
+ ) };
4296
+ }
4297
+ member.keys = pinned.map((j) => ({ jwk: j, kid: j.kid, valid_from: now }));
4298
+ }
3420
4299
  const attested = [...member.keys];
3421
- const jwks = p.jwks;
3422
- if (jwks?.keys && attested.length === 0) {
3423
- for (const j of jwks.keys) {
3424
- 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)) {
3425
4303
  member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
3426
4304
  }
3427
4305
  }
3428
4306
  }
3429
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
+ }
3430
4319
  if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
3431
4320
  if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
3432
4321
  if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
@@ -3437,18 +4326,126 @@ var Coordinator = class {
3437
4326
  }
3438
4327
  return { result: { joined: true, as: uri } };
3439
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);
3440
4333
  ws.members.set(uri, member);
4334
+ if (invitation) ws.invitations.delete(uri);
3441
4335
  return { result: { joined: true, as: uri } };
3442
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
+ }
3443
4425
  opParticipantLeave(p) {
3444
4426
  const ws = this.workspaces.get(p.workspace);
3445
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
+ }
3446
4431
  ws.members.delete(p.from);
3447
4432
  return { result: { left: true } };
3448
4433
  }
3449
4434
  opTaskCreate(p) {
3450
4435
  const miss = missing(p, ["workspace", "from", "kind", "input"]);
3451
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
+ }
3452
4449
  const ws = this.workspaces.get(p.workspace);
3453
4450
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3454
4451
  const notMember = this.requireMember(ws, p.from);
@@ -3460,9 +4457,6 @@ var Coordinator = class {
3460
4457
  if (ws.members.get(assignee).paused) {
3461
4458
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
3462
4459
  }
3463
- if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
3464
- return { error: rpcError(E.PARAMS, "mode must be a string") };
3465
- }
3466
4460
  const requestedMode = p.mode || ws.mode;
3467
4461
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
3468
4462
  return { error: rpcError(
@@ -3470,9 +4464,6 @@ var Coordinator = class {
3470
4464
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
3471
4465
  ) };
3472
4466
  }
3473
- if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
3474
- return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
3475
- }
3476
4467
  const taskId = this.ids.taskId();
3477
4468
  const now = this.now();
3478
4469
  const task = {
@@ -3511,6 +4502,7 @@ var Coordinator = class {
3511
4502
  if (notMember) return notMember;
3512
4503
  const task = ws.tasks.get(p.task_id);
3513
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") };
3514
4506
  const newState = p.state;
3515
4507
  const legal = {
3516
4508
  created: ["in_progress", "declined"],
@@ -3521,12 +4513,42 @@ var Coordinator = class {
3521
4513
  if (!legal[task.state]?.includes(newState)) {
3522
4514
  return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
3523
4515
  }
3524
- if (newState === "completed" && task.review_required) {
4516
+ if (newState === "completed" && reviewRequired(ws, task)) {
3525
4517
  return { error: rpcError(
3526
4518
  E.PARAMS,
3527
4519
  "This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
3528
4520
  ) };
3529
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
+ }
3530
4552
  task.state = newState;
3531
4553
  task.updated_at = this.now();
3532
4554
  task.history.push({
@@ -3547,42 +4569,60 @@ var Coordinator = class {
3547
4569
  if (task.state !== "created" && task.state !== "in_progress") {
3548
4570
  return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
3549
4571
  }
3550
- 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) {
3551
4580
  const now = this.now();
4581
+ const authors = authorsOf(task, p.from);
4582
+ let note = OPENED_ON_COMPLETE;
3552
4583
  if (!task.review) {
3553
- const producer = p.from;
3554
- 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);
3555
4585
  if (eligible.length === 0) {
3556
4586
  return { error: rpcError(
3557
4587
  E.NOT_AUTHORISED,
3558
4588
  "Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
3559
4589
  ) };
3560
4590
  }
3561
- 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
+ };
3562
4599
  } else {
3563
- const author = p.from;
3564
- const reviewers = task.review.requested_to.filter((r) => r !== author);
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"));
3565
4602
  if (reviewers.length === 0) {
3566
4603
  return { error: rpcError(
3567
4604
  E.NOT_AUTHORISED,
3568
- "Task requires review but has no reviewer other than its completer"
4605
+ "No reviewer of this task remains once the authors of the work are left out"
3569
4606
  ) };
3570
4607
  }
3571
- task.review = { ...task.review, requested_at: now, requested_to: reviewers, decisions: [] };
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;
3572
4617
  }
3573
4618
  task.pending_artefact = p.output;
3574
4619
  task.state = "review_requested";
3575
4620
  task.updated_at = now;
3576
- task.history.push({
3577
- ts: now,
3578
- from: p.from,
3579
- state: "review_requested",
3580
- note: "review required; opened on task.complete"
3581
- });
4621
+ task.history.push({ ts: now, from: p.from, state: "review_requested", note });
3582
4622
  return { result: { state: "review_requested", review_id: task.id } };
3583
4623
  }
3584
4624
  task.output = p.output;
3585
- if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
4625
+ if (p.confidence != null) task.confidence = p.confidence;
3586
4626
  task.state = "completed";
3587
4627
  task.updated_at = this.now();
3588
4628
  task.history.push({ ts: task.updated_at, from: p.from, state: "completed" });
@@ -3595,8 +4635,16 @@ var Coordinator = class {
3595
4635
  const notMember = this.requireMember(ws, p.from);
3596
4636
  if (notMember) return notMember;
3597
4637
  }
3598
- const range = p.range ?? {};
3599
- 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 : {};
3600
4648
  if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
3601
4649
  return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
3602
4650
  }
@@ -3647,37 +4695,73 @@ var Coordinator = class {
3647
4695
  return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
3648
4696
  }
3649
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
+ }
3650
4701
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
3651
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;
3652
4705
  const now = this.now();
3653
4706
  const declaredRule = p.rule;
3654
4707
  const rule = declaredRule || "any_one_approves";
3655
4708
  if (!reviewRuleSupported(rule)) {
3656
4709
  return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
3657
4710
  }
3658
- if (task.state === "review_requested" && task.review) {
3659
- 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)) {
3660
4714
  return { error: rpcError(
3661
4715
  E.REVIEW_ALREADY_OPEN,
3662
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."
3663
4717
  ) };
3664
4718
  }
3665
- if (declaredRule !== void 0 && declaredRule !== task.review.rule) {
4719
+ if (declaredRule != null && declaredRule !== open.rule) {
3666
4720
  return { error: rpcError(
3667
4721
  E.REVIEW_ALREADY_OPEN,
3668
- `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`
3669
4742
  ) };
3670
4743
  }
3671
4744
  for (const r of reviewers) {
3672
- 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
+ }
3673
4752
  }
3674
- if (p.deadline !== void 0) task.review.deadline = p.deadline;
4753
+ }
4754
+ if (open) {
4755
+ for (const r of reviewers) {
4756
+ if (!open.requested_to.includes(r)) open.requested_to.push(r);
4757
+ }
4758
+ if (p.deadline != null) open.deadline = p.deadline;
3675
4759
  task.updated_at = now;
3676
4760
  return { result: {
3677
4761
  state: "review_requested",
3678
4762
  review_id: task.id,
3679
4763
  amended: true,
3680
- requested_to: [...task.review.requested_to]
4764
+ requested_to: [...open.requested_to]
3681
4765
  } };
3682
4766
  }
3683
4767
  task.state = "review_requested";
@@ -3687,12 +4771,41 @@ var Coordinator = class {
3687
4771
  requested_to: reviewers,
3688
4772
  rule,
3689
4773
  deadline: p.deadline,
3690
- decisions: []
4774
+ decisions: [],
4775
+ authors,
4776
+ acknowledgements: []
3691
4777
  };
3692
4778
  task.history.push({ ts: now, from: p.from, state: "review_requested" });
3693
4779
  task.pending_artefact = p.artefact;
3694
4780
  return { result: { state: "review_requested", review_id: task.id } };
3695
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
+ }
3696
4809
  /**
3697
4810
  * Verify an optional `approved_artefact_digest` against the artefact under
3698
4811
  * review (review/1.0 S3.2).
@@ -3711,7 +4824,7 @@ var Coordinator = class {
3711
4824
  if (typeof declared !== "string") {
3712
4825
  return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
3713
4826
  }
3714
- if (task.pending_artefact === void 0) {
4827
+ if (task.pending_artefact === void 0 || task.pending_artefact === null) {
3715
4828
  return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
3716
4829
  }
3717
4830
  const actual = contentHash(task.pending_artefact);
@@ -3727,8 +4840,8 @@ var Coordinator = class {
3727
4840
  opDecide(p, kind) {
3728
4841
  const miss = missing(p, ["workspace", "from", "task_id"]);
3729
4842
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
3730
- const badTags = tagsError(p);
3731
- if (badTags) return badTags;
4843
+ const bad = paramsError(p, [["tags", "strings"], ["request_revision", "boolean"]]);
4844
+ if (bad) return bad;
3732
4845
  const ws = this.workspaces.get(p.workspace);
3733
4846
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3734
4847
  const notMember = this.requireMember(ws, p.from);
@@ -3738,7 +4851,7 @@ var Coordinator = class {
3738
4851
  if (task.state !== "review_requested") {
3739
4852
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3740
4853
  }
3741
- const notReviewer = this.requireReviewer(task, p.from);
4854
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3742
4855
  if (notReviewer) return notReviewer;
3743
4856
  const digestError = this.checkArtefactDigest(p, task);
3744
4857
  if (digestError) return digestError;
@@ -3765,8 +4878,8 @@ var Coordinator = class {
3765
4878
  opDecideOverride(p) {
3766
4879
  const miss = missing(p, ["workspace", "from", "task_id", "diff", "rationale"]);
3767
4880
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
3768
- const badTags = tagsError(p);
3769
- if (badTags) return badTags;
4881
+ const bad = paramsError(p, [["tags", "strings"], ["policy_refs", "strings"]]);
4882
+ if (bad) return bad;
3770
4883
  const ws = this.workspaces.get(p.workspace);
3771
4884
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3772
4885
  const notMember = this.requireMember(ws, p.from);
@@ -3776,12 +4889,12 @@ var Coordinator = class {
3776
4889
  if (task.state !== "review_requested") {
3777
4890
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3778
4891
  }
3779
- const notReviewer = this.requireReviewer(task, p.from);
4892
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3780
4893
  if (notReviewer) return notReviewer;
3781
4894
  const digestError = this.checkArtefactDigest(p, task);
3782
4895
  if (digestError) return digestError;
3783
4896
  const base = task.pending_artefact;
3784
- 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") };
3785
4898
  let applied;
3786
4899
  try {
3787
4900
  applied = applyJsonPatch(base, p.diff, {
@@ -3825,6 +4938,8 @@ var Coordinator = class {
3825
4938
  opAbstainDeclare(p) {
3826
4939
  const miss = missing(p, ["workspace", "from", "task_id", "reason"]);
3827
4940
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4941
+ const bad = paramsError(p, [["category", "string"]]);
4942
+ if (bad) return bad;
3828
4943
  const ws = this.workspaces.get(p.workspace);
3829
4944
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3830
4945
  const notMember = this.requireMember(ws, p.from);
@@ -3834,7 +4949,7 @@ var Coordinator = class {
3834
4949
  if (task.state !== "review_requested") {
3835
4950
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3836
4951
  }
3837
- const notReviewer = this.requireReviewer(task, p.from);
4952
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3838
4953
  if (notReviewer) return notReviewer;
3839
4954
  const now = this.now();
3840
4955
  task.review.decisions.push({
@@ -3866,6 +4981,8 @@ var Coordinator = class {
3866
4981
  return { error: rpcError(E.PARAMS, "new_task must be an object") };
3867
4982
  }
3868
4983
  const nt = p.new_task;
4984
+ const bad = paramsError(p, [["new_task.kind", "string"]]);
4985
+ if (bad) return bad;
3869
4986
  const assignee = nt.assignee;
3870
4987
  if (typeof assignee !== "string" || !ws.members.has(assignee)) {
3871
4988
  return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
@@ -3900,9 +5017,10 @@ var Coordinator = class {
3900
5017
  history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
3901
5018
  paused: false
3902
5019
  };
3903
- 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") {
3904
5021
  newTask.review_required = true;
3905
5022
  }
5023
+ newTask.prior_authors = successorAuthors(orig, p.from);
3906
5024
  ws.tasks.set(newId, newTask);
3907
5025
  orig.state = "escalated";
3908
5026
  orig.updated_at = now;
@@ -3919,8 +5037,8 @@ function memberToDict(m) {
3919
5037
  joined: m.joined
3920
5038
  };
3921
5039
  if (m.display_name) out.display_name = m.display_name;
3922
- if (m.capabilities) out.capabilities = m.capabilities;
3923
- 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;
3924
5042
  if (m.keys.length) {
3925
5043
  out.jwks = { keys: m.keys.filter((k) => !k.revoked_at && !k.valid_until).map((k) => k.jwk) };
3926
5044
  out.key_history = m.keys;
@@ -3932,7 +5050,6 @@ function memberToDict(m) {
3932
5050
  }
3933
5051
 
3934
5052
  // src/policy.ts
3935
- var CRIT2 = { low: 0, medium: 1, high: 2, critical: 3 };
3936
5053
  function makeDefaultPolicy(seniorTarget) {
3937
5054
  const routingPolicy = (task, candidates) => {
3938
5055
  const eligible = candidates;
@@ -3950,15 +5067,8 @@ function makeDefaultPolicy(seniorTarget) {
3950
5067
  };
3951
5068
  };
3952
5069
  const reviewDepthPolicy = (_task, hints) => {
3953
- const crit = hints.criticality ?? "medium";
3954
- let conf = 1;
3955
- try {
3956
- conf = Number(hints.confidence ?? 1);
3957
- } catch {
3958
- conf = 1;
3959
- }
3960
- if (isNaN(conf)) conf = 1;
3961
- const r = CRIT2[crit] ?? 1;
5070
+ const conf = confidenceOf(hints);
5071
+ const r = criticalityOf(hints);
3962
5072
  if (r >= 3) return {
3963
5073
  depth: "full",
3964
5074
  rationale: { policy_id: "default", summary: "criticality=critical: full review" }
@@ -3983,15 +5093,8 @@ function makeDefaultPolicy(seniorTarget) {
3983
5093
  };
3984
5094
  };
3985
5095
  const escalationPolicy = (_task, hints) => {
3986
- const crit = hints.criticality ?? "medium";
3987
- let conf = 1;
3988
- try {
3989
- conf = Number(hints.confidence ?? 1);
3990
- } catch {
3991
- conf = 1;
3992
- }
3993
- if (isNaN(conf)) conf = 1;
3994
- const r = CRIT2[crit] ?? 1;
5096
+ const conf = confidenceOf(hints);
5097
+ const r = criticalityOf(hints);
3995
5098
  const escalate = r >= 3 || r >= 2 && conf < 0.6;
3996
5099
  return {
3997
5100
  escalate,
@@ -4011,6 +5114,19 @@ function wrapMcpToolCall(coord, workspace, options) {
4011
5114
  if (!workspace) throw new Error("workspace is required");
4012
5115
  if (!options.caller) throw new Error("caller is required");
4013
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
+ }
4014
5130
  const inputHash = contentHash(options.args);
4015
5131
  const outputHash = contentHash(options.result);
4016
5132
  const kind = options.taskKind ?? `mcp_call:${options.tool}`;
@@ -4029,6 +5145,7 @@ function wrapMcpToolCall(coord, workspace, options) {
4029
5145
  input: options.args
4030
5146
  };
4031
5147
  if (options.routingHints) createParams.routing_hints = { ...options.routingHints };
5148
+ if (options.reviewRequired) createParams.review_required = true;
4032
5149
  const createResp = coord.dispatch({
4033
5150
  jsonrpc: "2.0",
4034
5151
  id: `wrap-${options.tool}-create`,
@@ -4058,7 +5175,7 @@ function wrapMcpToolCall(coord, workspace, options) {
4058
5175
  method: "task.complete",
4059
5176
  params: completeParams
4060
5177
  });
4061
- 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)}`);
4062
5179
  return { task_id: taskId, input_hash: inputHash, output_hash: outputHash };
4063
5180
  }
4064
5181
  function wrapA2aMessageExchange(coord, workspace, options) {