@brightbeamai/chap-coordinator 0.2.10 → 0.2.13

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/README.md CHANGED
@@ -13,8 +13,9 @@ surface, so the two interoperate on the same JSON-RPC 2.0 wire.
13
13
  npm install @brightbeamai/chap-coordinator
14
14
  ```
15
15
 
16
- Node 20+ required. Zero external runtime dependencies; uses Node's
17
- built-in `crypto` for Ed25519 and JCS.
16
+ Node 20+ required. No required runtime dependencies; uses Node's built-in
17
+ `crypto` for Ed25519 and JCS. `better-sqlite3` is an optional dependency,
18
+ needed only for `SqliteStore`.
18
19
 
19
20
  ## Companion packages
20
21
 
@@ -85,7 +86,7 @@ console.log(resp.result); // { task_id: "tsk_...", state: "created" }
85
86
 
86
87
  | Profile | Methods (per profile spec) |
87
88
  |-----------------------|-------------------------------------------------------------------------------------------|
88
- | `core/1.0` | `workspace.create`, `workspace.describe`, `workspace.set_profiles`, `participant.join`, `participant.leave`, `task.create`, `task.update`, `task.complete`, `audit.read` |
89
+ | `core/1.0` | `workspace.create`, `workspace.describe`, `workspace.set_profiles`, `participant.join`, `participant.leave`, `task.create`, `task.update`, `task.complete`, `audit.read`; a task created with `review_required` completes only on a reviewer decision |
89
90
  | `review/1.0` | `review.request`, `decide.approve`, `decide.reject`, `decide.override`, `abstain.declare`, `escalate.raise` |
90
91
  | `whisper/1.0` | `whisper.ask`, `whisper.answer`; lapse hook via `coord.checkWhisperLapses(workspaceId, now?)` |
91
92
  | `deliberation/1.0` | `deliberate.open`, `deliberate.comment`, `deliberate.vote`, `deliberate.close` |
@@ -100,6 +101,15 @@ console.log(resp.result); // { task_id: "tsk_...", state: "created" }
100
101
 
101
102
  **39 method handlers in total**, matching the Python reference exactly.
102
103
 
104
+ **The review gate.** A task carrying `review_required` does not complete on
105
+ `task.complete`. The call opens a review, holding the submitted output as the
106
+ artefact under review, and only a `decide.*` reaches `completed`. `task.update`
107
+ cannot complete such a task either, and answers `-32602` naming the route
108
+ through. Where no reviewer was named, the set is the human members other than
109
+ the completer and the assignee; a workspace with no eligible human refuses the
110
+ completion rather than opening a review nobody can decide. Under `modes/1.0` a
111
+ trial-mode task has `review_required` set for it.
112
+
103
113
  ## Architecture
104
114
 
105
115
  The coordinator is transport-agnostic, persistence-agnostic, and
@@ -171,7 +181,7 @@ Key lifecycle:
171
181
  - `participant.revoke_key` is an admin operation; envelopes signed
172
182
  with the revoked key after revocation are rejected.
173
183
 
174
- Helpers exported from `@brightbeamai/chap-coordinator/crypto`:
184
+ Helpers exported from `@brightbeamai/chap-coordinator`:
175
185
 
176
186
  ```typescript
