@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.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createHash } from "crypto";
3
3
  var ZERO_HASH = "sha256:" + "0".repeat(64);
4
4
  var NON_INTEGER_ERROR = 'CHAP canonical numbers must be integers; represent decimals as strings (e.g. "8.2") so the hash is deterministic across implementations.';
5
- 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.";
5
+ var NUMBER_RANGE_ERROR = "CHAP canonical integers must be within the safe-integer range (abs value <= 2^53 - 1); represent larger numbers as strings.";
6
6
  var LONE_SURROGATE_ERROR = "CHAP canonical strings must be valid Unicode (I-JSON / RFC 7493); unpaired UTF-16 surrogates are not permitted.";
7
7
  function assertNoLoneSurrogate(s) {
8
8
  for (let i = 0; i < s.length; i++) {
@@ -23,7 +23,7 @@ function canonicalString(s) {
23
23
  function canonicalNumber(n) {
24
24
  if (!Number.isFinite(n)) {
25
25
  throw new Error(
26
- "Non-finite numbers are not permitted in a CHAP canonical value"
26
+ "Non-finite numbers are not permitted in a CHAP canonical value."
27
27
  );
28
28
  }
29
29
  if (!Number.isInteger(n)) {
@@ -96,6 +96,7 @@ var OWNING_PROFILE = Object.freeze({
96
96
  "participant.leave": "core/1.0",
97
97
  "participant.revoke_key": "core/1.0",
98
98
  "participant.rotate_key": "core/1.0",
99
+ "review.acknowledge": "review/1.0",
99
100
  "review.depth": "routing/1.0",
100
101
  "review.request": "review/1.0",
101
102
  "task.complete": "core/1.0",
@@ -106,6 +107,7 @@ var OWNING_PROFILE = Object.freeze({
106
107
  "whisper.ask": "whisper/1.0",
107
108
  "workspace.create": "core/1.0",
108
109
  "workspace.describe": "core/1.0",
110
+ "workspace.invite": "core/1.0",
109
111
  "workspace.set_profiles": "core/1.0"
110
112
  });
111
113
  var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
@@ -114,6 +116,20 @@ var ALWAYS_AVAILABLE = /* @__PURE__ */ new Set([
114
116
  "audit.verify_chain",
115
117
  "audit.verify_receipt"
116
118
  ]);
119
+ var PROFILE_VERSIONS = Object.freeze({
120
+ "audit-scitt": "1.0",
121
+ "control": "1.0",
122
+ "core": "1.0",
123
+ "deliberation": "1.0",
124
+ "handoff": "1.0",
125
+ "identity-oidc": "1.0",
126
+ "identity-vc": "1.0",
127
+ "modes": "1.0",
128
+ "review": "1.0",
129
+ "routing": "1.0",
130
+ "security-signed": "1.0",
131
+ "whisper": "1.0"
132
+ });
117
133
 
118
134
  // src/crypto.ts
119
135
  import {
@@ -171,21 +187,132 @@ function signEnvelope(canonical, privateKey, kid) {
171
187
  const sig = nodeSign(null, canonical, privateKey);
172
188
  return `ed25519:${kid}:${sig.toString("base64")}`;
173
189
  }
190
+ function strictBase64(s) {
191
+ const bytes = Buffer.from(s, "base64");
192
+ return bytes.toString("base64") === s ? bytes : void 0;
193
+ }
174
194
  function verifyEnvelope(canonical, sigTag, publicKey) {
175
195
  if (!sigTag.startsWith("ed25519:")) return false;
176
196
  const parts = sigTag.split(":");
177
197
  if (parts.length < 2) return false;
178
- const sigB64 = parts[parts.length - 1];
198
+ const sig = strictBase64(parts[parts.length - 1]);
199
+ if (sig === void 0) return false;
179
200
  try {
180
- const sig = Buffer.from(sigB64, "base64");
181
201
  return nodeVerify(null, canonical, publicKey, sig);
182
202
  } catch {
183
203
  return false;
184
204
  }
185
205
  }
206
+ function isEd25519Key(jwk) {
207
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
208
+ const key = jwk;
209
+ return key.kty === "OKP" && key.crv === "Ed25519" && typeof key.x === "string" && COORDINATE.test(key.x);
210
+ }
186
211
  function publicKeyFromJwk(jwk) {
187
212
  return publicKeyFromRaw(b64urlDecode(jwk.x));
188
213
  }
214
+ function jwkThumbprint(jwk) {
215
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return void 0;
216
+ const key = jwk;
217
+ const names = key.kty === "OKP" ? ["crv", "kty", "x"] : key.kty === "EC" ? ["crv", "kty", "x", "y"] : void 0;
218
+ if (!names || names.some((n) => typeof key[n] !== "string")) return void 0;
219
+ const members = Object.fromEntries(names.map((n) => [n, key[n]]));
220
+ return b64urlNoPad(createHash2("sha256").update(canonicalize(members)).digest());
221
+ }
222
+ var B64URL = /^[A-Za-z0-9_-]+$/;
223
+ function parseWebAuthnAssertion(part) {
224
+ const pieces = part.split(".");
225
+ if (pieces.length !== 3 || pieces.some((p) => !B64URL.test(p) || p.length % 4 === 1)) return void 0;
226
+ const [authenticatorData, clientDataJSON, signature] = pieces.map((p) => Buffer.from(p, "base64url"));
227
+ return { authenticatorData, clientDataJSON, signature };
228
+ }
229
+ var COORDINATE = /^[A-Za-z0-9_-]{43}$/;
230
+ function isWebAuthnKey(jwk) {
231
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) return false;
232
+ const key = jwk;
233
+ const w = key.webauthn;
234
+ 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"));
235
+ }
236
+ var UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
237
+ var MAX_CLIENT_DATA_NESTING = 64;
238
+ function jsonDepth(text) {
239
+ let depth = 0;
240
+ let deepest = 0;
241
+ let inString = false;
242
+ for (let i = 0; i < text.length; i++) {
243
+ const ch = text[i];
244
+ if (inString) {
245
+ if (ch === "\\") i++;
246
+ else if (ch === '"') inString = false;
247
+ } else if (ch === '"') {
248
+ inString = true;
249
+ } else if (ch === "{" || ch === "[") {
250
+ deepest = Math.max(deepest, ++depth);
251
+ } else if (ch === "}" || ch === "]") {
252
+ depth--;
253
+ }
254
+ }
255
+ return deepest;
256
+ }
257
+ var UP = 1;
258
+ var UV = 4;
259
+ var BE = 8;
260
+ var BS = 16;
261
+ var AT = 64;
262
+ var ED = 128;
263
+ function webAuthnFailure(canonical, assertion, jwk) {
264
+ const w = jwk.webauthn;
265
+ if (!Array.isArray(w.origins) || w.origins.length === 0) return "WebAuthn key lists no origin";
266
+ let text;
267
+ try {
268
+ text = UTF8.decode(assertion.clientDataJSON);
269
+ } catch {
270
+ text = void 0;
271
+ }
272
+ if (text !== void 0 && jsonDepth(text) > MAX_CLIENT_DATA_NESTING) {
273
+ return `WebAuthn clientDataJSON nests deeper than ${MAX_CLIENT_DATA_NESTING} levels`;
274
+ }
275
+ let client;
276
+ try {
277
+ client = text === void 0 ? void 0 : JSON.parse(text);
278
+ } catch {
279
+ client = void 0;
280
+ }
281
+ if (client === null || typeof client !== "object" || Array.isArray(client)) {
282
+ return "WebAuthn clientDataJSON is not a JSON object";
283
+ }
284
+ const data = client;
285
+ if (data.type !== "webauthn.get") return "WebAuthn clientDataJSON type is not webauthn.get";
286
+ if (data.challenge !== b64urlNoPad(createHash2("sha256").update(canonical).digest())) {
287
+ return "WebAuthn challenge is not the digest of this request";
288
+ }
289
+ if (!w.origins.includes(data.origin)) return "WebAuthn origin is not one the key allows";
290
+ if (data.crossOrigin === true || data.topOrigin !== void 0) {
291
+ return "WebAuthn assertion was made in a cross-origin frame";
292
+ }
293
+ const auth = assertion.authenticatorData;
294
+ if (auth.length < 37) return "WebAuthn authenticatorData is too short";
295
+ if (!auth.subarray(0, 32).equals(createHash2("sha256").update(w.rp_id, "utf-8").digest())) {
296
+ return "WebAuthn rpIdHash does not match the key's rp_id";
297
+ }
298
+ const flags = auth[32];
299
+ if ((flags & (UP | UV)) !== (UP | UV)) return "WebAuthn assertion lacks user presence or verification";
300
+ if (flags & BS && !(flags & BE)) return "WebAuthn assertion is backed up but not backup eligible";
301
+ if (flags & AT) return "WebAuthn assertion carries attested credential data";
302
+ if (flags & ED ? auth.length === 37 : auth.length !== 37) {
303
+ return "WebAuthn authenticatorData does not end where its flags say";
304
+ }
305
+ try {
306
+ const key = createPublicKey({
307
+ key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y },
308
+ format: "jwk"
309
+ });
310
+ const signed = Buffer.concat([auth, createHash2("sha256").update(assertion.clientDataJSON).digest()]);
311
+ return nodeVerify("sha256", signed, { key, dsaEncoding: "der" }, assertion.signature) ? null : "Signature failed verification";
312
+ } catch {
313
+ return "Signature failed verification";
314
+ }
315
+ }
189
316
  function deriveKeypair(uri) {
190
317
  const seed = deriveSeed(uri);
191
318
  const privateKey = privateKeyFromSeed(seed);
@@ -302,6 +429,7 @@ var E = {
302
429
  SIG_KEY_REVOKED: -32072,
303
430
  SIG_ROTATION_KEY_MISMATCH: -32073,
304
431
  SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
432
+ SIG_ALREADY_ACCEPTED: -32075,
305
433
  // audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
306
434
  SCITT_UNREACHABLE: -32080,
307
435
  SCITT_STATEMENT_REJECTED: -32081,
@@ -334,6 +462,44 @@ function isValidEnvelope(env) {
334
462
  return e.jsonrpc === "2.0";
335
463
  }
336
464
 
465
+ // src/params.ts
466
+ var NAMES = {
467
+ string: "a string",
468
+ strings: "a list of strings",
469
+ boolean: "a boolean",
470
+ object: "an object",
471
+ list: "a list",
472
+ integer: "an integer"
473
+ };
474
+ function isKind(value, kind) {
475
+ switch (kind) {
476
+ case "string":
477
+ return typeof value === "string";
478
+ case "strings":
479
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
480
+ case "boolean":
481
+ return typeof value === "boolean";
482
+ case "object":
483
+ return typeof value === "object" && value !== null && !Array.isArray(value);
484
+ case "list":
485
+ return Array.isArray(value);
486
+ case "integer":
487
+ return typeof value === "number" && Number.isInteger(value);
488
+ }
489
+ }
490
+ function paramsError(params, checks) {
491
+ for (const [name, kind] of checks) {
492
+ let value = params;
493
+ for (const part of name.split(".")) {
494
+ value = typeof value === "object" && value !== null && !Array.isArray(value) ? value[part] : void 0;
495
+ }
496
+ if (value !== void 0 && value !== null && !isKind(value, kind)) {
497
+ return { error: rpcError(E.PARAMS, `${name} must be ${NAMES[kind]}`) };
498
+ }
499
+ }
500
+ return null;
501
+ }
502
+
337
503
  // src/audit.ts
338
504
  function entryRecord(entry) {
339
505
  if (entry.outcome != null) return { outcome: entry.outcome, request: entry.request };
@@ -377,7 +543,8 @@ var UNRECORDED_CODES = /* @__PURE__ */ new Set([
377
543
  E.SIG_VERIFY_FAILED,
378
544
  E.SIG_KEY_NOT_FOUND,
379
545
  E.SIG_KEY_REVOKED,
380
- E.SIG_ROTATION_KEY_MISMATCH
546
+ E.SIG_ROTATION_KEY_MISMATCH,
547
+ E.SIG_ALREADY_ACCEPTED
381
548
  ]);
382
549
  var UNRECORDED_METHODS = /* @__PURE__ */ new Set(["workspace.create", "participant.join"]);
383
550
  function refusalIsRecorded(method, error, privileged) {
@@ -794,7 +961,8 @@ var COLLECTIONS = [
794
961
  "deliberations",
795
962
  "handoffs",
796
963
  "snapshots",
797
- "route_decisions"
964
+ "route_decisions",
965
+ "invitations"
798
966
  ];
799
967
  var IDEMPOTENCY_KEYS = "idempotency_keys";
800
968
  var StoreConflictError = class extends Error {
@@ -805,7 +973,7 @@ var StoreConflictError = class extends Error {
805
973
  base;
806
974
  constructor(workspace, held, base) {
807
975
  super(
808
- `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.`
976
+ `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.`
809
977
  );
810
978
  this.name = "StoreConflictError";
811
979
  this.workspace = workspace;
@@ -882,20 +1050,24 @@ function assembleSnapshot(meta, entries, objects) {
882
1050
  var MemoryStore = class {
883
1051
  held = /* @__PURE__ */ new Map();
884
1052
  load() {
885
- const out = [];
886
- for (const [id, h] of this.held) {
887
- const objects = [];
888
- for (const [collection, items] of h.objects) {
889
- for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
890
- }
891
- out.push({
892
- id,
893
- data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
894
- version: h.version,
895
- updated_at: h.updated_at
896
- });
1053
+ return [...this.held.keys()].map((id) => this.loadOne(id));
1054
+ }
1055
+ loadOne(id) {
1056
+ const h = this.held.get(id);
1057
+ if (!h) return void 0;
1058
+ const objects = [];
1059
+ for (const [collection, items] of h.objects) {
1060
+ for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
897
1061
  }
898
- return out;
1062
+ return {
1063
+ id,
1064
+ data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
1065
+ version: h.version,
1066
+ updated_at: h.updated_at
1067
+ };
1068
+ }
1069
+ version(id) {
1070
+ return this.held.get(id)?.entries.length;
899
1071
  }
900
1072
  save(record) {
901
1073
  this.apply(changeFromRecord(record));
@@ -1022,7 +1194,8 @@ function makeApi(coord) {
1022
1194
  workspace: {
1023
1195
  create: (p) => c("workspace.create", p),
1024
1196
  describe: (p) => c("workspace.describe", p),
1025
- set_profiles: (p) => c("workspace.set_profiles", p)
1197
+ set_profiles: (p) => c("workspace.set_profiles", p),
1198
+ invite: (p) => c("workspace.invite", p)
1026
1199
  },
1027
1200
  participant: {
1028
1201
  join: (p) => c("participant.join", p),
@@ -1038,6 +1211,7 @@ function makeApi(coord) {
1038
1211
  },
1039
1212
  review: {
1040
1213
  request: (p) => c("review.request", p),
1214
+ acknowledge: (p) => c("review.acknowledge", p),
1041
1215
  depth: (p) => c("review.depth", p)
1042
1216
  },
1043
1217
  decide: {
@@ -1111,10 +1285,17 @@ function registerAuditScitt(coord) {
1111
1285
  coord.handlers.set("audit.submit_to_scitt", (p) => {
1112
1286
  const ws = coord.workspaces.get(p.workspace);
1113
1287
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1288
+ const bad = paramsError(p, [
1289
+ ["range", "object"],
1290
+ ["range.from_seq", "integer"],
1291
+ ["range.to_seq", "integer"],
1292
+ ["issuer", "string"]
1293
+ ]);
1294
+ if (bad) return bad;
1114
1295
  const range = p.range ?? {};
1115
1296
  const fromSeq = range.from_seq ?? 0;
1116
1297
  const toSeq = range.to_seq ?? ws.audit.length;
1117
- const issuer = p.issuer ?? "service:coordinator";
1298
+ const issuer = p.issuer || "service:coordinator";
1118
1299
  if (!coord.options.scittSubmitter) {
1119
1300
  const statements = ws.audit.slice(fromSeq, toSeq).map((e) => buildStatement(ws.id, entryRecord(e), issuer));
1120
1301
  return { result: {
@@ -1144,7 +1325,7 @@ function registerAuditScitt(coord) {
1144
1325
  });
1145
1326
  coord.handlers.set("audit.verify_receipt", (p) => {
1146
1327
  const receipt = p.receipt;
1147
- if (typeof receipt !== "object" || receipt === null) {
1328
+ if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
1148
1329
  return { error: rpcError(E.PARAMS, "receipt must be an object") };
1149
1330
  }
1150
1331
  if (coord.options.verifyScittReceipt) {
@@ -1158,7 +1339,7 @@ function registerAuditScitt(coord) {
1158
1339
  if (!ok) return { error: rpcError(E.SCITT_RECEIPT_INVALID, "Receipt did not verify") };
1159
1340
  return { result: { verified: true } };
1160
1341
  }
1161
- return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No verifyScittReceipt hook configured; receipt not verified") };
1342
+ return { error: rpcError(E.SCITT_RECEIPT_INVALID, "No receipt verifier is configured; receipt not verified") };
1162
1343
  });
1163
1344
  coord.handlers.set("audit.verify_chain", (p) => {
1164
1345
  const ws = coord.workspaces.get(p.workspace);
@@ -1166,7 +1347,7 @@ function registerAuditScitt(coord) {
1166
1347
  if (!(ws.chain_enabled || coord.options.enableChain)) {
1167
1348
  return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
1168
1349
  }
1169
- if (p.from_seq !== void 0 || p.to_seq !== void 0) {
1350
+ if (p.from_seq != null || p.to_seq != null) {
1170
1351
  return { error: rpcError(
1171
1352
  E.PARAMS,
1172
1353
  "Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
@@ -1228,13 +1409,21 @@ function registerAuditScitt(coord) {
1228
1409
  // src/profiles/control.ts
1229
1410
  var VALID_MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
1230
1411
  var VALID_SCOPES = /* @__PURE__ */ new Set(["task", "participant", "workspace"]);
1412
+ var SCOPE_ERROR = "scope must be task, participant or workspace";
1231
1413
  function registerControl(coord) {
1232
1414
  coord.handlers.set("control.pause", (p) => {
1233
1415
  const ws = coord.workspaces.get(p.workspace);
1234
1416
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1417
+ const bad = paramsError(p, [
1418
+ ["scope", "string"],
1419
+ ["participant_uri", "string"],
1420
+ ["uri", "string"],
1421
+ ["in_flight_policy", "string"]
1422
+ ]);
1423
+ if (bad) return bad;
1235
1424
  const scope = p.scope || "task";
1236
1425
  if (!VALID_SCOPES.has(scope)) {
1237
- return { error: rpcError(E.PARAMS, `scope must be task/participant/workspace`) };
1426
+ return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
1238
1427
  }
1239
1428
  if (scope === "task") {
1240
1429
  const task = ws.tasks.get(p.task_id);
@@ -1252,7 +1441,8 @@ function registerControl(coord) {
1252
1441
  }
1253
1442
  if (scope === "participant") {
1254
1443
  const uri = p.participant_uri || p.uri;
1255
- if (!uri || !ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1444
+ if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
1445
+ if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1256
1446
  ws.members.get(uri).paused = true;
1257
1447
  return { result: {
1258
1448
  scope: "participant",
@@ -1267,9 +1457,11 @@ function registerControl(coord) {
1267
1457
  coord.handlers.set("control.resume", (p) => {
1268
1458
  const ws = coord.workspaces.get(p.workspace);
1269
1459
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1460
+ const bad = paramsError(p, [["scope", "string"], ["participant_uri", "string"], ["uri", "string"]]);
1461
+ if (bad) return bad;
1270
1462
  const scope = p.scope || "task";
1271
1463
  if (!VALID_SCOPES.has(scope)) {
1272
- return { error: rpcError(E.PARAMS, `scope must be task/participant/workspace`) };
1464
+ return { error: rpcError(E.PARAMS, SCOPE_ERROR) };
1273
1465
  }
1274
1466
  if (scope === "task") {
1275
1467
  const task = ws.tasks.get(p.task_id);
@@ -1287,7 +1479,9 @@ function registerControl(coord) {
1287
1479
  }
1288
1480
  if (scope === "participant") {
1289
1481
  const uri = p.participant_uri || p.uri;
1290
- if (!uri || !ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1482
+ if (!uri) return { error: rpcError(E.PARAMS, "participant_uri is required") };
1483
+ if (!ws.members.has(uri)) return { error: rpcError(E.PARAMS, `Unknown participant: ${uri}`) };
1484
+ if (uri === p.from) return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not lift its own pause") };
1291
1485
  ws.members.get(uri).paused = false;
1292
1486
  return { result: { scope: "participant", participant_uri: uri, paused: false } };
1293
1487
  }
@@ -1302,6 +1496,12 @@ function registerControl(coord) {
1302
1496
  if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
1303
1497
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
1304
1498
  }
1499
+ if (openRequiredReviewAuthors(ws, task).includes(p.from)) {
1500
+ return { error: rpcError(
1501
+ E.NOT_AUTHORISED,
1502
+ "The authors of the work under review cannot end its review without a decision"
1503
+ ) };
1504
+ }
1305
1505
  task.state = "cancelled";
1306
1506
  task.updated_at = coord.now();
1307
1507
  task.history.push({ ts: task.updated_at, from: p.from, state: "cancelled", note: p.reason });
@@ -1310,6 +1510,8 @@ function registerControl(coord) {
1310
1510
  coord.handlers.set("control.snapshot", (p) => {
1311
1511
  const ws = coord.workspaces.get(p.workspace);
1312
1512
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1513
+ const bad = paramsError(p, [["include", "strings"], ["label", "string"]]);
1514
+ if (bad) return bad;
1313
1515
  if (Array.isArray(p.include) && p.include.length === 0) {
1314
1516
  return { error: rpcError(E.PARAMS, "include must not be empty") };
1315
1517
  }
@@ -1364,6 +1566,14 @@ function registerControl(coord) {
1364
1566
  coord.handlers.set("control.rollback", (p) => {
1365
1567
  const ws = coord.workspaces.get(p.workspace);
1366
1568
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1569
+ const bad = paramsError(p, [
1570
+ ["to_snapshot_artefact_id", "string"],
1571
+ ["snapshot_artefact_id", "string"],
1572
+ ["snapshot_id", "string"],
1573
+ ["what_to_restore", "strings"],
1574
+ ["reason", "string"]
1575
+ ]);
1576
+ if (bad) return bad;
1367
1577
  const snapId = p.to_snapshot_artefact_id || p.snapshot_artefact_id || p.snapshot_id;
1368
1578
  if (!snapId) return { error: rpcError(E.PARAMS, "to_snapshot_artefact_id is required") };
1369
1579
  const snap = ws.snapshots.get(snapId);
@@ -1385,7 +1595,8 @@ function registerControl(coord) {
1385
1595
  for (const [uri, snapM] of snapByUri) {
1386
1596
  if (ws.members.has(uri)) {
1387
1597
  const cur = ws.members.get(uri);
1388
- cur.role = snapM.role ?? cur.role;
1598
+ const role = snapM.role ?? cur.role;
1599
+ if (role !== "admin" && cur.role !== "admin") cur.role = role;
1389
1600
  cur.scopes = Array.isArray(snapM.scopes) ? [...snapM.scopes] : void 0;
1390
1601
  }
1391
1602
  }
@@ -1404,10 +1615,18 @@ function registerControl(coord) {
1404
1615
  const oldId = p.task_id;
1405
1616
  const old = ws.tasks.get(oldId);
1406
1617
  if (!old) return { error: rpcError(E.PARAMS, "Unknown task") };
1618
+ if (openRequiredReviewAuthors(ws, old).includes(p.from)) {
1619
+ return { error: rpcError(
1620
+ E.NOT_AUTHORISED,
1621
+ "The authors of the work under review cannot end its review without a decision"
1622
+ ) };
1623
+ }
1407
1624
  const newSpec = p.successor_task ?? {};
1408
- if (!newSpec.kind) {
1625
+ if (typeof newSpec.kind !== "string" || !newSpec.kind) {
1409
1626
  return { error: rpcError(E.PARAMS, "successor_task must include kind") };
1410
1627
  }
1628
+ const bad = paramsError(p, [["successor_task.assignee", "string"], ["reason", "string"]]);
1629
+ if (bad) return bad;
1411
1630
  const assignee = newSpec.assignee || old.assignee;
1412
1631
  if (typeof assignee !== "string" || !ws.members.has(assignee)) {
1413
1632
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
@@ -1454,6 +1673,7 @@ function registerControl(coord) {
1454
1673
  } else if ("review_required" in newSpec) {
1455
1674
  newTask.review_required = !!newSpec.review_required;
1456
1675
  }
1676
+ newTask.prior_authors = successorAuthors(old, p.from);
1457
1677
  ws.tasks.set(newId, newTask);
1458
1678
  old.state = "superseded";
1459
1679
  old.superseded_by = newId;
@@ -1464,27 +1684,37 @@ function registerControl(coord) {
1464
1684
  state: "superseded",
1465
1685
  note: `-> ${newId}: ${p.reason || ""}`
1466
1686
  });
1467
- return { result: { superseded_task_id: old.id, new_task_id: newId, reason: p.reason } };
1687
+ return { result: {
1688
+ superseded_task_id: old.id,
1689
+ new_task_id: newId,
1690
+ ...p.reason != null ? { reason: p.reason } : {}
1691
+ } };
1468
1692
  });
1469
1693
  coord.handlers.set("control.set_mode_ceiling", (p) => {
1470
1694
  const ws = coord.workspaces.get(p.workspace);
1471
1695
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1696
+ const bad = paramsError(p, [["reason", "string"]]);
1697
+ if (bad) return bad;
1472
1698
  const m = p.new_ceiling ?? p.mode_ceiling;
1473
1699
  if (!VALID_MODES.has(m)) {
1474
- return { error: rpcError(E.PARAMS, `new_ceiling must be shadow/trial/production`) };
1700
+ return { error: rpcError(E.PARAMS, "new_ceiling must be shadow, trial or production") };
1475
1701
  }
1476
1702
  ws.mode_ceiling = m;
1477
- return { result: { mode_ceiling: ws.mode_ceiling, reason: p.reason } };
1703
+ return { result: { mode_ceiling: ws.mode_ceiling, ...p.reason != null ? { reason: p.reason } : {} } };
1478
1704
  });
1479
1705
  }
1480
1706
 
1481
1707
  // src/profiles/deliberation.ts
1482
1708
  var RESERVED_IDS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1709
+ var QUORUM_ARG = /^[0-9]+$/;
1710
+ var THRESHOLD_ARG = /^[0-9]+(\.[0-9]+)?$/;
1483
1711
  function parseRule(rule) {
1484
- if (rule.includes(":")) {
1485
- const [kind, arg] = rule.split(":", 2);
1486
- if (kind === "quorum") return { kind, params: { n: parseInt(arg, 10) } };
1487
- if (kind === "weighted_vote" || kind === "weighted_vote_with_veto") {
1712
+ const colon = rule.indexOf(":");
1713
+ if (colon >= 0) {
1714
+ const kind = rule.slice(0, colon);
1715
+ const arg = rule.slice(colon + 1);
1716
+ if (kind === "quorum" && QUORUM_ARG.test(arg)) return { kind, params: { n: parseInt(arg, 10) } };
1717
+ if ((kind === "weighted_vote" || kind === "weighted_vote_with_veto") && THRESHOLD_ARG.test(arg)) {
1488
1718
  return { kind, params: { threshold: parseFloat(arg) } };
1489
1719
  }
1490
1720
  }
@@ -1569,6 +1799,23 @@ function registerDeliberation(coord) {
1569
1799
  for (const f of ["from", "rule"]) {
1570
1800
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1571
1801
  }
1802
+ for (const name of ["to", "participants"]) {
1803
+ const v = p[name];
1804
+ if (v != null && typeof v !== "string" && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) {
1805
+ return { error: rpcError(E.PARAMS, `${name} must be a string or a list of strings`) };
1806
+ }
1807
+ }
1808
+ if (typeof p.rule !== "string") return { error: rpcError(E.PARAMS, "rule must be a string") };
1809
+ const bad = paramsError(p, [["weights", "object"], ["veto", "object"]]);
1810
+ if (bad) return bad;
1811
+ const weights = p.weights ?? {};
1812
+ if (!Object.values(weights).every((w) => typeof w === "number" && Number.isInteger(w))) {
1813
+ return { error: rpcError(E.PARAMS, "weights must map participants to integers") };
1814
+ }
1815
+ const veto = p.veto ?? {};
1816
+ if (!Object.values(veto).every((v) => typeof v === "boolean")) {
1817
+ return { error: rpcError(E.PARAMS, "veto must map participants to booleans") };
1818
+ }
1572
1819
  const to = p.to ?? p.participants;
1573
1820
  const participants = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
1574
1821
  if (!participants.length) {
@@ -1619,6 +1866,8 @@ function registerDeliberation(coord) {
1619
1866
  if (delib.state !== "open") {
1620
1867
  return { error: rpcError(E.DELIB_CLOSED_OR_LAPSED, "Deliberation not open") };
1621
1868
  }
1869
+ const bad = paramsError(p, [["comment", "string"], ["text", "string"]]);
1870
+ if (bad) return bad;
1622
1871
  delib.comments.push({
1623
1872
  ts: coord.now(),
1624
1873
  from: p.from,
@@ -1641,6 +1890,8 @@ function registerDeliberation(coord) {
1641
1890
  if (v !== "yea" && v !== "nay" && v !== "abstain") {
1642
1891
  return { error: rpcError(E.PARAMS, "vote must be yea/nay/abstain") };
1643
1892
  }
1893
+ const bad = paramsError(p, [["veto_invoked", "boolean"]]);
1894
+ if (bad) return bad;
1644
1895
  const voter = p.from;
1645
1896
  if (!delib.participants.includes(voter)) {
1646
1897
  return { error: rpcError(
@@ -1682,6 +1933,9 @@ function registerHandoff(coord) {
1682
1933
  for (const f of ["from", "to", "tasks"]) {
1683
1934
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1684
1935
  }
1936
+ if (typeof p.to !== "string") return { error: rpcError(E.PARAMS, "to must be a string") };
1937
+ const bad = paramsError(p, [["context_links", "strings"]]);
1938
+ if (bad) return bad;
1685
1939
  const proposer = p.from;
1686
1940
  const recipient = p.to;
1687
1941
  if (typeof recipient === "string" && !recipient.startsWith("group:")) {
@@ -1698,6 +1952,11 @@ function registerHandoff(coord) {
1698
1952
  }
1699
1953
  const proposed = [];
1700
1954
  for (const t of tasksIn) {
1955
+ if (t === null || typeof t !== "object" || Array.isArray(t) || typeof t.task_id !== "string") {
1956
+ return { error: rpcError(E.PARAMS, "each entry in tasks must be an object with a string task_id") };
1957
+ }
1958
+ const badItem = paramsError(t, [["blockers", "strings"]]);
1959
+ if (badItem) return badItem;
1701
1960
  const tid = t.task_id;
1702
1961
  if (!tid || !ws.tasks.has(tid)) {
1703
1962
  return { error: rpcError(E.PARAMS, `Unknown task in handoff: ${tid}`) };
@@ -1779,6 +2038,8 @@ function registerHandoff(coord) {
1779
2038
  if (ws.members.get(acceptor).paused) {
1780
2039
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Acceptor ${acceptor} is paused`) };
1781
2040
  }
2041
+ const bad = paramsError(p, [["accepted_task_ids", "strings"]]);
2042
+ if (bad) return bad;
1782
2043
  if (Array.isArray(p.accepted_task_ids) && p.accepted_task_ids.length === 0) {
1783
2044
  return { error: rpcError(E.PARAMS, "accepted_task_ids must not be empty") };
1784
2045
  }
@@ -1789,9 +2050,18 @@ function registerHandoff(coord) {
1789
2050
  return { error: rpcError(E.PARAMS, `Task ${tid} not in this handoff`) };
1790
2051
  }
1791
2052
  }
2053
+ for (const tid of acceptedIds) {
2054
+ if (ws.tasks.get(tid)?.assignee !== ho.proposer) {
2055
+ return { error: rpcError(
2056
+ E.HANDOFF_TASKS_NOT_ASSIGNED_TO_PROPOSER,
2057
+ `Task ${tid} is not currently assigned to ${ho.proposer}`
2058
+ ) };
2059
+ }
2060
+ }
1792
2061
  const now = coord.now();
1793
2062
  for (const tid of acceptedIds) {
1794
2063
  const task = ws.tasks.get(tid);
2064
+ if (acceptor !== task.assignee) task.prior_authors = unionOf(task.prior_authors ?? [], heldBy(task, ho.proposer));
1795
2065
  task.assignee = acceptor;
1796
2066
  task.updated_at = now;
1797
2067
  task.history.push({
@@ -1832,16 +2102,20 @@ function registerHandoff(coord) {
1832
2102
 
1833
2103
  // src/profiles/routing.ts
1834
2104
  var CRIT = { low: 0, medium: 1, high: 2, critical: 3 };
1835
- function defaultDepth(hints) {
2105
+ var DECIMAL = /^-?[0-9]+(\.[0-9]+)?$/;
2106
+ function confidenceOf(hints) {
2107
+ const v = hints.confidence;
2108
+ if (typeof v === "number" && Number.isInteger(v)) return v;
2109
+ if (typeof v === "string" && DECIMAL.test(v)) return Number(v);
2110
+ return 1;
2111
+ }
2112
+ function criticalityOf(hints) {
1836
2113
  const crit = hints.criticality ?? "medium";
1837
- let conf = 1;
1838
- try {
1839
- conf = Number(hints.confidence ?? 1);
1840
- } catch {
1841
- conf = 1;
1842
- }
1843
- if (isNaN(conf)) conf = 1;
1844
- const r = CRIT[crit] ?? 1;
2114
+ return typeof crit === "string" && Object.prototype.hasOwnProperty.call(CRIT, crit) ? CRIT[crit] : 1;
2115
+ }
2116
+ function defaultDepth(hints) {
2117
+ const conf = confidenceOf(hints);
2118
+ const r = criticalityOf(hints);
1845
2119
  if (r >= 3) return { depth: "full", summary: "criticality=critical: full review" };
1846
2120
  if (r === 2 && conf < 0.7) return { depth: "full", summary: "criticality=high and confidence<0.7: full review" };
1847
2121
  if (r === 0 && conf >= 0.95) return { depth: "skip", summary: "very low criticality + very high confidence: skip" };
@@ -1907,6 +2181,8 @@ function registerRouting(coord) {
1907
2181
  if (ws.members.get(selected).paused) {
1908
2182
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${selected} is paused`) };
1909
2183
  }
2184
+ const leaving = heldBy(task, p.from);
2185
+ if (selected !== task.assignee && leaving.length) task.prior_authors = unionOf(task.prior_authors ?? [], leaving);
1910
2186
  task.assignee = selected;
1911
2187
  task.updated_at = coord.now();
1912
2188
  task.history.push({
@@ -1937,6 +2213,8 @@ function registerRouting(coord) {
1937
2213
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1938
2214
  const task = ws.tasks.get(p.task_id);
1939
2215
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2216
+ const bad = paramsError(p, [["artefact_routing_hints", "object"]]);
2217
+ if (bad) return bad;
1940
2218
  const hints = {
1941
2219
  ...task.routing_hints ?? {},
1942
2220
  ...p.artefact_routing_hints ?? {}
@@ -1994,6 +2272,8 @@ function registerRouting(coord) {
1994
2272
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
1995
2273
  const task = ws.tasks.get(p.task_id);
1996
2274
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2275
+ const bad = paramsError(p, [["default_escalation_target", "string"]]);
2276
+ if (bad) return bad;
1997
2277
  const hints = { ...task.routing_hints ?? {} };
1998
2278
  let escalate, to, rule;
1999
2279
  if (coord.options.escalationPolicy) {
@@ -2007,15 +2287,8 @@ function registerRouting(coord) {
2007
2287
  return { error: rpcError(E.ROUTING_POLICY_UNREACHABLE, `escalation policy error: ${msg}`) };
2008
2288
  }
2009
2289
  } else {
2010
- const crit = hints.criticality ?? "medium";
2011
- let conf = 1;
2012
- try {
2013
- conf = Number(hints.confidence ?? 1);
2014
- } catch {
2015
- conf = 1;
2016
- }
2017
- if (isNaN(conf)) conf = 1;
2018
- const r = CRIT[crit] ?? 1;
2290
+ const conf = confidenceOf(hints);
2291
+ const r = criticalityOf(hints);
2019
2292
  escalate = r >= 3 || r >= 2 && conf < 0.6;
2020
2293
  to = p.default_escalation_target;
2021
2294
  rule = {
@@ -2072,6 +2345,9 @@ function registerSecuritySigned(coord) {
2072
2345
  for (const f of ["from", "old_kid", "new_jwk"]) {
2073
2346
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
2074
2347
  }
2348
+ for (const f of ["from", "old_kid"]) {
2349
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
2350
+ }
2075
2351
  const member = ws.members.get(p.from);
2076
2352
  if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown participant: ${p.from}`) };
2077
2353
  const oldKid = p.old_kid;
@@ -2082,6 +2358,9 @@ function registerSecuritySigned(coord) {
2082
2358
  if (!newJwk || typeof newJwk.kid !== "string") {
2083
2359
  return { error: rpcError(E.PARAMS, "new_jwk must include kid") };
2084
2360
  }
2361
+ if (member.keys.some((k) => k.kid === newJwk.kid)) {
2362
+ return { error: rpcError(E.PARAMS, `${p.from} already holds a key ${newJwk.kid}; a new key takes a kid of its own`) };
2363
+ }
2085
2364
  const now = coord.now();
2086
2365
  oldKey.valid_until = now;
2087
2366
  member.keys.push({
@@ -2097,6 +2376,11 @@ function registerSecuritySigned(coord) {
2097
2376
  for (const f of ["target_uri", "kid"]) {
2098
2377
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
2099
2378
  }
2379
+ for (const f of ["target_uri", "kid"]) {
2380
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
2381
+ }
2382
+ const bad = paramsError(p, [["from", "string"], ["reason", "string"]]);
2383
+ if (bad) return bad;
2100
2384
  const sender = p.from;
2101
2385
  if (sender !== p.target_uri) {
2102
2386
  const caller = sender ? ws.members.get(sender) : void 0;
@@ -2139,8 +2423,10 @@ function registerWhisper(coord) {
2139
2423
  if (!ws.tasks.has(p.task_id)) {
2140
2424
  return { error: rpcError(E.PARAMS, "Unknown task") };
2141
2425
  }
2142
- if (p.options !== void 0 && p.options !== null && !Array.isArray(p.options)) {
2143
- return { error: rpcError(E.PARAMS, "options must be a list") };
2426
+ const bad = paramsError(p, [["options", "list"], ["urgency", "string"]]);
2427
+ if (bad) return bad;
2428
+ if (typeof p.deadline_ms !== "number" || !Number.isInteger(p.deadline_ms)) {
2429
+ return { error: rpcError(E.PARAMS, "deadline_ms must be an integer") };
2144
2430
  }
2145
2431
  const to = p.to;
2146
2432
  const askee = Array.isArray(to) ? to : [to];
@@ -2203,7 +2489,7 @@ function registerWhisper(coord) {
2203
2489
  if (!hasOption) {
2204
2490
  return { error: rpcError(E.PARAMS, "answer_option is required when options are defined") };
2205
2491
  }
2206
- const valid = new Set(prompt.options.map((o) => o.id));
2492
+ const valid = new Set(prompt.options.filter((o) => o !== null && typeof o === "object" && !Array.isArray(o)).map((o) => o.id));
2207
2493
  if (!valid.has(answerOption)) {
2208
2494
  return { error: rpcError(
2209
2495
  E.WHISPER_OPTION_NOT_IN_SET,
@@ -2221,12 +2507,13 @@ function registerWhisper(coord) {
2221
2507
  prompt.comment = p.comment;
2222
2508
  return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
2223
2509
  });
2224
- coord.checkWhisperLapses = (workspaceId, now) => {
2510
+ coord.checkWhisperLapses = (workspaceId, now) => coord.outsideCall(workspaceId, () => lapse(workspaceId, now));
2511
+ function lapse(workspaceId, now) {
2225
2512
  const ws = coord.workspaces.get(workspaceId);
2226
2513
  if (!ws) return [];
2227
2514
  const cutoff = parseIso(now ?? coord.now());
2228
2515
  const emitted = [];
2229
- for (const prompt of ws.whispers.values()) {
2516
+ for (const prompt of rawValues(ws.whispers)) {
2230
2517
  if (prompt.state !== "pending") continue;
2231
2518
  const asked = parseIso(prompt.asked_at);
2232
2519
  const deadline = asked + prompt.deadline_ms;
@@ -2256,7 +2543,7 @@ function registerWhisper(coord) {
2256
2543
  emitted.push(notify);
2257
2544
  }
2258
2545
  return emitted;
2259
- };
2546
+ }
2260
2547
  }
2261
2548
 
2262
2549
  // src/coordinator.ts
@@ -2269,6 +2556,7 @@ var PRIVILEGED_METHODS = /* @__PURE__ */ new Set([
2269
2556
  "control.rollback",
2270
2557
  "control.set_mode_ceiling",
2271
2558
  "workspace.set_profiles",
2559
+ "workspace.invite",
2272
2560
  "participant.rotate_key",
2273
2561
  "participant.revoke_key"
2274
2562
  ]);
@@ -2312,12 +2600,9 @@ function missing(params, fields) {
2312
2600
  for (const f of fields) if (!(f in params)) return f;
2313
2601
  return null;
2314
2602
  }
2315
- function tagsError(p) {
2316
- const tags = p.tags;
2317
- if (tags !== void 0 && (!Array.isArray(tags) || !tags.every((t) => typeof t === "string"))) {
2318
- return { error: rpcError(E.PARAMS, "tags must be a list of strings") };
2319
- }
2320
- return null;
2603
+ var MODES = /* @__PURE__ */ new Set(["shadow", "trial", "production"]);
2604
+ function isObject(value) {
2605
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2321
2606
  }
2322
2607
  function reviewRuleSupported(rule) {
2323
2608
  if (rule === "any_one_approves" || rule === "all_approve") return true;
@@ -2338,8 +2623,214 @@ function reviewSatisfied(review) {
2338
2623
  if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
2339
2624
  return approvers.size >= 1;
2340
2625
  }
2626
+ function advertises(ws, name) {
2627
+ return ws.profiles.some((p) => p === name || p.startsWith(name + "/"));
2628
+ }
2629
+ function knownProfile(id) {
2630
+ const m = /^([a-z][a-z0-9-]*)\/(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(id);
2631
+ if (!m || !Object.prototype.hasOwnProperty.call(PROFILE_VERSIONS, m[1])) return false;
2632
+ return PROFILE_VERSIONS[m[1]].split(".")[0] === m[2];
2633
+ }
2634
+ var LASTING_PROFILES = ["security-signed", "identity-oidc", "identity-vc"];
2635
+ function profilesError(profiles) {
2636
+ const unknown = profiles.find((x) => !knownProfile(x));
2637
+ if (unknown === void 0) return null;
2638
+ return { error: rpcError(
2639
+ E.PARAMS,
2640
+ `Unknown profile ${JSON.stringify(unknown)}: a profile id is <name>/<major>.<minor> for a profile the catalogue lists`
2641
+ ) };
2642
+ }
2643
+ var RESERVED_KEYS = ["__proto__", "constructor", "prototype"];
2644
+ var PARTICIPANT_TYPES = ["human", "agent", "service", "group", "workspace"];
2645
+ var ADMIN_TYPES = ["human", "service"];
2646
+ function participantError(type, uri, field) {
2647
+ if (!PARTICIPANT_TYPES.includes(type)) {
2648
+ return { error: rpcError(E.PARAMS, "type must be human, agent, service, group or workspace") };
2649
+ }
2650
+ if (!uri.startsWith(`${type}:`)) {
2651
+ return { error: rpcError(E.PARAMS, `${field} must start with "${type}:" for type ${type}`) };
2652
+ }
2653
+ if (type === "group" || type === "workspace") {
2654
+ return { error: rpcError(E.PARAMS, "group: and workspace: URIs name address scopes, which cannot join") };
2655
+ }
2656
+ return null;
2657
+ }
2658
+ function inForce(key, now) {
2659
+ return !(key.revoked_at !== void 0 && now >= key.revoked_at) && !(key.valid_until !== void 0 && now >= key.valid_until);
2660
+ }
2661
+ function sameKey(a, b) {
2662
+ const material = (k) => jwkThumbprint(k) ?? canonicalize(k).toString("utf-8");
2663
+ return a.kid === b.kid && material(a) === material(b);
2664
+ }
2665
+ function credentialKeys(cnf, nonce, joinKeys) {
2666
+ if (isObject(cnf)) {
2667
+ const kid = typeof cnf.kid === "string" && cnf.kid !== "" ? cnf.kid : jwkThumbprint(cnf);
2668
+ if (kid === void 0) return null;
2669
+ const key = { ...cnf, kid };
2670
+ return joinKeys.every((j) => sameKey(key, j)) ? [key] : null;
2671
+ }
2672
+ if (typeof nonce === "string") return joinKeys.every((j) => jwkThumbprint(j) === nonce) ? joinKeys : null;
2673
+ return void 0;
2674
+ }
2675
+ function rawInvitation(ws, uri) {
2676
+ return Map.prototype.get.call(ws.invitations, uri);
2677
+ }
2678
+ function awaitsFirstMember(ws) {
2679
+ if (ws.members.size > 0) return false;
2680
+ return !ws.audit.some((e) => e.outcome == null && entryCall(e)?.method === "participant.join");
2681
+ }
2682
+ function signingKeys(jwks) {
2683
+ const keys = isObject(jwks) && Array.isArray(jwks.keys) ? jwks.keys : [];
2684
+ return keys.filter((j) => isObject(j) && typeof j.kid === "string" && j.kid !== "");
2685
+ }
2686
+ function recordedForm(envelope) {
2687
+ if (envelope.method !== "participant.join" || !isObject(envelope.params)) return envelope;
2688
+ const params = { ...envelope.params };
2689
+ if (typeof params.oidc_token === "string") params.oidc_token = sha256Hex(params.oidc_token);
2690
+ if (isObject(params.vc_presentation)) params.vc_presentation = contentHash(params.vc_presentation);
2691
+ return { ...envelope, params };
2692
+ }
2693
+ function storeFailed() {
2694
+ return rpcError(E.INTERNAL, "Internal error", { reason: "store" });
2695
+ }
2696
+ function targetOf(envelope) {
2697
+ const params = isObject(envelope) ? envelope.params : void 0;
2698
+ const id = isObject(params) ? params.workspace : void 0;
2699
+ return typeof id === "string" && id !== "" ? id : void 0;
2700
+ }
2701
+ var KEPT_NULLS = { whispers: ["default_if_lapsed"] };
2702
+ function dropNulls(o, keep = []) {
2703
+ for (const key of Object.keys(o)) if (o[key] === null && !keep.includes(key)) delete o[key];
2704
+ }
2705
+ function shapeObject(name, item) {
2706
+ if (name === "route_decisions") {
2707
+ const extra = item.extra;
2708
+ delete item.extra;
2709
+ if (isObject(extra)) Object.assign(item, extra);
2710
+ item.kind = "route_decision";
2711
+ }
2712
+ dropNulls(item, KEPT_NULLS[name]);
2713
+ const each = (o, field, f) => {
2714
+ if (!Array.isArray(o[field])) return;
2715
+ for (const x of o[field]) {
2716
+ if (isObject(x)) {
2717
+ dropNulls(x);
2718
+ f?.(x);
2719
+ }
2720
+ }
2721
+ };
2722
+ if (name === "members") each(item, "keys");
2723
+ if (name === "tasks") {
2724
+ each(item, "history", (h) => {
2725
+ if (h.from === void 0 && typeof h.from_ === "string") h.from = h.from_;
2726
+ delete h.from_;
2727
+ });
2728
+ if (isObject(item.review)) {
2729
+ dropNulls(item.review);
2730
+ each(item.review, "decisions");
2731
+ }
2732
+ }
2733
+ if (name === "deliberations") each(item, "votes");
2734
+ if (name === "handoffs") {
2735
+ each(item, "tasks");
2736
+ if (Array.isArray(item.accepted_task_ids) && item.accepted_task_ids.length === 0) delete item.accepted_task_ids;
2737
+ }
2738
+ return item;
2739
+ }
2740
+ function storedCopy(name, item) {
2741
+ return shapeObject(name, structuredClone(item));
2742
+ }
2743
+ function shapeSnapshot(w) {
2744
+ const out = { ...w };
2745
+ dropNulls(out);
2746
+ for (const name of COLLECTIONS) {
2747
+ if (Array.isArray(out[name])) {
2748
+ out[name] = out[name].map((x) => isObject(x) ? storedCopy(name, x) : x);
2749
+ }
2750
+ }
2751
+ return out;
2752
+ }
2753
+ function reviewRequired(ws, task) {
2754
+ return task.review_required === true || task.mode === "trial" && advertises(ws, "modes");
2755
+ }
2756
+ function rawMember(ws, uri) {
2757
+ return typeof uri === "string" ? Map.prototype.get.call(ws.members, uri) : void 0;
2758
+ }
2759
+ function unionOf(...lists) {
2760
+ const out = [];
2761
+ for (const list of lists) for (const uri of list) if (!out.includes(uri)) out.push(uri);
2762
+ return out;
2763
+ }
2764
+ function authorsOf(task, completer) {
2765
+ const authors = [task.assignee];
2766
+ if (typeof completer === "string" && completer !== task.assignee) authors.push(completer);
2767
+ return unionOf(authors, Array.isArray(task.prior_authors) ? task.prior_authors : []);
2768
+ }
2769
+ function stateBefore(history, i) {
2770
+ for (let j = i - 1; j >= 0; j--) {
2771
+ if (history[j].state !== "paused") return history[j].state;
2772
+ }
2773
+ return void 0;
2774
+ }
2775
+ function workCompletion(history, i) {
2776
+ if (history[i].state !== "completed") return false;
2777
+ const before = stateBefore(history, i);
2778
+ return before !== "review_requested" && before !== "completed";
2779
+ }
2780
+ function lastCompleter(task) {
2781
+ for (let i = task.history.length - 1; i >= 0; i--) {
2782
+ if (workCompletion(task.history, i)) return task.history[i].from;
2783
+ }
2784
+ return void 0;
2785
+ }
2786
+ var OPENED_ON_COMPLETE = "review required; opened on task.complete";
2787
+ var NEW_ROUND_ON_COMPLETE = "a new round of the review; opened on task.complete";
2788
+ function storedAuthors(task) {
2789
+ let requester;
2790
+ const withRequester = (authors) => unionOf(authors, requester !== void 0 ? [requester] : []);
2791
+ for (let i = task.history.length - 1; i >= 0; i--) {
2792
+ const h = task.history[i];
2793
+ const opens = h.state === "review_requested" && stateBefore(task.history, i) !== "review_requested";
2794
+ if (opens && (h.note === OPENED_ON_COMPLETE || h.note === NEW_ROUND_ON_COMPLETE)) return withRequester(authorsOf(task, h.from));
2795
+ if (opens && requester === void 0) requester = h.from;
2796
+ if (workCompletion(task.history, i)) return withRequester(authorsOf(task, h.from));
2797
+ }
2798
+ return withRequester(authorsOf(task, void 0));
2799
+ }
2800
+ var ENDED = ["completed", "declined", "cancelled", "superseded", "escalated"];
2801
+ function reviewHolds(ws, task) {
2802
+ const seen = /* @__PURE__ */ new Set();
2803
+ for (let t = task; t && !seen.has(t.id); t = typeof t.supersedes === "string" ? ws.tasks.get(t.supersedes) : void 0) {
2804
+ seen.add(t.id);
2805
+ if (!reviewRequired(ws, t)) return [];
2806
+ if (t.review) {
2807
+ const decided = t.review.decisions.some((d) => d.kind === "reject" || d.kind === "override") || reviewSatisfied(t.review);
2808
+ return decided ? [] : t.review.authors;
2809
+ }
2810
+ }
2811
+ return [];
2812
+ }
2813
+ function openRequiredReviewAuthors(ws, task) {
2814
+ return ENDED.includes(task.state) ? [] : reviewHolds(ws, task);
2815
+ }
2816
+ function heldBy(task, mover) {
2817
+ return mover === task.assignee ? [task.assignee] : [];
2818
+ }
2819
+ function successorAuthors(old, mover) {
2820
+ const completer = lastCompleter(old);
2821
+ return unionOf(
2822
+ old.review ? old.review.authors : [],
2823
+ heldBy(old, mover),
2824
+ completer !== void 0 ? [completer] : [],
2825
+ Array.isArray(old.prior_authors) ? old.prior_authors : []
2826
+ );
2827
+ }
2828
+ function isScope(uri) {
2829
+ return typeof uri === "string" && (uri.startsWith("workspace:") || uri.startsWith("group:"));
2830
+ }
2341
2831
  function reply(env, body) {
2342
- const out = { jsonrpc: "2.0", id: env.id };
2832
+ const sent = env !== null && typeof env === "object" && !Array.isArray(env) ? env.id : void 0;
2833
+ const out = { jsonrpc: "2.0", id: sent ?? null };
2343
2834
  if (body.error !== void 0) out.error = body.error;
2344
2835
  else out.result = body.result;
2345
2836
  return out;
@@ -2354,7 +2845,7 @@ function indexSigned(index, entry) {
2354
2845
  return;
2355
2846
  }
2356
2847
  if (entry.outcome != null) index.refused.set(digest, { seq: entry.seq, code: entry.outcome.code });
2357
- else index.accepted.add(digest);
2848
+ else if (!index.accepted.has(digest)) index.accepted.set(digest, entry.seq);
2358
2849
  }
2359
2850
  function isThenable(value) {
2360
2851
  return typeof value?.then === "function";
@@ -2382,13 +2873,21 @@ var Coordinator = class {
2382
2873
  unflushed = /* @__PURE__ */ new Set();
2383
2874
  /** Workspaces with a write queued behind an async write in flight. */
2384
2875
  flushQueued = /* @__PURE__ */ new Set();
2876
+ /**
2877
+ * Workspaces whose store copy may differ from memory after a synchronous
2878
+ * write failed or conflicted. Each is reloaded from the store before the
2879
+ * next call on it, which is refused while the reload fails.
2880
+ */
2881
+ held = /* @__PURE__ */ new Set();
2882
+ /** Entries appended and not yet written, which the audit listeners hear of once they are. */
2883
+ unannounced = /* @__PURE__ */ new WeakMap();
2385
2884
  clockMs;
2386
2885
  frozenNow;
2387
2886
  auditListeners = [];
2388
2887
  /**
2389
2888
  * The signed calls on each workspace's log, by what their senders signed:
2390
- * recorded refusals with their seq and code, and accepted calls. Rebuilt
2391
- * from the log when missing, so a snapshot never carries it.
2889
+ * recorded refusals with their seq and code, and accepted calls with their
2890
+ * seq. Rebuilt from the log when missing, so a snapshot never carries it.
2392
2891
  */
2393
2892
  signedIndexes = /* @__PURE__ */ new WeakMap();
2394
2893
  /** Method handler registry; profiles plug into this. */
@@ -2524,6 +3023,7 @@ var Coordinator = class {
2524
3023
  change = this.takeChange(ws, p);
2525
3024
  } catch (err) {
2526
3025
  p.replace = true;
3026
+ if (this.options.storeErrors !== "log") this.held.add(ws.id);
2527
3027
  this.reportStoreError(err, { id: ws.id, version: ws.audit.length });
2528
3028
  return;
2529
3029
  }
@@ -2532,6 +3032,7 @@ var Coordinator = class {
2532
3032
  const done = () => {
2533
3033
  p.seq = taken.version;
2534
3034
  if (taken.replace) p.replace = false;
3035
+ this.announce(ws, taken.version);
2535
3036
  };
2536
3037
  const failed = (err) => {
2537
3038
  this.giveBack(ws, p, taken);
@@ -2541,13 +3042,34 @@ var Coordinator = class {
2541
3042
  try {
2542
3043
  result = this.store.apply(taken);
2543
3044
  } catch (err) {
3045
+ if (err instanceof StoreConflictError || this.options.storeErrors !== "log") this.held.add(ws.id);
2544
3046
  failed(err);
2545
3047
  return;
2546
3048
  }
2547
3049
  if (isThenable(result)) return Promise.resolve(result).then(done, failed);
2548
3050
  done();
2549
3051
  }
2550
- /** The change since the last write, taking the recorded keys with it. */
3052
+ /** Tell the audit listeners of the entries now written, up to `upTo`. */
3053
+ announce(ws, upTo) {
3054
+ const queued = this.unannounced.get(ws);
3055
+ if (!queued?.length) return;
3056
+ const now = queued.filter((e) => e.seq < upTo);
3057
+ this.unannounced.set(ws, queued.filter((e) => e.seq >= upTo));
3058
+ for (const entry of now) {
3059
+ for (const l of this.auditListeners) {
3060
+ try {
3061
+ l(ws, entry);
3062
+ } catch {
3063
+ }
3064
+ }
3065
+ }
3066
+ }
3067
+ /**
3068
+ * The change since the last write, taking the recorded keys with it. Each
3069
+ * object is written as a copy in the shape a restore gives back, so a
3070
+ * coordinator restarted on the store holds what this one holds, and the
3071
+ * workspace's own objects are left as they are.
3072
+ */
2551
3073
  takeChange(ws, p) {
2552
3074
  const replace = p.replace;
2553
3075
  const objects = {};
@@ -2556,12 +3078,15 @@ var Coordinator = class {
2556
3078
  if (replace) {
2557
3079
  const all = {};
2558
3080
  for (const [key, value] of Map.prototype.entries.call(map)) {
2559
- all[key] = value;
3081
+ all[key] = storedCopy(name, value);
2560
3082
  }
2561
3083
  objects[name] = all;
2562
3084
  } else if (map.dirty.size) {
2563
3085
  const changed = {};
2564
- for (const key of map.dirty) changed[key] = map.peek(key) ?? null;
3086
+ for (const key of map.dirty) {
3087
+ const value = map.peek(key);
3088
+ changed[key] = value === void 0 ? null : storedCopy(name, value);
3089
+ }
2565
3090
  objects[name] = changed;
2566
3091
  }
2567
3092
  map.dirty.clear();
@@ -2613,24 +3138,28 @@ var Coordinator = class {
2613
3138
  this.reportStoreError(err, { id: ws.id, version: next });
2614
3139
  return;
2615
3140
  }
3141
+ const upTo = ws.audit.length;
2616
3142
  const pending = this.saveChains.get(ws.id);
2617
3143
  if (pending) {
2618
- this.trackSave(pending.then(() => this.store.save(record)), record);
3144
+ this.trackSave(pending.then(() => this.store.save(record)), record, () => this.announce(ws, upTo));
2619
3145
  return;
2620
3146
  }
2621
3147
  let result;
2622
3148
  try {
2623
3149
  result = this.store.save(record);
2624
3150
  } catch (err) {
3151
+ if (this.options.storeErrors !== "log") this.held.add(ws.id);
2625
3152
  this.reportStoreError(err, record);
2626
3153
  return;
2627
3154
  }
2628
3155
  if (isThenable(result)) {
2629
- this.trackSave(Promise.resolve(result), record);
3156
+ this.trackSave(Promise.resolve(result), record, () => this.announce(ws, upTo));
3157
+ return;
2630
3158
  }
3159
+ this.announce(ws, upTo);
2631
3160
  }
2632
- trackSave(save, record) {
2633
- const guarded = save.catch((err) => this.reportStoreError(err, record));
3161
+ trackSave(save, record, saved) {
3162
+ const guarded = save.then(saved, (err) => this.reportStoreError(err, record));
2634
3163
  this.saveChains.set(record.id, guarded);
2635
3164
  void guarded.then(() => {
2636
3165
  if (this.saveChains.get(record.id) === guarded) {
@@ -2638,6 +3167,17 @@ var Coordinator = class {
2638
3167
  }
2639
3168
  });
2640
3169
  }
3170
+ reportInternalError(err, method, workspace) {
3171
+ const call2 = { method, ...workspace !== void 0 ? { workspace } : {} };
3172
+ if (this.options.onInternalError) {
3173
+ try {
3174
+ this.options.onInternalError(err, call2);
3175
+ } catch {
3176
+ }
3177
+ return;
3178
+ }
3179
+ console.error(`chap coordinator: ${method} failed with an internal error:`, err);
3180
+ }
2641
3181
  reportStoreError(err, record) {
2642
3182
  if (this.options.onStoreError) {
2643
3183
  try {
@@ -2647,7 +3187,9 @@ var Coordinator = class {
2647
3187
  return;
2648
3188
  }
2649
3189
  const message = err instanceof Error ? err.message : String(err);
2650
- console.error(`chap coordinator: writing workspace ${record.id} to the store failed: ${message}`);
3190
+ const line = `chap coordinator: writing workspace ${record.id} to the store failed: ${message}`;
3191
+ if (err instanceof StoreConflictError) console.warn(line);
3192
+ else console.error(line);
2651
3193
  }
2652
3194
  async drainSaves() {
2653
3195
  while (this.saveChains.size) {
@@ -2698,16 +3240,25 @@ var Coordinator = class {
2698
3240
  return null;
2699
3241
  }
2700
3242
  /**
2701
- * Assert that `sender` was addressed in the task's review. Applied to
2702
- * review-decision methods (decide.* and abstain.declare): to act on a
2703
- * review you must be one of the reviewers it was addressed to (the
2704
- * `to` set on review.request). Membership is the floor; this is the
2705
- * eligibility ceiling for decisions. Returns an error fragment if not
2706
- * eligible, else null.
3243
+ * Assert that `sender` may decide the task's open review. Applied to
3244
+ * review-decision methods (decide.* and abstain.declare) and to
3245
+ * review.acknowledge: to act on a review you must not be an author of the
3246
+ * work under review, must be a human member where the task requires
3247
+ * review, and must be one of the reviewers it was addressed to (the `to`
3248
+ * set on review.request).
3249
+ * Membership is the floor; this is the eligibility ceiling for decisions.
3250
+ * Returns an error fragment if not eligible, else null.
2707
3251
  */
2708
- requireReviewer(task, sender) {
3252
+ requireReviewer(ws, task, sender) {
2709
3253
  const review = task.review;
2710
- if (!review || !review.requested_to || review.requested_to.length === 0) {
3254
+ if (!review) return null;
3255
+ if (typeof sender === "string" && review.authors.includes(sender)) {
3256
+ return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot decide it") };
3257
+ }
3258
+ if (reviewRequired(ws, task) && rawMember(ws, sender)?.type !== "human") {
3259
+ return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${String(sender)} is not a human member`) };
3260
+ }
3261
+ if (!review.requested_to || review.requested_to.length === 0) {
2711
3262
  return null;
2712
3263
  }
2713
3264
  if (review.requested_to.some((r) => typeof r === "string" && (r.startsWith("workspace:") || r.startsWith("group:")))) {
@@ -2736,23 +3287,31 @@ var Coordinator = class {
2736
3287
  routing_policy_uri: ws.routing_policy_uri,
2737
3288
  step_up_window_sec: ws.step_up_window_sec,
2738
3289
  min_acr: ws.min_acr,
3290
+ signature_policy: ws.signature_policy,
3291
+ join_policy: ws.join_policy,
3292
+ review_rule: ws.review_rule,
2739
3293
  chain_head: ws.chain_head,
2740
3294
  chain_enabled: ws.chain_enabled
2741
3295
  };
2742
3296
  }
2743
- /** One workspace as `snapshot()` holds it. Reads nothing as a change. */
3297
+ /**
3298
+ * One workspace as `snapshot()` holds it: each object of its collections a
3299
+ * copy in the shape the store keeps. Reads nothing as a change.
3300
+ */
2744
3301
  snapshotOf(ws) {
2745
3302
  const { chain_head, chain_enabled, ...own } = this.metaOf(ws);
3303
+ const stored = (name) => Array.from(rawValues(ws[name])).map((v) => storedCopy(name, v));
2746
3304
  return {
2747
3305
  ...own,
2748
- members: Array.from(rawValues(ws.members)),
2749
- tasks: Array.from(rawValues(ws.tasks)),
2750
- overrides: Array.from(rawValues(ws.overrides)),
2751
- whispers: Array.from(rawValues(ws.whispers)),
2752
- deliberations: Array.from(rawValues(ws.deliberations)),
2753
- handoffs: Array.from(rawValues(ws.handoffs)),
2754
- snapshots: Array.from(rawValues(ws.snapshots)),
2755
- route_decisions: Array.from(rawValues(ws.route_decisions)),
3306
+ members: stored("members"),
3307
+ tasks: stored("tasks"),
3308
+ overrides: stored("overrides"),
3309
+ whispers: stored("whispers"),
3310
+ deliberations: stored("deliberations"),
3311
+ handoffs: stored("handoffs"),
3312
+ snapshots: stored("snapshots"),
3313
+ route_decisions: stored("route_decisions"),
3314
+ invitations: stored("invitations"),
2756
3315
  idempotency_keys: { ...ws.idempotency_keys },
2757
3316
  audit: ws.audit,
2758
3317
  chain_head,
@@ -2780,7 +3339,8 @@ var Coordinator = class {
2780
3339
  }
2781
3340
  }
2782
3341
  /** One workspace from a snapshot entry, its maps tracked. */
2783
- rebuild(w) {
3342
+ rebuild(snapshot) {
3343
+ const w = shapeSnapshot(snapshot);
2784
3344
  const ws = {
2785
3345
  id: w.id,
2786
3346
  created: w.created,
@@ -2791,6 +3351,11 @@ var Coordinator = class {
2791
3351
  routing_policy_uri: w.routing_policy_uri,
2792
3352
  step_up_window_sec: w.step_up_window_sec ?? 300,
2793
3353
  min_acr: w.min_acr,
3354
+ // A workspace written before the policy existed verified every call,
3355
+ // and one written before the join policy existed admitted anyone.
3356
+ signature_policy: w.signature_policy === "humans" ? "humans" : "all",
3357
+ join_policy: w.join_policy === "invite" ? "invite" : "open",
3358
+ review_rule: typeof w.review_rule === "string" && reviewRuleSupported(w.review_rule) ? w.review_rule : void 0,
2794
3359
  members: this.newMap(),
2795
3360
  tasks: this.newMap(),
2796
3361
  overrides: this.newMap(),
@@ -2799,6 +3364,7 @@ var Coordinator = class {
2799
3364
  handoffs: this.newMap(),
2800
3365
  snapshots: this.newMap(),
2801
3366
  route_decisions: this.newMap(),
3367
+ invitations: this.newMap(),
2802
3368
  idempotency_keys: w.idempotency_keys ?? {},
2803
3369
  audit: w.audit ?? [],
2804
3370
  chain_head: w.chain_head,
@@ -2807,9 +3373,16 @@ var Coordinator = class {
2807
3373
  for (const m of w.members ?? []) {
2808
3374
  if (!Array.isArray(m.keys)) m.keys = [];
2809
3375
  if (m.paused === void 0) m.paused = false;
3376
+ const scheme = typeof m.uri === "string" ? m.uri.split(":")[0] : "";
3377
+ if (PARTICIPANT_TYPES.includes(scheme)) m.type = scheme;
3378
+ if (m.role === "admin" && !ADMIN_TYPES.includes(m.type)) m.role = "participant";
2810
3379
  ws.members.set(m.uri, m);
2811
3380
  }
2812
- for (const t of w.tasks ?? []) ws.tasks.set(t.id, t);
3381
+ for (const t of w.tasks ?? []) {
3382
+ if (t.review && !Array.isArray(t.review.authors)) t.review.authors = storedAuthors(t);
3383
+ if (t.review && !Array.isArray(t.review.acknowledgements)) t.review.acknowledgements = [];
3384
+ ws.tasks.set(t.id, t);
3385
+ }
2813
3386
  for (const o of w.overrides ?? []) ws.overrides.set(o.id, o);
2814
3387
  for (const x of w.whispers ?? [])
2815
3388
  ws.whispers.set(x.id, x);
@@ -2853,20 +3426,119 @@ var Coordinator = class {
2853
3426
  }
2854
3427
  for (const x of w.route_decisions ?? [])
2855
3428
  ws.route_decisions.set(x.id, x);
3429
+ for (const x of w.invitations ?? []) ws.invitations.set(x.uri, x);
2856
3430
  return ws;
2857
3431
  }
2858
3432
  // -- dispatch ------------------------------------------------------
2859
3433
  dispatch(envelope) {
2860
3434
  this.frozenNow = this.advanceClock();
2861
3435
  this.tracker.depth++;
3436
+ let response;
3437
+ let target;
3438
+ let ran = false;
2862
3439
  try {
2863
- const response = this._dispatch(envelope);
2864
- if (response.error) this.recordRefusal(envelope, response.error);
2865
- return response;
3440
+ target = targetOf(envelope);
3441
+ const refused = target === void 0 ? null : this.keepUp(target);
3442
+ if (refused) {
3443
+ response = reply(envelope, { error: refused });
3444
+ } else {
3445
+ ran = true;
3446
+ response = this._dispatch(envelope);
3447
+ if (response.error) this.recordRefusal(envelope, response.error);
3448
+ else if (target === void 0 && envelope.method === "workspace.create") {
3449
+ target = response.result?.workspace;
3450
+ }
3451
+ }
2866
3452
  } finally {
2867
3453
  if (--this.tracker.depth === 0) this.flushUnflushed();
2868
3454
  this.frozenNow = void 0;
2869
3455
  }
3456
+ if (ran && target !== void 0 && this.tracker.depth === 0 && this.held.has(target)) {
3457
+ this.reload(target);
3458
+ return reply(envelope, { error: storeFailed() });
3459
+ }
3460
+ return response;
3461
+ }
3462
+ /**
3463
+ * Work that changes a workspace outside any call, a whisper lapse check
3464
+ * for one, kept to the rules a call keeps (SPECIFICATION 10.3): the
3465
+ * workspace is first brought up to date with the store, and left alone
3466
+ * as long as it is held; what the work changed is written once, when it ends;
3467
+ * and where that write fails, the workspace goes back to what the store
3468
+ * holds and the work's results are dropped, since none took effect.
3469
+ */
3470
+ outsideCall(id, work) {
3471
+ if (this.tracker.depth > 0) return work();
3472
+ if (this.keepUp(id)) return [];
3473
+ this.tracker.depth++;
3474
+ let out;
3475
+ try {
3476
+ out = work();
3477
+ } finally {
3478
+ if (--this.tracker.depth === 0) this.flushUnflushed();
3479
+ }
3480
+ if (this.held.has(id)) {
3481
+ this.reload(id);
3482
+ return [];
3483
+ }
3484
+ return out;
3485
+ }
3486
+ /**
3487
+ * Before a call on a workspace: where the store holds a different number of
3488
+ * audit entries for it than this instance last wrote, another writer has
3489
+ * written it, and it is reloaded. A workspace held after a failed write is
3490
+ * reloaded as well, and the call refused while that fails.
3491
+ */
3492
+ keepUp(id) {
3493
+ if (this.held.has(id)) return this.reload(id) ? null : storeFailed();
3494
+ if (typeof this.store.version !== "function" || this.saveChains.has(id)) return null;
3495
+ let stored;
3496
+ try {
3497
+ stored = this.store.version(id);
3498
+ } catch {
3499
+ return null;
3500
+ }
3501
+ if (isThenable(stored)) return null;
3502
+ const ws = this.workspaces.get(id);
3503
+ const p = ws ? this.persisted.get(ws) : void 0;
3504
+ if (ws && (!p || p.replace)) return null;
3505
+ if (stored === p?.seq) return null;
3506
+ return this.reload(id) ? null : storeFailed();
3507
+ }
3508
+ /** Replace a workspace with what the store holds of it. False when it cannot be read. */
3509
+ reload(id) {
3510
+ let record;
3511
+ try {
3512
+ if (typeof this.store.loadOne === "function") {
3513
+ record = this.store.loadOne(id);
3514
+ } else {
3515
+ const all = this.store.load();
3516
+ if (isThenable(all)) return false;
3517
+ record = all.find((r) => r.id === id);
3518
+ }
3519
+ if (isThenable(record)) return false;
3520
+ } catch (err) {
3521
+ this.reportStoreError(err, { id, version: this.workspaces.get(id)?.audit.length ?? 0 });
3522
+ return false;
3523
+ }
3524
+ const old = this.workspaces.get(id);
3525
+ if (old) this.unflushed.delete(old);
3526
+ if (record) {
3527
+ let ws;
3528
+ try {
3529
+ ws = this.rebuild(record.data);
3530
+ } catch (err) {
3531
+ this.reportStoreError(err, { id, version: record.version });
3532
+ return false;
3533
+ }
3534
+ this.workspaces.set(id, ws);
3535
+ this.markWritten(ws);
3536
+ this.wsVersions.set(id, record.version);
3537
+ } else {
3538
+ this.workspaces.delete(id);
3539
+ }
3540
+ this.held.delete(id);
3541
+ return true;
2870
3542
  }
2871
3543
  _dispatch(envelope) {
2872
3544
  if (!isValidEnvelope(envelope) || typeof envelope.method !== "string" || !envelope.method) {
@@ -2903,11 +3575,16 @@ var Coordinator = class {
2903
3575
  const msg = canonicalError instanceof Error ? canonicalError.message : String(canonicalError);
2904
3576
  return reply(envelope, { error: rpcError(E.PARAMS, msg) });
2905
3577
  }
3578
+ if (params.workspace !== void 0 && (typeof params.workspace !== "string" || !params.workspace)) {
3579
+ return reply(envelope, { error: rpcError(E.PARAMS, "workspace must be a non-empty string") });
3580
+ }
2906
3581
  if (isSigned(envelope)) {
2907
3582
  const wsId2 = params.workspace;
2908
3583
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2909
3584
  if (ws) {
2910
- const prior = this.signedIndex(ws).refused.get(signedDigest(envelope));
3585
+ const index = this.signedIndex(ws);
3586
+ const digest = signedDigest(recordedForm(envelope));
3587
+ const prior = index.refused.get(digest);
2911
3588
  if (prior) {
2912
3589
  return reply(envelope, { error: rpcError(
2913
3590
  prior.code,
@@ -2915,15 +3592,24 @@ var Coordinator = class {
2915
3592
  { refused_at_seq: prior.seq }
2916
3593
  ) });
2917
3594
  }
3595
+ const accepted = index.accepted.get(digest);
3596
+ if (accepted !== void 0) {
3597
+ return reply(envelope, { error: rpcError(
3598
+ E.SIG_ALREADY_ACCEPTED,
3599
+ `Accepted at seq ${accepted}; a signed request takes effect once`,
3600
+ { accepted_at_seq: accepted }
3601
+ ) });
3602
+ }
2918
3603
  }
2919
3604
  }
2920
- if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
3605
+ const target = typeof params.workspace === "string" ? this.workspaces.get(params.workspace) : void 0;
3606
+ if (this.requiresSignatures(target) && method !== "participant.join" && method !== "workspace.create" && !(!isSigned(envelope) && target !== void 0 && !this.mustSign(target, rawMember(target, params.from)?.type))) {
2921
3607
  const sigErr = this.verifySignature(envelope);
2922
3608
  if (sigErr) return reply(envelope, { error: sigErr });
2923
3609
  if (method === "participant.rotate_key") {
2924
3610
  const signingKid = envelope.sig.split(":")[1];
2925
3611
  const oldKid = params.old_kid;
2926
- if (oldKid !== void 0 && signingKid !== oldKid) {
3612
+ if (oldKid != null && signingKid !== oldKid) {
2927
3613
  return reply(envelope, { error: rpcError(
2928
3614
  E.SIG_ROTATION_KEY_MISMATCH,
2929
3615
  "Rotation must be signed with the old key"
@@ -2978,14 +3664,14 @@ var Coordinator = class {
2978
3664
  try {
2979
3665
  out = handler(ownCopy(params));
2980
3666
  } catch (e) {
2981
- const msg = e instanceof Error ? e.message : String(e);
2982
- return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error", { detail: msg }) });
3667
+ this.reportInternalError(e, method, params.workspace);
3668
+ return reply(envelope, { error: rpcError(E.INTERNAL, "Internal error") });
2983
3669
  }
2984
3670
  if (out.error) return reply(envelope, { error: out.error });
2985
- const wsId = params.workspace;
3671
+ const wsId = method === "workspace.create" ? out.result.workspace : params.workspace;
2986
3672
  if (typeof wsId === "string" && !READ_ONLY_METHODS.has(method)) {
2987
3673
  const ws = this.workspaces.get(wsId);
2988
- if (ws) this.recordAudit(ws, envelope);
3674
+ if (ws) this.recordAudit(ws, recordedForm(envelope));
2989
3675
  }
2990
3676
  return reply(envelope, { result: ownCopy(out.result) });
2991
3677
  }
@@ -3025,7 +3711,7 @@ var Coordinator = class {
3025
3711
  signedIndex(ws) {
3026
3712
  let index = this.signedIndexes.get(ws);
3027
3713
  if (!index) {
3028
- index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Set() };
3714
+ index = { refused: /* @__PURE__ */ new Map(), accepted: /* @__PURE__ */ new Map() };
3029
3715
  for (const entry of ws.audit) indexSigned(index, entry);
3030
3716
  this.signedIndexes.set(ws, index);
3031
3717
  }
@@ -3041,12 +3727,9 @@ var Coordinator = class {
3041
3727
  ws.audit.push(entry);
3042
3728
  const index = this.signedIndexes.get(ws);
3043
3729
  if (index) indexSigned(index, entry);
3044
- for (const l of this.auditListeners) {
3045
- try {
3046
- l(ws, entry);
3047
- } catch {
3048
- }
3049
- }
3730
+ const queued = this.unannounced.get(ws);
3731
+ if (queued) queued.push(entry);
3732
+ else this.unannounced.set(ws, [entry]);
3050
3733
  this.persist(ws);
3051
3734
  }
3052
3735
  // -- signature verification (security-signed/1.0) -----------------
@@ -3056,7 +3739,12 @@ var Coordinator = class {
3056
3739
  return rpcError(E.SIG_VERIFY_FAILED, "Missing top-level `sig` field");
3057
3740
  }
3058
3741
  const parts = sig.split(":");
3059
- if (parts.length !== 3 || parts[0] !== "ed25519") {
3742
+ const scheme = parts[0];
3743
+ if (parts.length !== 3 || scheme !== "ed25519" && scheme !== "webauthn") {
3744
+ return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
3745
+ }
3746
+ const assertion = scheme === "webauthn" ? parseWebAuthnAssertion(parts[2]) : void 0;
3747
+ if (scheme === "webauthn" && !assertion) {
3060
3748
  return rpcError(E.SIG_VERIFY_FAILED, "Malformed signature tag");
3061
3749
  }
3062
3750
  const kid = parts[1];
@@ -3090,6 +3778,14 @@ var Coordinator = class {
3090
3778
  if (revoked) return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
3091
3779
  return rpcError(E.SIG_KEY_NOT_FOUND, `No key ${kid} valid at ${ts} for ${sender}`);
3092
3780
  }
3781
+ if (assertion) {
3782
+ if (!isWebAuthnKey(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not a WebAuthn key`);
3783
+ const stripped = JSON.parse(JSON.stringify(envelope));
3784
+ delete stripped.sig;
3785
+ const failure = webAuthnFailure(canonicalize(stripped), assertion, key.jwk);
3786
+ return failure === null ? null : rpcError(E.SIG_VERIFY_FAILED, failure);
3787
+ }
3788
+ if (!isEd25519Key(key.jwk)) return rpcError(E.SIG_VERIFY_FAILED, `Key ${kid} is not an Ed25519 key`);
3093
3789
  try {
3094
3790
  const stripped = JSON.parse(JSON.stringify(envelope));
3095
3791
  delete stripped.sig;
@@ -3103,6 +3799,39 @@ var Coordinator = class {
3103
3799
  return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
3104
3800
  }
3105
3801
  }
3802
+ /**
3803
+ * SPECIFICATION 6.5: an identity profile states that a verifier checks
3804
+ * people's credentials, so advertising one this coordinator has no
3805
+ * verifier for describes a guarantee it does not give.
3806
+ */
3807
+ unverifiedIdentity(profiles) {
3808
+ const named = (name) => profiles.some((x) => x === name || x.startsWith(name + "/"));
3809
+ if (named("identity-oidc") && this.options.verifyOidcToken === void 0) {
3810
+ return rpcError(
3811
+ E.PARAMS,
3812
+ "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."
3813
+ );
3814
+ }
3815
+ if (named("identity-vc") && this.options.verifyVc === void 0) {
3816
+ return rpcError(
3817
+ E.PARAMS,
3818
+ "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."
3819
+ );
3820
+ }
3821
+ return null;
3822
+ }
3823
+ /** Whether a workspace requires signatures: it advertises security-signed/1.0, or this coordinator is configured to. */
3824
+ requiresSignatures(ws) {
3825
+ return !!this.options.requireSignatures || ws !== void 0 && advertises(ws, "security-signed");
3826
+ }
3827
+ /**
3828
+ * Whether a participant of this type signs its calls in a workspace that
3829
+ * requires signatures: every participant under "all", and every one but an
3830
+ * agent under "humans". A non-member signs.
3831
+ */
3832
+ mustSign(ws, type) {
3833
+ return ws.signature_policy === "all" || type !== "agent";
3834
+ }
3106
3835
  /** Lookup the key valid for a given participant at a given time. */
3107
3836
  lookupKey(member, kid, ts) {
3108
3837
  for (const k of member.keys) {
@@ -3176,6 +3905,7 @@ var Coordinator = class {
3176
3905
  this.handlers.set("workspace.create", (p) => this.opWorkspaceCreate(p));
3177
3906
  this.handlers.set("workspace.describe", (p) => this.opWorkspaceDescribe(p));
3178
3907
  this.handlers.set("workspace.set_profiles", (p) => this.opWorkspaceSetProfiles(p));
3908
+ this.handlers.set("workspace.invite", (p) => this.opWorkspaceInvite(p));
3179
3909
  this.handlers.set("participant.join", (p) => this.opParticipantJoin(p));
3180
3910
  this.handlers.set("participant.leave", (p) => this.opParticipantLeave(p));
3181
3911
  this.handlers.set("task.create", (p) => this.opTaskCreate(p));
@@ -3183,6 +3913,7 @@ var Coordinator = class {
3183
3913
  this.handlers.set("task.complete", (p) => this.opTaskComplete(p));
3184
3914
  this.handlers.set("audit.read", (p) => this.opAuditRead(p));
3185
3915
  this.handlers.set("review.request", (p) => this.opReviewRequest(p));
3916
+ this.handlers.set("review.acknowledge", (p) => this.opReviewAcknowledge(p));
3186
3917
  this.handlers.set("decide.approve", (p) => this.opDecide(p, "approve"));
3187
3918
  this.handlers.set("decide.reject", (p) => this.opDecide(p, "reject"));
3188
3919
  this.handlers.set("decide.override", (p) => this.opDecideOverride(p));
@@ -3202,24 +3933,59 @@ var Coordinator = class {
3202
3933
  // Core method handlers
3203
3934
  // ==========================================================
3204
3935
  opWorkspaceCreate(p) {
3936
+ const built = this.buildWorkspace(p);
3937
+ if (built.error) return { error: built.error };
3938
+ this.addWorkspace(built.ws);
3939
+ return { result: { workspace: built.ws.id, created: built.ws.created } };
3940
+ }
3941
+ /**
3942
+ * The workspace a workspace.create with these params makes, or why it
3943
+ * cannot be made. Nothing is registered, so participant.join can judge a
3944
+ * join by the workspace it would make and make it only once the join
3945
+ * passes.
3946
+ */
3947
+ buildWorkspace(p) {
3948
+ const bad = paramsError(p, [
3949
+ ["profiles", "strings"],
3950
+ ["mode", "string"],
3951
+ ["mode_ceiling", "string"],
3952
+ ["routing_policy_uri", "string"],
3953
+ ["step_up_window_sec", "integer"],
3954
+ ["min_acr", "string"]
3955
+ ]);
3956
+ if (bad) return bad;
3957
+ for (const name of ["mode", "mode_ceiling"]) {
3958
+ if (p[name] != null && !MODES.has(p[name])) {
3959
+ return { error: rpcError(E.PARAMS, `${name} must be shadow, trial or production`) };
3960
+ }
3961
+ }
3962
+ const policy = p.signature_policy ?? this.options.signaturePolicy ?? "all";
3963
+ if (policy !== "all" && policy !== "humans") {
3964
+ return { error: rpcError(E.PARAMS, "signature_policy must be all or humans") };
3965
+ }
3966
+ const joinPolicy = p.join_policy ?? this.options.joinPolicy ?? "open";
3967
+ if (joinPolicy !== "open" && joinPolicy !== "invite") {
3968
+ return { error: rpcError(E.PARAMS, "join_policy must be open or invite") };
3969
+ }
3970
+ const reviewRule = p.review_rule ?? void 0;
3971
+ if (reviewRule !== void 0 && (typeof reviewRule !== "string" || !reviewRuleSupported(reviewRule))) {
3972
+ return { error: rpcError(E.PARAMS, "review_rule must be any_one_approves, all_approve or quorum:<n>") };
3973
+ }
3205
3974
  const id = p.workspace || this.ids.workspaceId();
3206
- if (typeof id !== "string") return { error: rpcError(E.PARAMS, "workspace must be a string id") };
3207
3975
  if (this.workspaces.has(id)) return { error: rpcError(E.PARAMS, `workspace already exists: ${id}`) };
3208
- const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
3209
- for (const [profile, enforced, option] of [
3210
- ["security-signed/1.0", !!this.options.requireSignatures, "requireSignatures"],
3211
- ["identity-oidc/1.0", this.options.verifyOidcToken !== void 0, "verifyOidcToken"]
3212
- ]) {
3213
- const name = profile.split("/")[0];
3214
- const advertised = profiles.some((x) => x === name || x.startsWith(name + "/"));
3215
- if (enforced && !advertised) profiles.push(profile);
3216
- else if (advertised && !enforced) {
3217
- return { error: rpcError(
3218
- E.PARAMS,
3219
- `${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.`
3220
- ) };
3221
- }
3976
+ if (Array.isArray(p.profiles)) {
3977
+ const unknown = profilesError(p.profiles);
3978
+ if (unknown) return unknown;
3222
3979
  }
3980
+ const profiles = Array.isArray(p.profiles) ? [...p.profiles] : [...this.options.defaultProfiles ?? ["core/1.0", "review/1.0"]];
3981
+ const signed = profiles.some((x) => x === "security-signed" || x.startsWith("security-signed/"));
3982
+ if (this.options.requireSignatures && !signed) profiles.push("security-signed/1.0");
3983
+ const oidc = profiles.some((x) => x === "identity-oidc" || x.startsWith("identity-oidc/"));
3984
+ if (this.options.verifyOidcToken !== void 0 && !oidc) profiles.push("identity-oidc/1.0");
3985
+ const vc = profiles.some((x) => x === "identity-vc" || x.startsWith("identity-vc/"));
3986
+ if (this.options.verifyVc !== void 0 && !vc) profiles.push("identity-vc/1.0");
3987
+ const unverified = this.unverifiedIdentity(profiles);
3988
+ if (unverified) return { error: unverified };
3223
3989
  const chainEnabled = profiles.includes("audit-scitt/1.0") || !!this.options.enableChain;
3224
3990
  const ws = {
3225
3991
  id,
@@ -3228,9 +3994,14 @@ var Coordinator = class {
3228
3994
  profiles,
3229
3995
  mode: p.mode ?? "trial",
3230
3996
  mode_ceiling: p.mode_ceiling ?? "production",
3231
- routing_policy_uri: p.routing_policy_uri,
3232
- step_up_window_sec: typeof p.step_up_window_sec === "number" ? p.step_up_window_sec : 300,
3233
- min_acr: p.min_acr,
3997
+ // A param given as null gives no value, as in the Python reference: a
3998
+ // null min_acr sets no minimum.
3999
+ routing_policy_uri: p.routing_policy_uri ?? void 0,
4000
+ step_up_window_sec: p.step_up_window_sec ?? 300,
4001
+ min_acr: p.min_acr ?? void 0,
4002
+ signature_policy: policy,
4003
+ join_policy: joinPolicy,
4004
+ ...reviewRule !== void 0 ? { review_rule: reviewRule } : {},
3234
4005
  members: this.newMap(),
3235
4006
  tasks: this.newMap(),
3236
4007
  overrides: this.newMap(),
@@ -3239,14 +4010,17 @@ var Coordinator = class {
3239
4010
  handoffs: this.newMap(),
3240
4011
  snapshots: this.newMap(),
3241
4012
  route_decisions: this.newMap(),
4013
+ invitations: this.newMap(),
3242
4014
  idempotency_keys: {},
3243
4015
  audit: [],
3244
4016
  chain_enabled: chainEnabled,
3245
4017
  chain_head: chainEnabled ? ZERO_HASH : void 0
3246
4018
  };
3247
- this.workspaces.set(id, ws);
4019
+ return { ws };
4020
+ }
4021
+ addWorkspace(ws) {
4022
+ this.workspaces.set(ws.id, ws);
3248
4023
  this.persisted.set(ws, { seq: 0, replace: false, keys: /* @__PURE__ */ new Set() });
3249
- return { result: { workspace: id, created: ws.created } };
3250
4024
  }
3251
4025
  opWorkspaceDescribe(p) {
3252
4026
  const miss = missing(p, ["workspace"]);
@@ -3265,6 +4039,9 @@ var Coordinator = class {
3265
4039
  mode_ceiling: ws.mode_ceiling,
3266
4040
  max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
3267
4041
  step_up_window_sec: ws.step_up_window_sec,
4042
+ signature_policy: ws.signature_policy,
4043
+ join_policy: ws.join_policy,
4044
+ review_rule: ws.review_rule ?? "any_one_approves",
3268
4045
  profiles: ws.profiles,
3269
4046
  members: Array.from(rawValues(ws.members)).map(memberToDict),
3270
4047
  audit_count: ws.audit.length,
@@ -3280,6 +4057,11 @@ var Coordinator = class {
3280
4057
  opWorkspaceSetProfiles(p) {
3281
4058
  const miss = missing(p, ["workspace", "from", "profiles"]);
3282
4059
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4060
+ if (!Array.isArray(p.profiles) || !p.profiles.every((x) => typeof x === "string")) {
4061
+ return { error: rpcError(E.PARAMS, "profiles must be a list of strings") };
4062
+ }
4063
+ const unknown = profilesError(p.profiles);
4064
+ if (unknown) return unknown;
3283
4065
  const ws = this.workspaces.get(p.workspace);
3284
4066
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3285
4067
  const notMember = this.requireMember(ws, p.from);
@@ -3288,6 +4070,14 @@ var Coordinator = class {
3288
4070
  return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
3289
4071
  }
3290
4072
  const newProfiles = [...p.profiles];
4073
+ for (const name of LASTING_PROFILES) {
4074
+ const held = ws.profiles.find((x) => x === name || x.startsWith(name + "/"));
4075
+ if (held !== void 0 && !newProfiles.some((x) => x.startsWith(name + "/"))) {
4076
+ return { error: rpcError(E.NOT_AUTHORISED, `${held} cannot be removed once advertised`) };
4077
+ }
4078
+ }
4079
+ const unverified = this.unverifiedIdentity(newProfiles);
4080
+ if (unverified) return { error: unverified };
3291
4081
  if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
3292
4082
  ws.profiles = newProfiles;
3293
4083
  if (newProfiles.includes("audit-scitt/1.0") && !ws.chain_enabled) {
@@ -3299,20 +4089,76 @@ var Coordinator = class {
3299
4089
  opParticipantJoin(p) {
3300
4090
  const miss = missing(p, ["workspace", "from", "type"]);
3301
4091
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4092
+ for (const f of ["from", "type"]) {
4093
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
4094
+ }
4095
+ const type = p.type;
4096
+ const named = participantError(type, p.from, "from");
4097
+ if (named) return named;
4098
+ const bad = paramsError(p, [
4099
+ ["role", "string"],
4100
+ ["display_name", "string"],
4101
+ ["scopes", "strings"],
4102
+ ["capabilities", "object"],
4103
+ ["jwks", "object"],
4104
+ ["jwks.keys", "list"],
4105
+ ["oidc_token", "string"],
4106
+ ["vc_presentation", "object"]
4107
+ ]);
4108
+ if (bad) return bad;
4109
+ if (p.role === "admin" && !ADMIN_TYPES.includes(type)) {
4110
+ return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
4111
+ }
3302
4112
  let ws = this.workspaces.get(p.workspace);
4113
+ const made = ws === void 0;
3303
4114
  if (!ws) {
3304
- const create = this.opWorkspaceCreate({ workspace: p.workspace });
3305
- if (create.error) return create;
3306
- ws = this.workspaces.get(p.workspace);
4115
+ const built = this.buildWorkspace({ workspace: p.workspace });
4116
+ if (built.error) return { error: built.error };
4117
+ ws = built.ws;
3307
4118
  }
3308
4119
  const uri = p.from;
3309
4120
  const now = this.now();
3310
4121
  const existing = ws.members.get(uri);
3311
4122
  let tokenVerified = false;
4123
+ let holderVerified = false;
4124
+ let credentialBound = false;
4125
+ const invitation = existing ? void 0 : rawInvitation(ws, uri);
4126
+ const asked = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
4127
+ let role = asked ?? "participant";
4128
+ if (!existing) {
4129
+ const first = awaitsFirstMember(ws);
4130
+ if (ws.join_policy === "invite" && !invitation && !(first && ADMIN_TYPES.includes(type))) {
4131
+ return { error: rpcError(E.NOT_AUTHORISED, `${uri} has no invitation to this workspace`) };
4132
+ }
4133
+ if (invitation?.role !== void 0) {
4134
+ if (asked === "admin" && invitation.role !== "admin") {
4135
+ return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
4136
+ }
4137
+ role = invitation.role;
4138
+ } else if (first && ws.join_policy === "invite") {
4139
+ if (asked !== void 0 && asked !== "admin") {
4140
+ return { error: rpcError(
4141
+ E.PARAMS,
4142
+ "The first member of a workspace that admits by invitation is its admin"
4143
+ ) };
4144
+ }
4145
+ role = "admin";
4146
+ } else if (asked === "admin" && !first) {
4147
+ return { error: rpcError(E.NOT_AUTHORISED, "The admin role is granted by an admin") };
4148
+ }
4149
+ }
4150
+ if (type === "human") {
4151
+ const oidc = this.options.verifyOidcToken !== void 0 && advertises(ws, "identity-oidc");
4152
+ const vc = this.options.verifyVc !== void 0 && advertises(ws, "identity-vc");
4153
+ const presented = oidc && typeof p.oidc_token === "string" || vc && isObject(p.vc_presentation);
4154
+ if ((oidc || vc) && !presented) {
4155
+ 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") };
4156
+ }
4157
+ }
3312
4158
  const member = {
3313
4159
  uri,
3314
- type: p.type,
3315
- role: p.role || "participant",
4160
+ type,
4161
+ role,
3316
4162
  joined: now,
3317
4163
  display_name: p.display_name,
3318
4164
  capabilities: p.capabilities,
@@ -3320,6 +4166,9 @@ var Coordinator = class {
3320
4166
  keys: [],
3321
4167
  paused: false
3322
4168
  };
4169
+ if (invitation?.oidc_sub !== void 0 && (this.options.verifyOidcToken === void 0 || typeof p.oidc_token !== "string")) {
4170
+ return { error: rpcError(E.OIDC_TOKEN_INVALID, "An oidc_token is required: the invitation names a token subject") };
4171
+ }
3323
4172
  if (this.options.verifyOidcToken && typeof p.oidc_token === "string") {
3324
4173
  const claims = this.options.verifyOidcToken(p.oidc_token);
3325
4174
  if (claims === null) return { error: rpcError(E.OIDC_TOKEN_INVALID, "OIDC token invalid") };
@@ -3328,6 +4177,9 @@ var Coordinator = class {
3328
4177
  if (boundUri != null && boundUri !== uri) {
3329
4178
  return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token is bound to another participant") };
3330
4179
  }
4180
+ if (invitation?.oidc_sub !== void 0 && sub !== invitation.oidc_sub) {
4181
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token subject does not match the invitation") };
4182
+ }
3331
4183
  if (existing) {
3332
4184
  if (existing.oidc_sub != null) {
3333
4185
  if (sub !== existing.oidc_sub) {
@@ -3336,19 +4188,23 @@ var Coordinator = class {
3336
4188
  } else if (boundUri !== uri) {
3337
4189
  return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this member") };
3338
4190
  }
4191
+ } else if (type === "human" && boundUri !== uri && invitation?.oidc_sub === void 0) {
4192
+ return { error: rpcError(E.OIDC_CNF_MISMATCH, "OIDC token does not name this participant") };
3339
4193
  }
3340
4194
  member.oidc_sub = sub;
3341
4195
  tokenVerified = true;
3342
4196
  const at = claims.auth_time;
3343
- if (typeof at === "number") member.oidc_auth_time = at;
4197
+ if (typeof at === "number" && Number.isFinite(at) && Math.abs(at) <= Number.MAX_SAFE_INTEGER) member.oidc_auth_time = Math.floor(at);
3344
4198
  member.oidc_acr = typeof claims.acr === "string" ? claims.acr : void 0;
3345
- const cnf = claims.cnf;
3346
- const cnfJwk = cnf?.jwk;
3347
- if (cnfJwk && typeof cnfJwk.kid === "string") {
3348
- member.keys.push({ jwk: cnfJwk, kid: cnfJwk.kid, valid_from: now });
4199
+ const cnf = isObject(claims.cnf) ? claims.cnf.jwk : void 0;
4200
+ const bound = credentialKeys(cnf, cnf == null ? claims.nonce : void 0, signingKeys(p.jwks));
4201
+ if (bound === null) return { error: rpcError(E.OIDC_CNF_MISMATCH, "The join's keys are not the key the token binds") };
4202
+ if (bound !== void 0) {
4203
+ credentialBound = true;
4204
+ for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
3349
4205
  }
3350
4206
  }
3351
- if (this.options.verifyVc && typeof p.vc_presentation === "object" && p.vc_presentation !== null && !Array.isArray(p.vc_presentation)) {
4207
+ if (this.options.verifyVc && isObject(p.vc_presentation)) {
3352
4208
  const subject = this.options.verifyVc(p.vc_presentation);
3353
4209
  if (subject === null) return { error: rpcError(E.VC_VP_INVALID, "VC presentation invalid") };
3354
4210
  const holder = [subject.holder, subject.id].find(
@@ -3358,21 +4214,54 @@ var Coordinator = class {
3358
4214
  return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "Presentation holder does not match the member") };
3359
4215
  }
3360
4216
  member.vc_holder = holder;
3361
- const vpJwk = subject.cnf_jwk;
3362
- if (vpJwk && typeof vpJwk.kid === "string") {
3363
- member.keys.push({ jwk: vpJwk, kid: vpJwk.kid, valid_from: now });
4217
+ holderVerified = true;
4218
+ const bound = credentialKeys(subject.cnf_jwk, void 0, signingKeys(p.jwks));
4219
+ if (bound === null) {
4220
+ return { error: rpcError(E.VC_HOLDER_BINDING_INVALID, "The join's keys are not the key the presentation binds") };
4221
+ }
4222
+ if (bound !== void 0) {
4223
+ credentialBound = true;
4224
+ for (const j of bound) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
3364
4225
  }
3365
4226
  }
4227
+ if (!existing && type === "human" && (tokenVerified || holderVerified) && !credentialBound && !invitation?.jwks && signingKeys(p.jwks).length > 0) {
4228
+ return { error: tokenVerified ? rpcError(E.OIDC_CNF_MISMATCH, "The token binds no key: a person it verifies signs with a key it binds") : rpcError(
4229
+ E.VC_HOLDER_BINDING_INVALID,
4230
+ "The presentation binds no key: a person it verifies signs with a key it binds"
4231
+ ) };
4232
+ }
4233
+ const pinned = invitation?.jwks?.keys;
4234
+ if (pinned) {
4235
+ const strays = [...signingKeys(p.jwks), ...member.keys.map((k) => k.jwk)].filter((j) => !pinned.some((k) => sameKey(k, j)));
4236
+ if (strays.length) {
4237
+ return { error: rpcError(
4238
+ E.NOT_AUTHORISED,
4239
+ `${uri} joins with keys its invitation does not name`,
4240
+ { kids: [...new Set(strays.map((j) => j.kid))] }
4241
+ ) };
4242
+ }
4243
+ member.keys = pinned.map((j) => ({ jwk: j, kid: j.kid, valid_from: now }));
4244
+ }
3366
4245
  const attested = [...member.keys];
3367
- const jwks = p.jwks;
3368
- if (jwks?.keys && attested.length === 0) {
3369
- for (const j of jwks.keys) {
3370
- if (j.kid && !member.keys.some((k) => k.kid === j.kid)) {
4246
+ if (attested.length === 0) {
4247
+ for (const j of signingKeys(p.jwks)) {
4248
+ if (!member.keys.some((k) => k.kid === j.kid)) {
3371
4249
  member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
3372
4250
  }
3373
4251
  }
3374
4252
  }
3375
4253
  if (existing) {
4254
+ if (attested.length === 0) {
4255
+ const keys = signingKeys(p.jwks);
4256
+ const held = keys.some((j) => existing.keys.some((k) => sameKey(k.jwk, j)));
4257
+ if (keys.length > 0 && !held) {
4258
+ return { error: rpcError(
4259
+ E.NOT_AUTHORISED,
4260
+ `${uri} is already a member, with other keys; a new key arrives through participant.rotate_key`,
4261
+ { kids: [...new Set(keys.map((j) => j.kid))] }
4262
+ ) };
4263
+ }
4264
+ }
3376
4265
  if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
3377
4266
  if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
3378
4267
  if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
@@ -3383,18 +4272,126 @@ var Coordinator = class {
3383
4272
  }
3384
4273
  return { result: { joined: true, as: uri } };
3385
4274
  }
4275
+ if (this.requiresSignatures(ws) && this.mustSign(ws, type) && member.keys.length === 0) {
4276
+ return { error: rpcError(E.PARAMS, `${uri} must sign in this workspace, and joins with no key`) };
4277
+ }
4278
+ if (made) this.addWorkspace(ws);
3386
4279
  ws.members.set(uri, member);
4280
+ if (invitation) ws.invitations.delete(uri);
3387
4281
  return { result: { joined: true, as: uri } };
3388
4282
  }
4283
+ /**
4284
+ * SPECIFICATION 6.3.3: an admin invites a participant. For a URI that is
4285
+ * not a member it records an invitation, which the invitee's join takes
4286
+ * up. For a member that holds no key in force it registers the
4287
+ * invitation's keys: the way back for a member whose keys were all revoked,
4288
+ * or who joined a workspace that requires signatures with none.
4289
+ */
4290
+ opWorkspaceInvite(p) {
4291
+ const miss = missing(p, ["workspace", "from", "uri", "type"]);
4292
+ if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4293
+ for (const f of ["uri", "type"]) {
4294
+ if (typeof p[f] !== "string") return { error: rpcError(E.PARAMS, `${f} must be a string`) };
4295
+ }
4296
+ const bad = paramsError(p, [
4297
+ ["role", "string"],
4298
+ ["jwks", "object"],
4299
+ ["jwks.keys", "list"],
4300
+ ["oidc_sub", "string"]
4301
+ ]);
4302
+ if (bad) return bad;
4303
+ const ws = this.workspaces.get(p.workspace);
4304
+ if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
4305
+ const notMember = this.requireMember(ws, p.from);
4306
+ if (notMember) return notMember;
4307
+ if (rawMember(ws, p.from).role !== "admin") {
4308
+ return { error: rpcError(E.NOT_AUTHORISED, "workspace.invite requires the admin role") };
4309
+ }
4310
+ const uri = p.uri;
4311
+ const type = p.type;
4312
+ const named = participantError(type, uri, "uri");
4313
+ if (named) return named;
4314
+ const role = typeof p.role === "string" && p.role !== "" ? p.role : void 0;
4315
+ if (role === "admin" && !ADMIN_TYPES.includes(type)) {
4316
+ return { error: rpcError(E.PARAMS, "The admin role is held by a human or a service") };
4317
+ }
4318
+ const sub = typeof p.oidc_sub === "string" ? p.oidc_sub : void 0;
4319
+ if (sub !== void 0 && this.options.verifyOidcToken === void 0) {
4320
+ return { error: rpcError(E.PARAMS, "oidc_sub names a token subject, and this Coordinator has no token verifier") };
4321
+ }
4322
+ let pinned;
4323
+ if (p.jwks != null) {
4324
+ pinned = signingKeys(p.jwks);
4325
+ if (pinned.length === 0) return { error: rpcError(E.PARAMS, "jwks names no key: each key needs a kid") };
4326
+ }
4327
+ const now = this.now();
4328
+ const member = ws.members.get(uri);
4329
+ if (member) {
4330
+ if (member.keys.some((k) => inForce(k, now))) {
4331
+ return { error: rpcError(
4332
+ E.PARAMS,
4333
+ `${uri} is a member and holds a key in force; its keys change through participant.rotate_key`
4334
+ ) };
4335
+ }
4336
+ if (!pinned) {
4337
+ return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers the keys its jwks names`) };
4338
+ }
4339
+ 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)) {
4340
+ return { error: rpcError(
4341
+ E.PARAMS,
4342
+ `${uri} registers a new key by joining again with a token that binds it; an invitation does not register a person's keys here`
4343
+ ) };
4344
+ }
4345
+ if (role !== void 0 && role !== member.role || sub !== void 0 && sub !== member.oidc_sub) {
4346
+ return { error: rpcError(E.PARAMS, `${uri} is a member; an invitation for a member registers keys and changes nothing else`) };
4347
+ }
4348
+ const reused = pinned.find((j) => member.keys.some((k) => k.kid === j.kid));
4349
+ if (reused) return { error: rpcError(E.PARAMS, `Key ${reused.kid} was ${uri}'s; a new key takes a new kid`) };
4350
+ for (const j of pinned) member.keys.push({ jwk: j, kid: j.kid, valid_from: now });
4351
+ return { result: { invited: true, uri, registered: pinned.map((j) => j.kid) } };
4352
+ }
4353
+ if (pinned && (type === "human" || sub !== void 0) && (this.options.verifyOidcToken !== void 0 || this.options.verifyVc !== void 0)) {
4354
+ return { error: rpcError(
4355
+ E.PARAMS,
4356
+ `${uri} joins with a token that binds its key; an invitation does not register a person's keys here`
4357
+ ) };
4358
+ }
4359
+ const invitation = {
4360
+ uri,
4361
+ type,
4362
+ invited_by: p.from,
4363
+ invited_at: now
4364
+ };
4365
+ if (role !== void 0) invitation.role = role;
4366
+ if (pinned) invitation.jwks = { keys: pinned };
4367
+ if (sub !== void 0) invitation.oidc_sub = sub;
4368
+ ws.invitations.set(uri, invitation);
4369
+ return { result: { invited: true, uri } };
4370
+ }
3389
4371
  opParticipantLeave(p) {
3390
4372
  const ws = this.workspaces.get(p.workspace);
3391
4373
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
4374
+ if (rawMember(ws, p.from)?.paused) {
4375
+ return { error: rpcError(E.NOT_AUTHORISED, "A paused participant does not leave until another member lifts its pause") };
4376
+ }
3392
4377
  ws.members.delete(p.from);
3393
4378
  return { result: { left: true } };
3394
4379
  }
3395
4380
  opTaskCreate(p) {
3396
4381
  const miss = missing(p, ["workspace", "from", "kind", "input"]);
3397
4382
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4383
+ const bad = paramsError(p, [
4384
+ ["assignee", "string"],
4385
+ ["to", "string"],
4386
+ ["mode", "string"],
4387
+ ["review_required", "boolean"],
4388
+ ["routing_hints", "object"],
4389
+ ["idempotency_key", "string"]
4390
+ ]);
4391
+ if (bad) return bad;
4392
+ if (RESERVED_KEYS.includes(p.idempotency_key)) {
4393
+ return { error: rpcError(E.PARAMS, "idempotency_key must not be __proto__, constructor or prototype") };
4394
+ }
3398
4395
  const ws = this.workspaces.get(p.workspace);
3399
4396
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3400
4397
  const notMember = this.requireMember(ws, p.from);
@@ -3406,9 +4403,6 @@ var Coordinator = class {
3406
4403
  if (ws.members.get(assignee).paused) {
3407
4404
  return { error: rpcError(E.CONTROL_WORKSPACE_PAUSED, `Assignee ${assignee} is paused`) };
3408
4405
  }
3409
- if (p.mode !== void 0 && p.mode !== null && typeof p.mode !== "string") {
3410
- return { error: rpcError(E.PARAMS, "mode must be a string") };
3411
- }
3412
4406
  const requestedMode = p.mode || ws.mode;
3413
4407
  if (!modeLE(requestedMode, ws.mode_ceiling)) {
3414
4408
  return { error: rpcError(
@@ -3416,9 +4410,6 @@ var Coordinator = class {
3416
4410
  `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
3417
4411
  ) };
3418
4412
  }
3419
- if (p.review_required !== void 0 && p.review_required !== null && typeof p.review_required !== "boolean") {
3420
- return { error: rpcError(E.PARAMS, "review_required must be a boolean") };
3421
- }
3422
4413
  const taskId = this.ids.taskId();
3423
4414
  const now = this.now();
3424
4415
  const task = {
@@ -3457,6 +4448,7 @@ var Coordinator = class {
3457
4448
  if (notMember) return notMember;
3458
4449
  const task = ws.tasks.get(p.task_id);
3459
4450
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
4451
+ if (typeof p.state !== "string") return { error: rpcError(E.PARAMS, "state must be a string") };
3460
4452
  const newState = p.state;
3461
4453
  const legal = {
3462
4454
  created: ["in_progress", "declined"],
@@ -3467,12 +4459,42 @@ var Coordinator = class {
3467
4459
  if (!legal[task.state]?.includes(newState)) {
3468
4460
  return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
3469
4461
  }
3470
- if (newState === "completed" && task.review_required) {
4462
+ if (newState === "completed" && reviewRequired(ws, task)) {
3471
4463
  return { error: rpcError(
3472
4464
  E.PARAMS,
3473
4465
  "This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
3474
4466
  ) };
3475
4467
  }
4468
+ if (newState === "completed" && task.review) {
4469
+ return { error: rpcError(
4470
+ E.PARAMS,
4471
+ "This task holds a review. Submit the output with task.complete, which opens a new round; a reviewer decision completes it."
4472
+ ) };
4473
+ }
4474
+ if (task.state === "review_requested" && !task.review.authors.includes(p.from)) {
4475
+ return { error: rpcError(
4476
+ E.NOT_AUTHORISED,
4477
+ "Only the authors of the work under review take it back from review"
4478
+ ) };
4479
+ }
4480
+ if (newState === "cancelled" && openRequiredReviewAuthors(ws, task).includes(p.from)) {
4481
+ return { error: rpcError(
4482
+ E.NOT_AUTHORISED,
4483
+ "The authors of the work under review cannot end its review without a decision"
4484
+ ) };
4485
+ }
4486
+ if (newState === "declined" && task.review && !task.review.decisions.some((d) => d.kind === "reject")) {
4487
+ return { error: rpcError(
4488
+ E.PARAMS,
4489
+ "This task holds a review no decision has closed; a reviewer's decision ends it"
4490
+ ) };
4491
+ }
4492
+ if (newState === "declined" && !task.review && openRequiredReviewAuthors(ws, task).length > 0) {
4493
+ return { error: rpcError(
4494
+ E.PARAMS,
4495
+ "This task carries on a review no decision has closed; completing it opens the review a reviewer decides"
4496
+ ) };
4497
+ }
3476
4498
  task.state = newState;
3477
4499
  task.updated_at = this.now();
3478
4500
  task.history.push({
@@ -3493,42 +4515,60 @@ var Coordinator = class {
3493
4515
  if (task.state !== "created" && task.state !== "in_progress") {
3494
4516
  return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
3495
4517
  }
3496
- if (task.review_required) {
4518
+ if (p.confidence != null && typeof p.confidence !== "number" && typeof p.confidence !== "string") {
4519
+ return { error: rpcError(E.PARAMS, "confidence must be a number or a string") };
4520
+ }
4521
+ const required = reviewRequired(ws, task);
4522
+ if (required && p.from !== task.assignee) {
4523
+ return { error: rpcError(E.NOT_AUTHORISED, "Only the assignee completes a task that requires review") };
4524
+ }
4525
+ if (required || task.review) {
3497
4526
  const now = this.now();
4527
+ const authors = authorsOf(task, p.from);
4528
+ let note = OPENED_ON_COMPLETE;
3498
4529
  if (!task.review) {
3499
- const producer = p.from;
3500
- const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
4530
+ const eligible = [...rawEntries(ws.members)].filter(([uri, member]) => !authors.includes(uri) && member.type === "human").map(([uri]) => uri);
3501
4531
  if (eligible.length === 0) {
3502
4532
  return { error: rpcError(
3503
4533
  E.NOT_AUTHORISED,
3504
4534
  "Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
3505
4535
  ) };
3506
4536
  }
3507
- task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
4537
+ task.review = {
4538
+ requested_at: now,
4539
+ requested_to: eligible,
4540
+ rule: ws.review_rule ?? "any_one_approves",
4541
+ decisions: [],
4542
+ authors,
4543
+ acknowledgements: []
4544
+ };
3508
4545
  } else {
3509
- const author = p.from;
3510
- const reviewers = task.review.requested_to.filter((r) => r !== author);
4546
+ const roundAuthors = unionOf(task.review.authors, authors);
4547
+ const reviewers = task.review.requested_to.filter((r) => !roundAuthors.includes(r) && !(required && rawMember(ws, r) !== void 0 && rawMember(ws, r).type !== "human"));
3511
4548
  if (reviewers.length === 0) {
3512
4549
  return { error: rpcError(
3513
4550
  E.NOT_AUTHORISED,
3514
- "Task requires review but has no reviewer other than its completer"
4551
+ "No reviewer of this task remains once the authors of the work are left out"
3515
4552
  ) };
3516
4553
  }
3517
- task.review = { ...task.review, requested_at: now, requested_to: reviewers, decisions: [] };
4554
+ task.review = {
4555
+ ...task.review,
4556
+ requested_at: now,
4557
+ requested_to: reviewers,
4558
+ decisions: [],
4559
+ authors: roundAuthors,
4560
+ acknowledgements: []
4561
+ };
4562
+ note = NEW_ROUND_ON_COMPLETE;
3518
4563
  }
3519
4564
  task.pending_artefact = p.output;
3520
4565
  task.state = "review_requested";
3521
4566
  task.updated_at = now;
3522
- task.history.push({
3523
- ts: now,
3524
- from: p.from,
3525
- state: "review_requested",
3526
- note: "review required; opened on task.complete"
3527
- });
4567
+ task.history.push({ ts: now, from: p.from, state: "review_requested", note });
3528
4568
  return { result: { state: "review_requested", review_id: task.id } };
3529
4569
  }
3530
4570
  task.output = p.output;
3531
- if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
4571
+ if (p.confidence != null) task.confidence = p.confidence;
3532
4572
  task.state = "completed";
3533
4573
  task.updated_at = this.now();
3534
4574
  task.history.push({ ts: task.updated_at, from: p.from, state: "completed" });
@@ -3541,8 +4581,16 @@ var Coordinator = class {
3541
4581
  const notMember = this.requireMember(ws, p.from);
3542
4582
  if (notMember) return notMember;
3543
4583
  }
3544
- const range = p.range ?? {};
3545
- const filter = p.filter ?? {};
4584
+ const bad = paramsError(p, [
4585
+ ["range.from_seq", "integer"],
4586
+ ["range.to_seq", "integer"],
4587
+ ["filter.method", "string"],
4588
+ ["filter.from", "string"],
4589
+ ["filter.task_id", "string"]
4590
+ ]);
4591
+ if (bad) return bad;
4592
+ const range = isObject(p.range) ? p.range : {};
4593
+ const filter = isObject(p.filter) ? p.filter : {};
3546
4594
  if (filter.outcome != null && filter.outcome !== "accepted" && filter.outcome !== "refused") {
3547
4595
  return { error: rpcError(E.PARAMS, "filter.outcome must be 'accepted' or 'refused'") };
3548
4596
  }
@@ -3593,37 +4641,73 @@ var Coordinator = class {
3593
4641
  return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
3594
4642
  }
3595
4643
  const to = p.to;
4644
+ if (to != null && typeof to !== "string" && !(Array.isArray(to) && to.every((r) => typeof r === "string"))) {
4645
+ return { error: rpcError(E.PARAMS, "to must be a string or a list of strings") };
4646
+ }
3596
4647
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
3597
4648
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
4649
+ const bad = paramsError(p, [["rule", "string"]]);
4650
+ if (bad) return bad;
3598
4651
  const now = this.now();
3599
4652
  const declaredRule = p.rule;
3600
4653
  const rule = declaredRule || "any_one_approves";
3601
4654
  if (!reviewRuleSupported(rule)) {
3602
4655
  return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
3603
4656
  }
3604
- if (task.state === "review_requested" && task.review) {
3605
- if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
4657
+ const open = task.state === "review_requested" ? task.review : void 0;
4658
+ if (open) {
4659
+ if (contentHash(task.pending_artefact ?? null) !== contentHash(p.artefact ?? null)) {
3606
4660
  return { error: rpcError(
3607
4661
  E.REVIEW_ALREADY_OPEN,
3608
4662
  "A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
3609
4663
  ) };
3610
4664
  }
3611
- if (declaredRule !== void 0 && declaredRule !== task.review.rule) {
4665
+ if (declaredRule != null && declaredRule !== open.rule) {
3612
4666
  return { error: rpcError(
3613
4667
  E.REVIEW_ALREADY_OPEN,
3614
- `Cannot change the decision rule of an open review (currently ${task.review.rule})`
4668
+ `Cannot change the decision rule of an open review (currently ${open.rule})`
4669
+ ) };
4670
+ }
4671
+ }
4672
+ const authors = open ? open.authors : unionOf(task.review ? task.review.authors : [], authorsOf(task, lastCompleter(task)));
4673
+ if (reviewRequired(ws, task)) {
4674
+ if (!open) {
4675
+ return { error: rpcError(
4676
+ E.NOT_AUTHORISED,
4677
+ "This task requires a person's review, which opens on task.complete; review.request only adds reviewers to it once it is open"
4678
+ ) };
4679
+ }
4680
+ if (authors.includes(p.from)) {
4681
+ return { error: rpcError(E.NOT_AUTHORISED, "The author of the work under review cannot request its review") };
4682
+ }
4683
+ const scope = reviewers.find(isScope);
4684
+ if (scope !== void 0) {
4685
+ return { error: rpcError(
4686
+ E.PARAMS,
4687
+ `A review that requires a person names its reviewers, and ${scope} is an address scope`
3615
4688
  ) };
3616
4689
  }
3617
4690
  for (const r of reviewers) {
3618
- if (!task.review.requested_to.includes(r)) task.review.requested_to.push(r);
4691
+ if (authors.includes(r)) {
4692
+ return { error: rpcError(E.NOT_AUTHORISED, `The author of the work under review cannot review it: ${r}`) };
4693
+ }
4694
+ const member = rawMember(ws, r);
4695
+ if (member && member.type !== "human") {
4696
+ return { error: rpcError(E.NOT_AUTHORISED, `This task requires a person's review: ${r} is not a human member`) };
4697
+ }
3619
4698
  }
3620
- if (p.deadline !== void 0) task.review.deadline = p.deadline;
4699
+ }
4700
+ if (open) {
4701
+ for (const r of reviewers) {
4702
+ if (!open.requested_to.includes(r)) open.requested_to.push(r);
4703
+ }
4704
+ if (p.deadline != null) open.deadline = p.deadline;
3621
4705
  task.updated_at = now;
3622
4706
  return { result: {
3623
4707
  state: "review_requested",
3624
4708
  review_id: task.id,
3625
4709
  amended: true,
3626
- requested_to: [...task.review.requested_to]
4710
+ requested_to: [...open.requested_to]
3627
4711
  } };
3628
4712
  }
3629
4713
  task.state = "review_requested";
@@ -3633,12 +4717,41 @@ var Coordinator = class {
3633
4717
  requested_to: reviewers,
3634
4718
  rule,
3635
4719
  deadline: p.deadline,
3636
- decisions: []
4720
+ decisions: [],
4721
+ authors,
4722
+ acknowledgements: []
3637
4723
  };
3638
4724
  task.history.push({ ts: now, from: p.from, state: "review_requested" });
3639
4725
  task.pending_artefact = p.artefact;
3640
4726
  return { result: { state: "review_requested", review_id: task.id } };
3641
4727
  }
4728
+ /**
4729
+ * review/1.0 S3.6: a reviewer says they have begun the open review. Only
4730
+ * someone who may decide it can, so the log's record of who took a review
4731
+ * up, and when, names reviewers alone. It changes nothing else: the review
4732
+ * stays open and the acknowledgement counts towards no rule.
4733
+ */
4734
+ opReviewAcknowledge(p) {
4735
+ const miss = missing(p, ["workspace", "from", "task_id"]);
4736
+ if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4737
+ const ws = this.workspaces.get(p.workspace);
4738
+ if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
4739
+ const notMember = this.requireMember(ws, p.from);
4740
+ if (notMember) return notMember;
4741
+ const task = ws.tasks.get(p.task_id);
4742
+ if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
4743
+ if (task.state !== "review_requested") {
4744
+ return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
4745
+ }
4746
+ const notReviewer = this.requireReviewer(ws, task, p.from);
4747
+ if (notReviewer) return notReviewer;
4748
+ const review = task.review;
4749
+ if (review.acknowledgements.some((a) => a.reviewer === p.from)) {
4750
+ return { error: rpcError(E.PARAMS, "Already acknowledged in this round") };
4751
+ }
4752
+ review.acknowledgements.push({ reviewer: p.from, ts: this.now() });
4753
+ return { result: { acknowledged: true, requested_at: review.requested_at } };
4754
+ }
3642
4755
  /**
3643
4756
  * Verify an optional `approved_artefact_digest` against the artefact under
3644
4757
  * review (review/1.0 S3.2).
@@ -3657,7 +4770,7 @@ var Coordinator = class {
3657
4770
  if (typeof declared !== "string") {
3658
4771
  return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
3659
4772
  }
3660
- if (task.pending_artefact === void 0) {
4773
+ if (task.pending_artefact === void 0 || task.pending_artefact === null) {
3661
4774
  return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
3662
4775
  }
3663
4776
  const actual = contentHash(task.pending_artefact);
@@ -3673,8 +4786,8 @@ var Coordinator = class {
3673
4786
  opDecide(p, kind) {
3674
4787
  const miss = missing(p, ["workspace", "from", "task_id"]);
3675
4788
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
3676
- const badTags = tagsError(p);
3677
- if (badTags) return badTags;
4789
+ const bad = paramsError(p, [["tags", "strings"], ["request_revision", "boolean"]]);
4790
+ if (bad) return bad;
3678
4791
  const ws = this.workspaces.get(p.workspace);
3679
4792
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3680
4793
  const notMember = this.requireMember(ws, p.from);
@@ -3684,7 +4797,7 @@ var Coordinator = class {
3684
4797
  if (task.state !== "review_requested") {
3685
4798
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3686
4799
  }
3687
- const notReviewer = this.requireReviewer(task, p.from);
4800
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3688
4801
  if (notReviewer) return notReviewer;
3689
4802
  const digestError = this.checkArtefactDigest(p, task);
3690
4803
  if (digestError) return digestError;
@@ -3711,8 +4824,8 @@ var Coordinator = class {
3711
4824
  opDecideOverride(p) {
3712
4825
  const miss = missing(p, ["workspace", "from", "task_id", "diff", "rationale"]);
3713
4826
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
3714
- const badTags = tagsError(p);
3715
- if (badTags) return badTags;
4827
+ const bad = paramsError(p, [["tags", "strings"], ["policy_refs", "strings"]]);
4828
+ if (bad) return bad;
3716
4829
  const ws = this.workspaces.get(p.workspace);
3717
4830
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3718
4831
  const notMember = this.requireMember(ws, p.from);
@@ -3722,12 +4835,12 @@ var Coordinator = class {
3722
4835
  if (task.state !== "review_requested") {
3723
4836
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3724
4837
  }
3725
- const notReviewer = this.requireReviewer(task, p.from);
4838
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3726
4839
  if (notReviewer) return notReviewer;
3727
4840
  const digestError = this.checkArtefactDigest(p, task);
3728
4841
  if (digestError) return digestError;
3729
4842
  const base = task.pending_artefact;
3730
- if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
4843
+ if (base === void 0 || base === null) return { error: rpcError(E.PARAMS, "No base artefact for override") };
3731
4844
  let applied;
3732
4845
  try {
3733
4846
  applied = applyJsonPatch(base, p.diff, {
@@ -3771,6 +4884,8 @@ var Coordinator = class {
3771
4884
  opAbstainDeclare(p) {
3772
4885
  const miss = missing(p, ["workspace", "from", "task_id", "reason"]);
3773
4886
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
4887
+ const bad = paramsError(p, [["category", "string"]]);
4888
+ if (bad) return bad;
3774
4889
  const ws = this.workspaces.get(p.workspace);
3775
4890
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
3776
4891
  const notMember = this.requireMember(ws, p.from);
@@ -3780,7 +4895,7 @@ var Coordinator = class {
3780
4895
  if (task.state !== "review_requested") {
3781
4896
  return { error: rpcError(E.NOT_REVIEWABLE, `Task not awaiting review: ${task.state}`) };
3782
4897
  }
3783
- const notReviewer = this.requireReviewer(task, p.from);
4898
+ const notReviewer = this.requireReviewer(ws, task, p.from);
3784
4899
  if (notReviewer) return notReviewer;
3785
4900
  const now = this.now();
3786
4901
  task.review.decisions.push({
@@ -3812,6 +4927,8 @@ var Coordinator = class {
3812
4927
  return { error: rpcError(E.PARAMS, "new_task must be an object") };
3813
4928
  }
3814
4929
  const nt = p.new_task;
4930
+ const bad = paramsError(p, [["new_task.kind", "string"]]);
4931
+ if (bad) return bad;
3815
4932
  const assignee = nt.assignee;
3816
4933
  if (typeof assignee !== "string" || !ws.members.has(assignee)) {
3817
4934
  return { error: rpcError(E.PARAMS, "Escalation assignee not in workspace") };
@@ -3846,9 +4963,10 @@ var Coordinator = class {
3846
4963
  history: [{ ts: now, from: p.from, state: "created", note: `escalated from ${orig.id}` }],
3847
4964
  paused: false
3848
4965
  };
3849
- if (orig.review_required || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
4966
+ if (orig.review_required || orig.review !== void 0 || ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") {
3850
4967
  newTask.review_required = true;
3851
4968
  }
4969
+ newTask.prior_authors = successorAuthors(orig, p.from);
3852
4970
  ws.tasks.set(newId, newTask);
3853
4971
  orig.state = "escalated";
3854
4972
  orig.updated_at = now;
@@ -3865,8 +4983,8 @@ function memberToDict(m) {
3865
4983
  joined: m.joined
3866
4984
  };
3867
4985
  if (m.display_name) out.display_name = m.display_name;
3868
- if (m.capabilities) out.capabilities = m.capabilities;
3869
- if (m.scopes) out.scopes = m.scopes;
4986
+ if (m.capabilities && Object.keys(m.capabilities).length) out.capabilities = m.capabilities;
4987
+ if (m.scopes?.length) out.scopes = m.scopes;
3870
4988
  if (m.keys.length) {
3871
4989
  out.jwks = { keys: m.keys.filter((k) => !k.revoked_at && !k.valid_until).map((k) => k.jwk) };
3872
4990
  out.key_history = m.keys;
@@ -3878,7 +4996,6 @@ function memberToDict(m) {
3878
4996
  }
3879
4997
 
3880
4998
  // src/policy.ts
3881
- var CRIT2 = { low: 0, medium: 1, high: 2, critical: 3 };
3882
4999
  function makeDefaultPolicy(seniorTarget) {
3883
5000
  const routingPolicy = (task, candidates) => {
3884
5001
  const eligible = candidates;
@@ -3896,15 +5013,8 @@ function makeDefaultPolicy(seniorTarget) {
3896
5013
  };
3897
5014
  };
3898
5015
  const reviewDepthPolicy = (_task, hints) => {
3899
- const crit = hints.criticality ?? "medium";
3900
- let conf = 1;
3901
- try {
3902
- conf = Number(hints.confidence ?? 1);
3903
- } catch {
3904
- conf = 1;
3905
- }
3906
- if (isNaN(conf)) conf = 1;
3907
- const r = CRIT2[crit] ?? 1;
5016
+ const conf = confidenceOf(hints);
5017
+ const r = criticalityOf(hints);
3908
5018
  if (r >= 3) return {
3909
5019
  depth: "full",
3910
5020
  rationale: { policy_id: "default", summary: "criticality=critical: full review" }
@@ -3929,15 +5039,8 @@ function makeDefaultPolicy(seniorTarget) {
3929
5039
  };
3930
5040
  };
3931
5041
  const escalationPolicy = (_task, hints) => {
3932
- const crit = hints.criticality ?? "medium";
3933
- let conf = 1;
3934
- try {
3935
- conf = Number(hints.confidence ?? 1);
3936
- } catch {
3937
- conf = 1;
3938
- }
3939
- if (isNaN(conf)) conf = 1;
3940
- const r = CRIT2[crit] ?? 1;
5042
+ const conf = confidenceOf(hints);
5043
+ const r = criticalityOf(hints);
3941
5044
  const escalate = r >= 3 || r >= 2 && conf < 0.6;
3942
5045
  return {
3943
5046
  escalate,
@@ -3957,6 +5060,19 @@ function wrapMcpToolCall(coord, workspace, options) {
3957
5060
  if (!workspace) throw new Error("workspace is required");
3958
5061
  if (!options.caller) throw new Error("caller is required");
3959
5062
  if (!options.tool) throw new Error("tool is required");
5063
+ if (options.reviewRequired) {
5064
+ const described = coord.dispatch({
5065
+ jsonrpc: "2.0",
5066
+ id: `wrap-${options.tool}-describe`,
5067
+ method: "workspace.describe",
5068
+ params: { workspace, from: options.caller }
5069
+ });
5070
+ if (described.error) throw new Error(`workspace.describe failed: ${JSON.stringify(described.error)}`);
5071
+ const members = described.result.members;
5072
+ if (!members.some((m) => m.type === "human" && m.uri !== options.caller)) {
5073
+ throw new Error("review_required needs a human member other than the caller to review the result; nothing was recorded");
5074
+ }
5075
+ }
3960
5076
  const inputHash = contentHash(options.args);
3961
5077
  const outputHash = contentHash(options.result);
3962
5078
  const kind = options.taskKind ?? `mcp_call:${options.tool}`;
@@ -3975,6 +5091,7 @@ function wrapMcpToolCall(coord, workspace, options) {
3975
5091
  input: options.args
3976
5092
  };
3977
5093
  if (options.routingHints) createParams.routing_hints = { ...options.routingHints };
5094
+ if (options.reviewRequired) createParams.review_required = true;
3978
5095
  const createResp = coord.dispatch({
3979
5096
  jsonrpc: "2.0",
3980
5097
  id: `wrap-${options.tool}-create`,
@@ -4004,7 +5121,7 @@ function wrapMcpToolCall(coord, workspace, options) {
4004
5121
  method: "task.complete",
4005
5122
  params: completeParams
4006
5123
  });
4007
- if (completeResp.error) throw new Error(`task.complete failed: ${JSON.stringify(completeResp.error)}`);
5124
+ if (completeResp.error) throw new Error(`task.complete failed for ${taskId}: ${JSON.stringify(completeResp.error)}`);
4008
5125
  return { task_id: taskId, input_hash: inputHash, output_hash: outputHash };
4009
5126
  }
4010
5127
  function wrapA2aMessageExchange(coord, workspace, options) {