@brightbeamai/chap-coordinator 0.2.8 → 0.2.10

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
@@ -9,19 +9,11 @@ surface, so the two interoperate on the same JSON-RPC 2.0 wire.
9
9
 
10
10
  ## Install
11
11
 
12
- This package is currently distributed alongside the spec repo rather
13
- than published to npm. To use it directly from source in another
14
- TypeScript project:
15
-
16
12
  ```bash
17
- # From the chap repo root
18
- cd packages/coordinator
19
- npm pack
20
- # In your project
21
- npm install ./brightbeamai-chap-coordinator-*.tgz # the .tgz that npm pack just produced
13
+ npm install @brightbeamai/chap-coordinator
22
14
  ```
23
15
 
24
- Node 18+ required. Zero external runtime dependencies; uses Node's
16
+ Node 20+ required. Zero external runtime dependencies; uses Node's
25
17
  built-in `crypto` for Ed25519 and JCS.
26
18
 
27
19
  ## Companion packages
@@ -33,7 +25,8 @@ A2A ecosystems:
33
25
  - [`@brightbeamai/chap-coordinator-mcp`](https://github.com/BrightbeamAI/chap/tree/main/packages/coordinator-mcp) wraps a Coordinator
34
26
  as an MCP server. Every CHAP method becomes an MCP tool named
35
27
  `chap.<method>`. Reference stdio server at
36
- `reference/mcp-server-ts/`. Spec target: MCP 2025-11-25.
28
+ `reference/mcp-server-ts/`. Spec target: MCP 2026-07-28, serving
29
+ 2025-11-25 clients as well.
37
30
  - [`@brightbeamai/chap-coordinator-a2a`](https://github.com/BrightbeamAI/chap/tree/main/packages/coordinator-a2a) wraps a Coordinator
38
31
  as an A2A agent. Every CHAP method becomes an `AgentSkill` on the
39
32
  Agent Card. Reference Express server at `reference/a2a-server-ts/`.
@@ -223,7 +216,12 @@ task's `assignee`.
223
216
 
224
217
  Audit entries are hash-linked when the profile is active.
225
218
  `audit.verify_chain` replays the chain locally and confirms
226
- `prev_hash` continuity.
219
+ `prev_hash` continuity. The verdict names the range it covered:
220
+ `status` is `verified` with `ok: true` only when every entry was
221
+ checked, and `not_evaluated` with `ok: false` when part of the log
222
+ lies outside the chain, which happens when the profile is added to a
223
+ workspace that already has entries. A broken chain is an error. Read
224
+ `status`, or read `ok` and fail closed.
227
225
 
228
226
  `audit.submit_to_scitt` builds a SCITT-shaped signed statement for
229
227
  each audit entry in the requested range and passes it to a
@@ -247,7 +245,7 @@ statements so the caller can submit out-of-band.
247
245
  npm test
248
246
  ```
249
247
 
250
- 72 tests as of this release, covering Core, every profile, JCS and
248
+ The suite covers Core, every profile, JCS and
251
249
  Ed25519 conformance vectors, signed-envelope verification, OIDC/VC
252
250
  binding, the inward wrap helpers, and an end-to-end composition test
253
251
  exercising every method handler in one workspace.
@@ -74,6 +74,7 @@ interface CoordinatorOptions {
74
74
  enableChain?: boolean;
75
75
  requireSignatures?: boolean;
76
76
  enforceStepUp?: boolean;
77
+ requireReadMembership?: boolean;
77
78
  onAudit?: AuditListener;
78
79
  onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
79
80
  verifyOidcToken?: TokenVerifier;
@@ -174,6 +175,19 @@ declare class Coordinator {
174
175
  private opTaskComplete;
175
176
  private opAuditRead;
176
177
  private opReviewRequest;
178
+ /**
179
+ * Verify an optional `approved_artefact_digest` against the artefact under
180
+ * review (review/1.0 S3.2).
181
+ *
182
+ * The digest sits in params, so it is inside whatever the envelope signature
183
+ * covers: the decision then attests the content the reviewer saw rather than
184
+ * a task reference, and a relying party can check it without trusting the
185
+ * Coordinator that produced it. Absent, behaviour is exactly as before.
186
+ *
187
+ * On mismatch nothing is recorded and no state changes, so a client that
188
+ * computes the wrong digest refuses only its own decision.
189
+ */
190
+ private checkArtefactDigest;
177
191
  private opDecide;
178
192
  private opDecideOverride;
179
193
  private opAbstainDeclare;
@@ -74,6 +74,7 @@ interface CoordinatorOptions {
74
74
  enableChain?: boolean;
75
75
  requireSignatures?: boolean;
76
76
  enforceStepUp?: boolean;
77
+ requireReadMembership?: boolean;
77
78
  onAudit?: AuditListener;
78
79
  onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
79
80
  verifyOidcToken?: TokenVerifier;
@@ -174,6 +175,19 @@ declare class Coordinator {
174
175
  private opTaskComplete;
175
176
  private opAuditRead;
176
177
  private opReviewRequest;
178
+ /**
179
+ * Verify an optional `approved_artefact_digest` against the artefact under
180
+ * review (review/1.0 S3.2).
181
+ *
182
+ * The digest sits in params, so it is inside whatever the envelope signature
183
+ * covers: the decision then attests the content the reviewer saw rather than
184
+ * a task reference, and a relying party can check it without trusting the
185
+ * Coordinator that produced it. Absent, behaviour is exactly as before.
186
+ *
187
+ * On mismatch nothing is recorded and no state changes, so a client that
188
+ * computes the wrong digest refuses only its own decision.
189
+ */
190
+ private checkArtefactDigest;
177
191
  private opDecide;
178
192
  private opDecideOverride;
179
193
  private opAbstainDeclare;
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-C3nkEk-B.cjs';
2
+ export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-CcT_8H5j.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-CmSvlIqO.js';
2
+ export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-Cvkhcalw.js';
3
3
  import './types.js';
4
4
  import './storage/store.js';
package/dist/index.cjs CHANGED
@@ -210,12 +210,7 @@ var IdFactory = class {
210
210
  return this.counter * 0x9E3779B97F4A7C15n & (1n << 80n) - 1n;
211
211
  }
212
212
  const buf = new Uint8Array(10);
213
- if (typeof crypto !== "undefined" && crypto.getRandomValues) {
214
- crypto.getRandomValues(buf);
215
- } else {
216
- const nodeCrypto = require("crypto");
217
- buf.set(nodeCrypto.randomBytes(10));
218
- }
213
+ crypto.getRandomValues(buf);
219
214
  let n = 0n;
220
215
  for (const b of buf) n = n << 8n | BigInt(b);
221
216
  return n;
@@ -264,6 +259,7 @@ var E = {
264
259
  NOT_AUTHORISED: -32011,
265
260
  PATCH_FAILED: -32012,
266
261
  REVIEW_LAPSED: -32013,
262
+ REVIEW_ALREADY_OPEN: -32014,
267
263
  // whisper/1.0 profile (profiles/whisper.md S6)
268
264
  WHISPER_ALREADY_ANSWERED: -32020,
269
265
  WHISPER_LAPSED: -32021,
@@ -290,6 +286,7 @@ var E = {
290
286
  SIG_KEY_NOT_FOUND: -32071,
291
287
  SIG_KEY_REVOKED: -32072,
292
288
  SIG_ROTATION_KEY_MISMATCH: -32073,
289
+ SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
293
290
  // audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
294
291
  SCITT_UNREACHABLE: -32080,
295
292
  SCITT_STATEMENT_REJECTED: -32081,
@@ -757,6 +754,12 @@ function registerAuditScitt(coord) {
757
754
  if (!(ws.chain_enabled || coord.options.enableChain)) {
758
755
  return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
759
756
  }
757
+ if (p.from_seq !== void 0 || p.to_seq !== void 0) {
758
+ return { error: rpcError(
759
+ E.PARAMS,
760
+ "Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
761
+ ) };
762
+ }
760
763
  let start = ws.audit.findIndex((e) => e.prev_hash != null);
761
764
  if (start < 0) start = ws.audit.length;
762
765
  const errors = [];
@@ -768,16 +771,35 @@ function registerAuditScitt(coord) {
768
771
  }
769
772
  prev = sha256Hex(Buffer.concat([canonicalize(e.envelope), Buffer.from(expectedPrev, "utf-8")]));
770
773
  }
771
- const storedHead = ws.chain_head ?? ZERO_HASH;
774
+ const storedHead = ws.chain_head === void 0 || ws.chain_head === null ? ZERO_HASH : ws.chain_head;
772
775
  if (prev !== storedHead) {
773
776
  errors.push("chain_head mismatch: replay does not match stored head");
774
777
  }
775
778
  if (errors.length) {
776
779
  return { error: rpcError(E.PARAMS, errors.join("; ")) };
777
780
  }
781
+ const entriesTotal = ws.audit.length;
782
+ const entriesChecked = entriesTotal - start;
783
+ const entriesUnchecked = start;
784
+ if (entriesUnchecked > 0) {
785
+ return { result: {
786
+ status: "not_evaluated",
787
+ ok: false,
788
+ reason: "unchained_prefix",
789
+ entries_total: entriesTotal,
790
+ entries_checked: entriesChecked,
791
+ entries_unchecked: entriesUnchecked,
792
+ checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
793
+ chain_head: storedHead
794
+ } };
795
+ }
778
796
  return { result: {
797
+ status: "verified",
779
798
  ok: true,
780
- entries_checked: ws.audit.length - start,
799
+ entries_total: entriesTotal,
800
+ entries_checked: entriesChecked,
801
+ entries_unchecked: 0,
802
+ checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
781
803
  chain_head: storedHead
782
804
  } };
783
805
  });
@@ -1558,6 +1580,16 @@ function registerSecuritySigned(coord) {
1558
1580
  for (const f of ["target_uri", "kid"]) {
1559
1581
  if (!(f in p)) return { error: rpcError(E.PARAMS, `Missing field: ${f}`) };
1560
1582
  }
1583
+ const sender = p.from;
1584
+ if (sender !== p.target_uri) {
1585
+ const caller = sender ? ws.members.get(sender) : void 0;
1586
+ if (!caller || caller.role !== "admin") {
1587
+ return { error: rpcError(
1588
+ E.NOT_AUTHORISED,
1589
+ "Revoking another member's key requires the admin role"
1590
+ ) };
1591
+ }
1592
+ }
1561
1593
  const member = ws.members.get(p.target_uri);
1562
1594
  if (!member) return { error: rpcError(E.SIG_KEY_NOT_FOUND, `Unknown target: ${p.target_uri}`) };
1563
1595
  const key = member.keys.find((k) => k.kid === p.kid);
@@ -1649,7 +1681,8 @@ function registerWhisper(coord) {
1649
1681
  prompt.answer_option = answerOption;
1650
1682
  prompt.answer_text = answerText;
1651
1683
  prompt.comment = p.comment;
1652
- return { result: { answered: true, whisper_id: prompt.id } };
1684
+ p.task_id = prompt.task_id;
1685
+ return { result: { answered: true, whisper_id: prompt.id, task_id: prompt.task_id } };
1653
1686
  });
1654
1687
  coord.checkWhisperLapses = (workspaceId, now) => {
1655
1688
  const ws = coord.workspaces.get(workspaceId);
@@ -1673,6 +1706,11 @@ function registerWhisper(coord) {
1673
1706
  ts: coord.now(),
1674
1707
  kind: "whisper_lapsed",
1675
1708
  whisper_id: prompt.id,
1709
+ // Carry the task so a task-filtered audit.read surfaces the
1710
+ // lapse. A lapsed whisper applies its default with no human
1711
+ // input, so it is the case most in need of being visible on
1712
+ // the task's thread.
1713
+ task_id: prompt.task_id,
1676
1714
  default_applied: prompt.default_if_lapsed
1677
1715
  }
1678
1716
  };
@@ -1874,6 +1912,7 @@ var Coordinator = class {
1874
1912
  mode_ceiling: ws.mode_ceiling,
1875
1913
  routing_policy_uri: ws.routing_policy_uri,
1876
1914
  step_up_window_sec: ws.step_up_window_sec,
1915
+ min_acr: ws.min_acr,
1877
1916
  members: Array.from(ws.members.values()),
1878
1917
  tasks: Array.from(ws.tasks.values()),
1879
1918
  overrides: Array.from(ws.overrides.values()),
@@ -1882,6 +1921,7 @@ var Coordinator = class {
1882
1921
  handoffs: Array.from(ws.handoffs.values()),
1883
1922
  snapshots: Array.from(ws.snapshots.values()),
1884
1923
  route_decisions: Array.from(ws.route_decisions.values()),
1924
+ idempotency_keys: ws.idempotency_keys,
1885
1925
  audit: ws.audit,
1886
1926
  chain_head: ws.chain_head,
1887
1927
  chain_enabled: ws.chain_enabled
@@ -1904,6 +1944,7 @@ var Coordinator = class {
1904
1944
  mode_ceiling: w.mode_ceiling ?? "production",
1905
1945
  routing_policy_uri: w.routing_policy_uri,
1906
1946
  step_up_window_sec: w.step_up_window_sec ?? 300,
1947
+ min_acr: w.min_acr,
1907
1948
  members: /* @__PURE__ */ new Map(),
1908
1949
  tasks: /* @__PURE__ */ new Map(),
1909
1950
  overrides: /* @__PURE__ */ new Map(),
@@ -1912,6 +1953,7 @@ var Coordinator = class {
1912
1953
  handoffs: /* @__PURE__ */ new Map(),
1913
1954
  snapshots: /* @__PURE__ */ new Map(),
1914
1955
  route_decisions: /* @__PURE__ */ new Map(),
1956
+ idempotency_keys: w.idempotency_keys ?? {},
1915
1957
  audit: w.audit ?? [],
1916
1958
  chain_head: w.chain_head,
1917
1959
  chain_enabled: !!w.chain_enabled
@@ -1950,6 +1992,16 @@ var Coordinator = class {
1950
1992
  if (this.options.requireSignatures && method !== "participant.join" && method !== "workspace.create") {
1951
1993
  const sigErr = this.verifySignature(envelope);
1952
1994
  if (sigErr) return reply(envelope, { error: sigErr });
1995
+ if (method === "participant.rotate_key") {
1996
+ const signingKid = envelope.sig.split(":")[1];
1997
+ const oldKid = params.old_kid;
1998
+ if (oldKid !== void 0 && signingKid !== oldKid) {
1999
+ return reply(envelope, { error: rpcError(
2000
+ E.SIG_ROTATION_KEY_MISMATCH,
2001
+ "Rotation must be signed with the old key"
2002
+ ) });
2003
+ }
2004
+ }
1953
2005
  }
1954
2006
  if (this.options.enforceStepUp && PRIVILEGED_METHODS.has(method)) {
1955
2007
  const stale = this.checkStepUp(params);
@@ -1980,6 +2032,16 @@ var Coordinator = class {
1980
2032
  }
1981
2033
  }
1982
2034
  }
2035
+ if (method === "task.create") {
2036
+ const wsId2 = params.workspace;
2037
+ const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
2038
+ const key = params.idempotency_key;
2039
+ if (ws && typeof key === "string") {
2040
+ const existingId = ws.idempotency_keys[key];
2041
+ const task = existingId ? ws.tasks.get(existingId) : void 0;
2042
+ if (task) return reply(envelope, { result: { task_id: existingId, state: task.state } });
2043
+ }
2044
+ }
1983
2045
  const handler = this.handlers.get(method);
1984
2046
  if (!handler) {
1985
2047
  return reply(envelope, { error: rpcError(E.METHOD, `Unknown method: ${method}`) });
@@ -2093,7 +2155,13 @@ var Coordinator = class {
2093
2155
  const ws = this.workspaces.get(wsId);
2094
2156
  if (!ws) return null;
2095
2157
  const member = ws.members.get(sender);
2096
- if (!member || member.oidc_auth_time === void 0) return null;
2158
+ if (!member) return null;
2159
+ if (member.type !== "human" && member.oidc_sub === void 0) return null;
2160
+ if (member.oidc_auth_time === void 0) {
2161
+ return rpcError(E.OIDC_STEP_UP_REQUIRED, "Step-up authentication required", {
2162
+ window_sec: ws.step_up_window_sec
2163
+ });
2164
+ }
2097
2165
  const nowSec = Math.floor(Date.now() / 1e3);
2098
2166
  const age = nowSec - member.oidc_auth_time;
2099
2167
  if (age > ws.step_up_window_sec) {
@@ -2102,6 +2170,12 @@ var Coordinator = class {
2102
2170
  age_sec: age
2103
2171
  });
2104
2172
  }
2173
+ if (ws.min_acr !== void 0 && member.oidc_acr !== ws.min_acr) {
2174
+ return rpcError(E.OIDC_STEP_UP_REQUIRED, "Step-up required: acr below the workspace minimum", {
2175
+ required_acr: ws.min_acr,
2176
+ acr: member.oidc_acr
2177
+ });
2178
+ }
2105
2179
  return null;
2106
2180
  }
2107
2181
  // ==========================================================
@@ -2151,6 +2225,7 @@ var Coordinator = class {
2151
2225
  mode_ceiling: p.mode_ceiling ?? "production",
2152
2226
  routing_policy_uri: p.routing_policy_uri,
2153
2227
  step_up_window_sec: typeof p.step_up_window_sec === "number" ? p.step_up_window_sec : 300,
2228
+ min_acr: p.min_acr,
2154
2229
  members: /* @__PURE__ */ new Map(),
2155
2230
  tasks: /* @__PURE__ */ new Map(),
2156
2231
  overrides: /* @__PURE__ */ new Map(),
@@ -2159,6 +2234,7 @@ var Coordinator = class {
2159
2234
  handoffs: /* @__PURE__ */ new Map(),
2160
2235
  snapshots: /* @__PURE__ */ new Map(),
2161
2236
  route_decisions: /* @__PURE__ */ new Map(),
2237
+ idempotency_keys: {},
2162
2238
  audit: [],
2163
2239
  chain_enabled: chainEnabled,
2164
2240
  chain_head: chainEnabled ? ZERO_HASH : void 0
@@ -2171,6 +2247,10 @@ var Coordinator = class {
2171
2247
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
2172
2248
  const ws = this.workspaces.get(p.workspace);
2173
2249
  if (!ws) return { error: rpcError(E.PARAMS, `Unknown workspace: ${p.workspace}`) };
2250
+ if (this.options.requireReadMembership) {
2251
+ const notMember = this.requireMember(ws, p.from);
2252
+ if (notMember) return notMember;
2253
+ }
2174
2254
  return { result: {
2175
2255
  id: ws.id,
2176
2256
  created: ws.created,
@@ -2188,10 +2268,15 @@ var Coordinator = class {
2188
2268
  } };
2189
2269
  }
2190
2270
  opWorkspaceSetProfiles(p) {
2191
- const miss = missing(p, ["workspace", "profiles"]);
2271
+ const miss = missing(p, ["workspace", "from", "profiles"]);
2192
2272
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
2193
2273
  const ws = this.workspaces.get(p.workspace);
2194
2274
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2275
+ const notMember = this.requireMember(ws, p.from);
2276
+ if (notMember) return notMember;
2277
+ if (ws.members.get(p.from).role !== "admin") {
2278
+ return { error: rpcError(E.NOT_AUTHORISED, "workspace.set_profiles requires the admin role") };
2279
+ }
2195
2280
  const newProfiles = [...p.profiles];
2196
2281
  if (!newProfiles.some((pr) => pr.startsWith("core/"))) newProfiles.push("core/1.0");
2197
2282
  ws.profiles = newProfiles;
@@ -2229,6 +2314,7 @@ var Coordinator = class {
2229
2314
  member.oidc_sub = claims.sub;
2230
2315
  const at = claims.auth_time;
2231
2316
  if (typeof at === "number") member.oidc_auth_time = at;
2317
+ member.oidc_acr = claims.acr;
2232
2318
  const cnf = claims.cnf;
2233
2319
  const cnfJwk = cnf?.jwk;
2234
2320
  if (cnfJwk && typeof cnfJwk.kid === "string") {
@@ -2257,6 +2343,7 @@ var Coordinator = class {
2257
2343
  if (existing) {
2258
2344
  if (member.oidc_sub !== void 0) existing.oidc_sub = member.oidc_sub;
2259
2345
  if (member.oidc_auth_time !== void 0) existing.oidc_auth_time = member.oidc_auth_time;
2346
+ if (member.oidc_acr !== void 0) existing.oidc_acr = member.oidc_acr;
2260
2347
  if (member.vc_holder !== void 0) existing.vc_holder = member.vc_holder;
2261
2348
  for (const k of attested) {
2262
2349
  if (!existing.keys.some((x) => x.kid === k.kid)) existing.keys.push(k);
@@ -2277,6 +2364,8 @@ var Coordinator = class {
2277
2364
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
2278
2365
  const ws = this.workspaces.get(p.workspace);
2279
2366
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2367
+ const notMember = this.requireMember(ws, p.from);
2368
+ if (notMember) return notMember;
2280
2369
  const assignee = p.assignee || p.to;
2281
2370
  if (!assignee || !ws.members.has(assignee)) {
2282
2371
  return { error: rpcError(E.PARAMS, "Assignee not in workspace") };
@@ -2311,11 +2400,14 @@ var Coordinator = class {
2311
2400
  if (task.mode === "trial") task.review_required = true;
2312
2401
  else if ("review_required" in p) task.review_required = !!p.review_required;
2313
2402
  ws.tasks.set(taskId, task);
2403
+ if (typeof p.idempotency_key === "string") ws.idempotency_keys[p.idempotency_key] = taskId;
2314
2404
  return { result: { task_id: taskId, state: "created" } };
2315
2405
  }
2316
2406
  opTaskUpdate(p) {
2317
2407
  const ws = this.workspaces.get(p.workspace);
2318
2408
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2409
+ const notMember = this.requireMember(ws, p.from);
2410
+ if (notMember) return notMember;
2319
2411
  const task = ws.tasks.get(p.task_id);
2320
2412
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
2321
2413
  const newState = p.state;
@@ -2358,6 +2450,10 @@ var Coordinator = class {
2358
2450
  opAuditRead(p) {
2359
2451
  const ws = this.workspaces.get(p.workspace);
2360
2452
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
2453
+ if (this.options.requireReadMembership) {
2454
+ const notMember = this.requireMember(ws, p.from);
2455
+ if (notMember) return notMember;
2456
+ }
2361
2457
  const range = p.range ?? {};
2362
2458
  const filter = p.filter ?? {};
2363
2459
  const fromSeq = range.from_seq ?? 0;
@@ -2394,12 +2490,38 @@ var Coordinator = class {
2394
2490
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
2395
2491
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
2396
2492
  const now = this.now();
2493
+ const rule = p.rule || "any_one_approves";
2494
+ if (task.state === "review_requested" && task.review) {
2495
+ if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
2496
+ return { error: rpcError(
2497
+ E.REVIEW_ALREADY_OPEN,
2498
+ "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
+ ) };
2500
+ }
2501
+ if (rule !== task.review.rule) {
2502
+ return { error: rpcError(
2503
+ E.REVIEW_ALREADY_OPEN,
2504
+ `Cannot change the decision rule of an open review (currently ${task.review.rule})`
2505
+ ) };
2506
+ }
2507
+ for (const r of reviewers) {
2508
+ if (!task.review.requested_to.includes(r)) task.review.requested_to.push(r);
2509
+ }
2510
+ if (p.deadline !== void 0) task.review.deadline = p.deadline;
2511
+ task.updated_at = now;
2512
+ return { result: {
2513
+ state: "review_requested",
2514
+ review_id: task.id,
2515
+ amended: true,
2516
+ requested_to: [...task.review.requested_to]
2517
+ } };
2518
+ }
2397
2519
  task.state = "review_requested";
2398
2520
  task.updated_at = now;
2399
2521
  task.review = {
2400
2522
  requested_at: now,
2401
2523
  requested_to: reviewers,
2402
- rule: p.rule || "any_one_approves",
2524
+ rule,
2403
2525
  deadline: p.deadline,
2404
2526
  decisions: []
2405
2527
  };
@@ -2407,6 +2529,37 @@ var Coordinator = class {
2407
2529
  task.pending_artefact = p.artefact;
2408
2530
  return { result: { state: "review_requested", review_id: task.id } };
2409
2531
  }
2532
+ /**
2533
+ * Verify an optional `approved_artefact_digest` against the artefact under
2534
+ * review (review/1.0 S3.2).
2535
+ *
2536
+ * The digest sits in params, so it is inside whatever the envelope signature
2537
+ * covers: the decision then attests the content the reviewer saw rather than
2538
+ * a task reference, and a relying party can check it without trusting the
2539
+ * Coordinator that produced it. Absent, behaviour is exactly as before.
2540
+ *
2541
+ * On mismatch nothing is recorded and no state changes, so a client that
2542
+ * computes the wrong digest refuses only its own decision.
2543
+ */
2544
+ checkArtefactDigest(p, task) {
2545
+ const declared = p.approved_artefact_digest;
2546
+ if (declared === void 0 || declared === null) return null;
2547
+ if (typeof declared !== "string") {
2548
+ return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
2549
+ }
2550
+ if (task.pending_artefact === void 0) {
2551
+ return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
2552
+ }
2553
+ const actual = contentHash(task.pending_artefact);
2554
+ if (declared !== actual) {
2555
+ return { error: rpcError(
2556
+ E.SIG_ARTEFACT_DIGEST_MISMATCH,
2557
+ "approved_artefact_digest does not match the artefact under review",
2558
+ { declared, actual }
2559
+ ) };
2560
+ }
2561
+ return null;
2562
+ }
2410
2563
  opDecide(p, kind) {
2411
2564
  const miss = missing(p, ["workspace", "from", "task_id"]);
2412
2565
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
@@ -2423,6 +2576,8 @@ var Coordinator = class {
2423
2576
  }
2424
2577
  const notReviewer = this.requireReviewer(task, p.from);
2425
2578
  if (notReviewer) return notReviewer;
2579
+ const digestError = this.checkArtefactDigest(p, task);
2580
+ if (digestError) return digestError;
2426
2581
  const now = this.now();
2427
2582
  task.review.decisions.push({
2428
2583
  reviewer: p.from,
@@ -2457,6 +2612,8 @@ var Coordinator = class {
2457
2612
  }
2458
2613
  const notReviewer = this.requireReviewer(task, p.from);
2459
2614
  if (notReviewer) return notReviewer;
2615
+ const digestError = this.checkArtefactDigest(p, task);
2616
+ if (digestError) return digestError;
2460
2617
  const base = task.pending_artefact;
2461
2618
  if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
2462
2619
  let applied;