177
187
  import {
@@ -75,6 +75,8 @@ interface CoordinatorOptions {
75
75
  requireSignatures?: boolean;
76
76
  enforceStepUp?: boolean;
77
77
  requireReadMembership?: boolean;
78
+ /** Largest envelope accepted (default 1 MiB), published in the descriptor. */
79
+ maxEnvelopeBytes?: number;
78
80
  onAudit?: AuditListener;
79
81
  onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
80
82
  verifyOidcToken?: TokenVerifier;
@@ -75,6 +75,8 @@ interface CoordinatorOptions {
75
75
  requireSignatures?: boolean;
76
76
  enforceStepUp?: boolean;
77
77
  requireReadMembership?: boolean;
78
+ /** Largest envelope accepted (default 1 MiB), published in the descriptor. */
79
+ maxEnvelopeBytes?: number;
78
80
  onAudit?: AuditListener;
79
81
  onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
80
82
  verifyOidcToken?: TokenVerifier;
package/dist/api.d.cts CHANGED
@@ -1,4 +1,4 @@
1
1
  import './methods.cjs';
2
- export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-CcT_8H5j.cjs';
2
+ export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-DiiL0sil.cjs';
3
3
  import './types.cjs';
4
4
  import './storage/store.cjs';
package/dist/api.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import './methods.js';
2
- export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-Cvkhcalw.js';
2
+ export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-BOGEBNmX.js';
3
3
  import './types.js';
4
4
  import './storage/store.js';
package/dist/index.cjs CHANGED
@@ -323,6 +323,11 @@ function isValidEnvelope(env) {
323
323
  var PatchError = class extends Error {
324
324
  };
325
325
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
326
+ var ARRAY_INDEX_RE = /^(0|[1-9][0-9]*)$/;
327
+ function arrayIndex(seg) {
328
+ if (!ARRAY_INDEX_RE.test(seg)) throw new PatchError(`Array index expected at ${JSON.stringify(seg)}`);
329
+ return Number(seg);
330
+ }
326
331
  function unescape(token) {
327
332
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
328
333
  }
@@ -347,8 +352,8 @@ function navigate(doc, parts) {
347
352
  for (let i = 0; i < parts.length - 1; i++) {
348
353
  const part = parts[i];
349
354
  if (Array.isArray(parent)) {
350
- const idx = Number(part);
351
- if (!Number.isInteger(idx) || idx < 0 || idx >= parent.length) {
355
+ const idx = arrayIndex(part);
356
+ if (idx >= parent.length) {
352
357
  throw new PatchError(`Index out of range at /${parts.slice(0, i + 1).join("/")}`);
353
358
  }
354
359
  parent = parent[idx];
@@ -364,11 +369,7 @@ function navigate(doc, parts) {
364
369
  const last = parts[parts.length - 1];
365
370
  if (Array.isArray(parent)) {
366
371
  if (last === "-") return [parent, "-"];
367
- const idx = Number(last);
368
- if (!Number.isInteger(idx)) {
369
- throw new PatchError(`Array index expected at ${JSON.stringify(last)}`);
370
- }
371
- return [parent, idx];
372
+ return [parent, arrayIndex(last)];
372
373
  }
373
374
  return [parent, last];
374
375
  }
@@ -819,7 +820,7 @@ function registerControl(coord) {
819
820
  if (scope === "task") {
820
821
  const task = ws.tasks.get(p.task_id);
821
822
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
822
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
823
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
823
824
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot pause ${task.state} task`) };
824
825
  }
825
826
  task.paused = true;
@@ -876,7 +877,7 @@ function registerControl(coord) {
876
877
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
877
878
  const task = ws.tasks.get(p.task_id);
878
879
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
879
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
880
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
880
881
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
881
882
  }
882
883
  task.state = "cancelled";
@@ -898,7 +899,7 @@ function registerControl(coord) {
898
899
  }));
899
900
  }
900
901
  if (include.includes("open_tasks")) {
901
- state.open_tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled").map((t) => ({ id: t.id, kind: t.kind, state: t.state, assignee: t.assignee }));
902
+ state.open_tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled" && t.state !== "superseded").map((t) => ({ id: t.id, kind: t.kind, state: t.state, assignee: t.assignee }));
902
903
  }
903
904
  if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
904
905
  if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
@@ -966,6 +967,13 @@ function registerControl(coord) {
966
967
  if (!ws.members.has(assignee)) {
967
968
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
968
969
  }
970
+ const requestedMode = newSpec.mode ?? old.mode;
971
+ if (!modeLE(requestedMode, ws.mode_ceiling)) {
972
+ return { error: rpcError(
973
+ E.MODE_CEILING_EXCEEDED,
974
+ `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
975
+ ) };
976
+ }
969
977
  const now = coord.now();
970
978
  const newId = coord.ids.taskId();
971
979
  const newTask = {
@@ -977,7 +985,7 @@ function registerControl(coord) {
977
985
  input: newSpec.input ?? {},
978
986
  created_at: now,
979
987
  updated_at: now,
980
- mode: newSpec.mode ?? old.mode,
988
+ mode: requestedMode,
981
989
  supersedes: old.id,
982
990
  history: [{
983
991
  ts: now,
@@ -987,6 +995,8 @@ function registerControl(coord) {
987
995
  }],
988
996
  paused: false
989
997
  };
998
+ if (ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") newTask.review_required = true;
999
+ else if ("review_required" in newSpec) newTask.review_required = !!newSpec.review_required;
990
1000
  ws.tasks.set(newId, newTask);
991
1001
  old.state = "superseded";
992
1002
  old.superseded_by = newId;
@@ -1740,6 +1750,8 @@ var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1740
1750
  "audit.verify_chain",
1741
1751
  "audit.verify_receipt"
1742
1752
  ]);
1753
+ var MAX_IDEMPOTENCY_KEYS = 1e4;
1754
+ var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
1743
1755
  function nowIso(clockMs) {
1744
1756
  const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
1745
1757
  return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
@@ -1758,12 +1770,32 @@ function tagsError(p) {
1758
1770
  function linkHash(envelope, prev) {
1759
1771
  return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
1760
1772
  }
1773
+ function reviewRuleSupported(rule) {
1774
+ if (rule === "any_one_approves" || rule === "all_approve") return true;
1775
+ if (rule.startsWith("quorum:")) {
1776
+ const arg = rule.slice("quorum:".length);
1777
+ return /^[0-9]+$/.test(arg) && parseInt(arg, 10) >= 1;
1778
+ }
1779
+ return false;
1780
+ }
1781
+ function reviewSatisfied(review) {
1782
+ const approvers = new Set(review.decisions.filter((d) => d.kind === "approve").map((d) => d.reviewer));
1783
+ const rule = review.rule || "any_one_approves";
1784
+ if (rule === "all_approve") {
1785
+ const named = review.requested_to.filter((r) => !(r.startsWith("workspace:") || r.startsWith("group:")));
1786
+ if (named.length === 0) return approvers.size >= 1;
1787
+ return named.every((r) => approvers.has(r));
1788
+ }
1789
+ if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
1790
+ return approvers.size >= 1;
1791
+ }
1761
1792
  function reply(env, body) {
1762
1793
  const out = { jsonrpc: "2.0", id: env.id };
1763
1794
  if (body.error !== void 0) out.error = body.error;
1764
1795
  else out.result = body.result;
1765
1796
  return out;
1766
1797
  }
1798
+ var REVIEW_REQUEST_STOPPED = ["cancelled", "superseded", "paused"];
1767
1799
  var Coordinator = class {
1768
1800
  workspaces = /* @__PURE__ */ new Map();
1769
1801
  options;
@@ -1983,6 +2015,18 @@ var Coordinator = class {
1983
2015
  if (!isValidEnvelope(envelope) || !envelope.method) {
1984
2016
  return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
1985
2017
  }
2018
+ const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
2019
+ let size = 0;
2020
+ try {
2021
+ size = canonicalize(envelope).length;
2022
+ } catch {
2023
+ }
2024
+ if (size > maxBytes) {
2025
+ return reply(envelope, { error: rpcError(
2026
+ E.REQUEST,
2027
+ `Envelope exceeds max_envelope_bytes (${size} > ${maxBytes})`
2028
+ ) });
2029
+ }
1986
2030
  const method = envelope.method;
1987
2031
  const rawParams = envelope.params;
1988
2032
  if (rawParams !== void 0 && rawParams !== null && (typeof rawParams !== "object" || Array.isArray(rawParams))) {
@@ -2007,7 +2051,7 @@ var Coordinator = class {
2007
2051
  const stale = this.checkStepUp(params);
2008
2052
  if (stale) return reply(envelope, { error: stale });
2009
2053
  }
2010
- if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.")) {
2054
+ if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.")) {
2011
2055
  const wsId2 = params.workspace;
2012
2056
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2013
2057
  if (ws) {
@@ -2116,6 +2160,9 @@ var Coordinator = class {
2116
2160
  if (member.keys.some((k) => k.kid === kid && k.revoked_at !== void 0 && now >= k.revoked_at)) {
2117
2161
  return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
2118
2162
  }
2163
+ if (member.keys.some((k) => k.kid === kid && k.valid_until !== void 0 && now >= k.valid_until)) {
2164
+ return rpcError(E.SIG_KEY_NOT_FOUND, `Key ${kid} is no longer valid`);
2165
+ }
2119
2166
  const key = this.lookupKey(member, kid, ts);
2120
2167
  if (!key) {
2121
2168
  const revoked = member.keys.find((k) => k.kid === kid && k.revoked_at);
@@ -2131,9 +2178,8 @@ var Coordinator = class {
2131
2178
  return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2132
2179
  }
2133
2180
  return null;
2134
- } catch (e) {
2135
- const msg = e instanceof Error ? e.message : String(e);
2136
- return rpcError(E.INTERNAL, `Signature check error: ${msg}`);
2181
+ } catch {
2182
+ return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2137
2183
  }
2138
2184
  }
2139
2185
  /** Lookup the key valid for a given participant at a given time. */
@@ -2257,6 +2303,7 @@ var Coordinator = class {
2257
2303
  state: ws.state,
2258
2304
  mode: ws.mode,
2259
2305
  mode_ceiling: ws.mode_ceiling,
2306
+ max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
2260
2307
  step_up_window_sec: ws.step_up_window_sec,
2261
2308
  profiles: ws.profiles,
2262
2309
  members: Array.from(ws.members.values()).map(memberToDict),
@@ -2397,10 +2444,14 @@ var Coordinator = class {
2397
2444
  history: [{ ts: now, from: p.from, state: "created" }],
2398
2445
  paused: false
2399
2446
  };
2400
- if (task.mode === "trial") task.review_required = true;
2447
+ if (ws.profiles.some((pr) => pr.startsWith("modes/")) && task.mode === "trial") task.review_required = true;
2401
2448
  else if ("review_required" in p) task.review_required = !!p.review_required;
2402
2449
  ws.tasks.set(taskId, task);
2403
- if (typeof p.idempotency_key === "string") ws.idempotency_keys[p.idempotency_key] = taskId;
2450
+ if (typeof p.idempotency_key === "string") {
2451
+ ws.idempotency_keys[p.idempotency_key] = taskId;
2452
+ const keys = Object.keys(ws.idempotency_keys);
2453
+ if (keys.length > MAX_IDEMPOTENCY_KEYS) delete ws.idempotency_keys[keys[0]];
2454
+ }
2404
2455
  return { result: { task_id: taskId, state: "created" } };
2405
2456
  }
2406
2457
  opTaskUpdate(p) {
@@ -2414,12 +2465,18 @@ var Coordinator = class {
2414
2465
  const legal = {
2415
2466
  created: ["in_progress", "declined", "paused"],
2416
2467
  in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
2417
- review_requested: ["in_progress", "completed", "declined"],
2468
+ review_requested: ["in_progress"],
2418
2469
  paused: ["in_progress", "cancelled"]
2419
2470
  };
2420
2471
  if (!legal[task.state]?.includes(newState)) {
2421
2472
  return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
2422
2473
  }
2474
+ if (newState === "completed" && task.review_required) {
2475
+ return { error: rpcError(
2476
+ E.PARAMS,
2477
+ "This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
2478
+ ) };
2479
+ }
2423
2480
  task.state = newState;
2424
2481
  task.updated_at = this.now();
2425
2482
  task.history.push({
@@ -2440,6 +2497,30 @@ var Coordinator = class {
2440
2497
  if (task.state !== "created" && task.state !== "in_progress") {
2441
2498
  return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
2442
2499
  }
2500
+ if (task.review_required) {
2501
+ const now = this.now();
2502
+ task.pending_artefact = p.output;
2503
+ if (!task.review) {
2504
+ const producer = p.from;
2505
+ const eligible = [...ws.members.entries()].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
2506
+ if (eligible.length === 0) {
2507
+ return { error: rpcError(
2508
+ E.NOT_AUTHORISED,
2509
+ "Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
2510
+ ) };
2511
+ }
2512
+ task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
2513
+ }
2514
+ task.state = "review_requested";
2515
+ task.updated_at = now;
2516
+ task.history.push({
2517
+ ts: now,
2518
+ from: p.from,
2519
+ state: "review_requested",
2520
+ note: "review required; opened on task.complete"
2521
+ });
2522
+ return { result: { state: "review_requested", review_id: task.id } };
2523
+ }
2443
2524
  task.output = p.output;
2444
2525
  if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
2445
2526
  task.state = "completed";
@@ -2486,11 +2567,18 @@ var Coordinator = class {
2486
2567
  if (notMember) return notMember;
2487
2568
  const task = ws.tasks.get(p.task_id);
2488
2569
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2570
+ if (REVIEW_REQUEST_STOPPED.includes(task.state)) {
2571
+ return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
2572
+ }
2489
2573
  const to = p.to;
2490
2574
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
2491
2575
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
2492
2576
  const now = this.now();
2493
- const rule = p.rule || "any_one_approves";
2577
+ const declaredRule = p.rule;
2578
+ const rule = declaredRule || "any_one_approves";
2579
+ if (!reviewRuleSupported(rule)) {
2580
+ return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
2581
+ }
2494
2582
  if (task.state === "review_requested" && task.review) {
2495
2583
  if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
2496
2584
  return { error: rpcError(
@@ -2498,7 +2586,7 @@ var Coordinator = class {
2498
2586
  "A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
2499
2587
  ) };
2500
2588
  }
2501
- if (rule !== task.review.rule) {
2589
+ if (declaredRule !== void 0 && declaredRule !== task.review.rule) {
2502
2590
  return { error: rpcError(
2503
2591
  E.REVIEW_ALREADY_OPEN,
2504
2592
  `Cannot change the decision rule of an open review (currently ${task.review.rule})`
@@ -2587,8 +2675,10 @@ var Coordinator = class {
2587
2675
  tags: p.tags
2588
2676
  });
2589
2677
  if (kind === "approve") {
2590
- task.output = task.pending_artefact;
2591
- task.state = "completed";
2678
+ if (reviewSatisfied(task.review)) {
2679
+ task.output = task.pending_artefact;
2680
+ task.state = "completed";
2681
+ }
2592
2682
  } else {
2593
2683
  task.state = p.request_revision ? "in_progress" : "declined";
2594
2684
  }