@brightbeamai/chap-coordinator 0.2.9 → 0.2.12

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.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as CoordinatorOptions, a as Coordinator } from './api-DE78DA0o.cjs';
2
- export { b as CoordinatorApi, c as CoordinatorError, d as CredentialVerifier, E as EscalationPolicyFn, H as Handler, I as IdFactory, P as PRIVILEGED_METHODS, R as ReviewDepthPolicyFn, e as RoutingPolicyFn, S as ScittReceiptVerifier, f as ScittSubmitter, T as TokenVerifier, g as call, m as makeApi } from './api-DE78DA0o.cjs';
1
+ import { C as CoordinatorOptions, a as Coordinator } from './api-DiiL0sil.cjs';
2
+ export { b as CoordinatorApi, c as CoordinatorError, d as CredentialVerifier, E as EscalationPolicyFn, H as Handler, I as IdFactory, P as PRIVILEGED_METHODS, R as ReviewDepthPolicyFn, e as RoutingPolicyFn, S as ScittReceiptVerifier, f as ScittSubmitter, T as TokenVerifier, g as call, m as makeApi } from './api-DiiL0sil.cjs';
3
3
  export { MethodName, MethodParams, MethodResult, MethodTable } from './methods.cjs';
4
4
  export { MemoryStore, Store, WorkspaceRecord } from './storage/store.cjs';
5
5
  export { ArtefactId, ArtefactRoutingHints, AuditEntry, AuditListener, Deliberation, DeliberationVote, Envelope, Handoff, HandoffTaskItem, JsonPatchOp, Jwk, KeyRecord, Member, Mode, OverrideArtefact, ParticipantType, ParticipantUri, PauseScope, ReviewDecision, RouteDecisionArtefact, SnapshotArtefact, Task, TaskId, TaskRoutingHints, TaskState, WhisperPrompt, Workspace, WorkspaceId, WorkspaceState, modeLE } from './types.cjs';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as CoordinatorOptions, a as Coordinator } from './api-CPEk0V6i.js';
2
- export { b as CoordinatorApi, c as CoordinatorError, d as CredentialVerifier, E as EscalationPolicyFn, H as Handler, I as IdFactory, P as PRIVILEGED_METHODS, R as ReviewDepthPolicyFn, e as RoutingPolicyFn, S as ScittReceiptVerifier, f as ScittSubmitter, T as TokenVerifier, g as call, m as makeApi } from './api-CPEk0V6i.js';
1
+ import { C as CoordinatorOptions, a as Coordinator } from './api-BOGEBNmX.js';
2
+ export { b as CoordinatorApi, c as CoordinatorError, d as CredentialVerifier, E as EscalationPolicyFn, H as Handler, I as IdFactory, P as PRIVILEGED_METHODS, R as ReviewDepthPolicyFn, e as RoutingPolicyFn, S as ScittReceiptVerifier, f as ScittSubmitter, T as TokenVerifier, g as call, m as makeApi } from './api-BOGEBNmX.js';
3
3
  export { MethodName, MethodParams, MethodResult, MethodTable } from './methods.js';
4
4
  export { MemoryStore, Store, WorkspaceRecord } from './storage/store.js';
5
5
  export { ArtefactId, ArtefactRoutingHints, AuditEntry, AuditListener, Deliberation, DeliberationVote, Envelope, Handoff, HandoffTaskItem, JsonPatchOp, Jwk, KeyRecord, Member, Mode, OverrideArtefact, ParticipantType, ParticipantUri, PauseScope, ReviewDecision, RouteDecisionArtefact, SnapshotArtefact, Task, TaskId, TaskRoutingHints, TaskState, WhisperPrompt, Workspace, WorkspaceId, WorkspaceState, modeLE } from './types.js';
package/dist/index.js CHANGED
@@ -211,6 +211,7 @@ var E = {
211
211
  NOT_AUTHORISED: -32011,
212
212
  PATCH_FAILED: -32012,
213
213
  REVIEW_LAPSED: -32013,
214
+ REVIEW_ALREADY_OPEN: -32014,
214
215
  // whisper/1.0 profile (profiles/whisper.md S6)
215
216
  WHISPER_ALREADY_ANSWERED: -32020,
216
217
  WHISPER_LAPSED: -32021,
@@ -237,6 +238,7 @@ var E = {
237
238
  SIG_KEY_NOT_FOUND: -32071,
238
239
  SIG_KEY_REVOKED: -32072,
239
240
  SIG_ROTATION_KEY_MISMATCH: -32073,
241
+ SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
240
242
  // audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
241
243
  SCITT_UNREACHABLE: -32080,
242
244
  SCITT_STATEMENT_REJECTED: -32081,
@@ -273,6 +275,11 @@ function isValidEnvelope(env) {
273
275
  var PatchError = class extends Error {
274
276
  };
275
277
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
278
+ var ARRAY_INDEX_RE = /^(0|[1-9][0-9]*)$/;
279
+ function arrayIndex(seg) {
280
+ if (!ARRAY_INDEX_RE.test(seg)) throw new PatchError(`Array index expected at ${JSON.stringify(seg)}`);
281
+ return Number(seg);
282
+ }
276
283
  function unescape(token) {
277
284
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
278
285
  }
@@ -297,8 +304,8 @@ function navigate(doc, parts) {
297
304
  for (let i = 0; i < parts.length - 1; i++) {
298
305
  const part = parts[i];
299
306
  if (Array.isArray(parent)) {
300
- const idx = Number(part);
301
- if (!Number.isInteger(idx) || idx < 0 || idx >= parent.length) {
307
+ const idx = arrayIndex(part);
308
+ if (idx >= parent.length) {
302
309
  throw new PatchError(`Index out of range at /${parts.slice(0, i + 1).join("/")}`);
303
310
  }
304
311
  parent = parent[idx];
@@ -314,11 +321,7 @@ function navigate(doc, parts) {
314
321
  const last = parts[parts.length - 1];
315
322
  if (Array.isArray(parent)) {
316
323
  if (last === "-") return [parent, "-"];
317
- const idx = Number(last);
318
- if (!Number.isInteger(idx)) {
319
- throw new PatchError(`Array index expected at ${JSON.stringify(last)}`);
320
- }
321
- return [parent, idx];
324
+ return [parent, arrayIndex(last)];
322
325
  }
323
326
  return [parent, last];
324
327
  }
@@ -704,6 +707,12 @@ function registerAuditScitt(coord) {
704
707
  if (!(ws.chain_enabled || coord.options.enableChain)) {
705
708
  return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
706
709
  }
710
+ if (p.from_seq !== void 0 || p.to_seq !== void 0) {
711
+ return { error: rpcError(
712
+ E.PARAMS,
713
+ "Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
714
+ ) };
715
+ }
707
716
  let start = ws.audit.findIndex((e) => e.prev_hash != null);
708
717
  if (start < 0) start = ws.audit.length;
709
718
  const errors = [];
@@ -715,16 +724,35 @@ function registerAuditScitt(coord) {
715
724
  }
716
725
  prev = sha256Hex(Buffer.concat([canonicalize(e.envelope), Buffer.from(expectedPrev, "utf-8")]));
717
726
  }
718
- const storedHead = ws.chain_head ?? ZERO_HASH;
727
+ const storedHead = ws.chain_head === void 0 || ws.chain_head === null ? ZERO_HASH : ws.chain_head;
719
728
  if (prev !== storedHead) {
720
729
  errors.push("chain_head mismatch: replay does not match stored head");
721
730
  }
722
731
  if (errors.length) {
723
732
  return { error: rpcError(E.PARAMS, errors.join("; ")) };
724
733
  }
734
+ const entriesTotal = ws.audit.length;
735
+ const entriesChecked = entriesTotal - start;
736
+ const entriesUnchecked = start;
737
+ if (entriesUnchecked > 0) {
738
+ return { result: {
739
+ status: "not_evaluated",
740
+ ok: false,
741
+ reason: "unchained_prefix",
742
+ entries_total: entriesTotal,
743
+ entries_checked: entriesChecked,
744
+ entries_unchecked: entriesUnchecked,
745
+ checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
746
+ chain_head: storedHead
747
+ } };
748
+ }
725
749
  return { result: {
750
+ status: "verified",
726
751
  ok: true,
727
- entries_checked: ws.audit.length - start,
752
+ entries_total: entriesTotal,
753
+ entries_checked: entriesChecked,
754
+ entries_unchecked: 0,
755
+ checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
728
756
  chain_head: storedHead
729
757
  } };
730
758
  });
@@ -744,7 +772,7 @@ function registerControl(coord) {
744
772
  if (scope === "task") {
745
773
  const task = ws.tasks.get(p.task_id);
746
774
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
747
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
775
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
748
776
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot pause ${task.state} task`) };
749
777
  }
750
778
  task.paused = true;
@@ -801,7 +829,7 @@ function registerControl(coord) {
801
829
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
802
830
  const task = ws.tasks.get(p.task_id);
803
831
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
804
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
832
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
805
833
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
806
834
  }
807
835
  task.state = "cancelled";
@@ -823,7 +851,7 @@ function registerControl(coord) {
823
851
  }));
824
852
  }
825
853
  if (include.includes("open_tasks")) {
826
- 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 }));
854
+ 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 }));
827
855
  }
828
856
  if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
829
857
  if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
@@ -891,6 +919,13 @@ function registerControl(coord) {
891
919
  if (!ws.members.has(assignee)) {
892
920
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
893
921
  }
922
+ const requestedMode = newSpec.mode ?? old.mode;
923
+ if (!modeLE(requestedMode, ws.mode_ceiling)) {
924
+ return { error: rpcError(
925
+ E.MODE_CEILING_EXCEEDED,
926
+ `Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
927
+ ) };
928
+ }
894
929
  const now = coord.now();
895
930
  const newId = coord.ids.taskId();
896
931
  const newTask = {
@@ -902,7 +937,7 @@ function registerControl(coord) {
902
937
  input: newSpec.input ?? {},
903
938
  created_at: now,
904
939
  updated_at: now,
905
- mode: newSpec.mode ?? old.mode,
940
+ mode: requestedMode,
906
941
  supersedes: old.id,
907
942
  history: [{
908
943
  ts: now,
@@ -912,6 +947,8 @@ function registerControl(coord) {
912
947
  }],
913
948
  paused: false
914
949
  };
950
+ if (ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") newTask.review_required = true;
951
+ else if ("review_required" in newSpec) newTask.review_required = !!newSpec.review_required;
915
952
  ws.tasks.set(newId, newTask);
916
953
  old.state = "superseded";
917
954
  old.superseded_by = newId;
@@ -1665,6 +1702,8 @@ var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1665
1702
  "audit.verify_chain",
1666
1703
  "audit.verify_receipt"
1667
1704
  ]);
1705
+ var MAX_IDEMPOTENCY_KEYS = 1e4;
1706
+ var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
1668
1707
  function nowIso(clockMs) {
1669
1708
  const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
1670
1709
  return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
@@ -1683,6 +1722,25 @@ function tagsError(p) {
1683
1722
  function linkHash(envelope, prev) {
1684
1723
  return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
1685
1724
  }
1725
+ function reviewRuleSupported(rule) {
1726
+ if (rule === "any_one_approves" || rule === "all_approve") return true;
1727
+ if (rule.startsWith("quorum:")) {
1728
+ const arg = rule.slice("quorum:".length);
1729
+ return /^[0-9]+$/.test(arg) && parseInt(arg, 10) >= 1;
1730
+ }
1731
+ return false;
1732
+ }
1733
+ function reviewSatisfied(review) {
1734
+ const approvers = new Set(review.decisions.filter((d) => d.kind === "approve").map((d) => d.reviewer));
1735
+ const rule = review.rule || "any_one_approves";
1736
+ if (rule === "all_approve") {
1737
+ const named = review.requested_to.filter((r) => !(r.startsWith("workspace:") || r.startsWith("group:")));
1738
+ if (named.length === 0) return approvers.size >= 1;
1739
+ return named.every((r) => approvers.has(r));
1740
+ }
1741
+ if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
1742
+ return approvers.size >= 1;
1743
+ }
1686
1744
  function reply(env, body) {
1687
1745
  const out = { jsonrpc: "2.0", id: env.id };
1688
1746
  if (body.error !== void 0) out.error = body.error;
@@ -1908,6 +1966,18 @@ var Coordinator = class {
1908
1966
  if (!isValidEnvelope(envelope) || !envelope.method) {
1909
1967
  return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
1910
1968
  }
1969
+ const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
1970
+ let size = 0;
1971
+ try {
1972
+ size = canonicalize(envelope).length;
1973
+ } catch {
1974
+ }
1975
+ if (size > maxBytes) {
1976
+ return reply(envelope, { error: rpcError(
1977
+ E.REQUEST,
1978
+ `Envelope exceeds max_envelope_bytes (${size} > ${maxBytes})`
1979
+ ) });
1980
+ }
1911
1981
  const method = envelope.method;
1912
1982
  const rawParams = envelope.params;
1913
1983
  if (rawParams !== void 0 && rawParams !== null && (typeof rawParams !== "object" || Array.isArray(rawParams))) {
@@ -1932,7 +2002,7 @@ var Coordinator = class {
1932
2002
  const stale = this.checkStepUp(params);
1933
2003
  if (stale) return reply(envelope, { error: stale });
1934
2004
  }
1935
- if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.")) {
2005
+ if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.")) {
1936
2006
  const wsId2 = params.workspace;
1937
2007
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
1938
2008
  if (ws) {
@@ -2041,6 +2111,9 @@ var Coordinator = class {
2041
2111
  if (member.keys.some((k) => k.kid === kid && k.revoked_at !== void 0 && now >= k.revoked_at)) {
2042
2112
  return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
2043
2113
  }
2114
+ if (member.keys.some((k) => k.kid === kid && k.valid_until !== void 0 && now >= k.valid_until)) {
2115
+ return rpcError(E.SIG_KEY_NOT_FOUND, `Key ${kid} is no longer valid`);
2116
+ }
2044
2117
  const key = this.lookupKey(member, kid, ts);
2045
2118
  if (!key) {
2046
2119
  const revoked = member.keys.find((k) => k.kid === kid && k.revoked_at);
@@ -2056,9 +2129,8 @@ var Coordinator = class {
2056
2129
  return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2057
2130
  }
2058
2131
  return null;
2059
- } catch (e) {
2060
- const msg = e instanceof Error ? e.message : String(e);
2061
- return rpcError(E.INTERNAL, `Signature check error: ${msg}`);
2132
+ } catch {
2133
+ return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2062
2134
  }
2063
2135
  }
2064
2136
  /** Lookup the key valid for a given participant at a given time. */
@@ -2182,6 +2254,7 @@ var Coordinator = class {
2182
2254
  state: ws.state,
2183
2255
  mode: ws.mode,
2184
2256
  mode_ceiling: ws.mode_ceiling,
2257
+ max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
2185
2258
  step_up_window_sec: ws.step_up_window_sec,
2186
2259
  profiles: ws.profiles,
2187
2260
  members: Array.from(ws.members.values()).map(memberToDict),
@@ -2322,10 +2395,14 @@ var Coordinator = class {
2322
2395
  history: [{ ts: now, from: p.from, state: "created" }],
2323
2396
  paused: false
2324
2397
  };
2325
- if (task.mode === "trial") task.review_required = true;
2398
+ if (ws.profiles.some((pr) => pr.startsWith("modes/")) && task.mode === "trial") task.review_required = true;
2326
2399
  else if ("review_required" in p) task.review_required = !!p.review_required;
2327
2400
  ws.tasks.set(taskId, task);
2328
- if (typeof p.idempotency_key === "string") ws.idempotency_keys[p.idempotency_key] = taskId;
2401
+ if (typeof p.idempotency_key === "string") {
2402
+ ws.idempotency_keys[p.idempotency_key] = taskId;
2403
+ const keys = Object.keys(ws.idempotency_keys);
2404
+ if (keys.length > MAX_IDEMPOTENCY_KEYS) delete ws.idempotency_keys[keys[0]];
2405
+ }
2329
2406
  return { result: { task_id: taskId, state: "created" } };
2330
2407
  }
2331
2408
  opTaskUpdate(p) {
@@ -2339,7 +2416,7 @@ var Coordinator = class {
2339
2416
  const legal = {
2340
2417
  created: ["in_progress", "declined", "paused"],
2341
2418
  in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
2342
- review_requested: ["in_progress", "completed", "declined"],
2419
+ review_requested: ["in_progress"],
2343
2420
  paused: ["in_progress", "cancelled"]
2344
2421
  };
2345
2422
  if (!legal[task.state]?.includes(newState)) {
@@ -2365,6 +2442,30 @@ var Coordinator = class {
2365
2442
  if (task.state !== "created" && task.state !== "in_progress") {
2366
2443
  return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
2367
2444
  }
2445
+ if (task.review_required) {
2446
+ const now = this.now();
2447
+ task.pending_artefact = p.output;
2448
+ if (!task.review) {
2449
+ const producer = p.from;
2450
+ const eligible = [...ws.members.keys()].filter((uri) => uri !== producer && uri !== task.assignee);
2451
+ if (eligible.length === 0) {
2452
+ return { error: rpcError(
2453
+ E.NOT_AUTHORISED,
2454
+ "Task requires review but has no eligible reviewer (needs a member other than its assignee and completer)"
2455
+ ) };
2456
+ }
2457
+ task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
2458
+ }
2459
+ task.state = "review_requested";
2460
+ task.updated_at = now;
2461
+ task.history.push({
2462
+ ts: now,
2463
+ from: p.from,
2464
+ state: "review_requested",
2465
+ note: "review required; opened on task.complete"
2466
+ });
2467
+ return { result: { state: "review_requested", review_id: task.id } };
2468
+ }
2368
2469
  task.output = p.output;
2369
2470
  if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
2370
2471
  task.state = "completed";
@@ -2415,12 +2516,41 @@ var Coordinator = class {
2415
2516
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
2416
2517
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
2417
2518
  const now = this.now();
2519
+ const rule = p.rule || "any_one_approves";
2520
+ if (!reviewRuleSupported(rule)) {
2521
+ return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
2522
+ }
2523
+ if (task.state === "review_requested" && task.review) {
2524
+ if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
2525
+ return { error: rpcError(
2526
+ E.REVIEW_ALREADY_OPEN,
2527
+ "A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
2528
+ ) };
2529
+ }
2530
+ if (rule !== task.review.rule) {
2531
+ return { error: rpcError(
2532
+ E.REVIEW_ALREADY_OPEN,
2533
+ `Cannot change the decision rule of an open review (currently ${task.review.rule})`
2534
+ ) };
2535
+ }
2536
+ for (const r of reviewers) {
2537
+ if (!task.review.requested_to.includes(r)) task.review.requested_to.push(r);
2538
+ }
2539
+ if (p.deadline !== void 0) task.review.deadline = p.deadline;
2540
+ task.updated_at = now;
2541
+ return { result: {
2542
+ state: "review_requested",
2543
+ review_id: task.id,
2544
+ amended: true,
2545
+ requested_to: [...task.review.requested_to]
2546
+ } };
2547
+ }
2418
2548
  task.state = "review_requested";
2419
2549
  task.updated_at = now;
2420
2550
  task.review = {
2421
2551
  requested_at: now,
2422
2552
  requested_to: reviewers,
2423
- rule: p.rule || "any_one_approves",
2553
+ rule,
2424
2554
  deadline: p.deadline,
2425
2555
  decisions: []
2426
2556
  };
@@ -2428,6 +2558,37 @@ var Coordinator = class {
2428
2558
  task.pending_artefact = p.artefact;
2429
2559
  return { result: { state: "review_requested", review_id: task.id } };
2430
2560
  }
2561
+ /**
2562
+ * Verify an optional `approved_artefact_digest` against the artefact under
2563
+ * review (review/1.0 S3.2).
2564
+ *
2565
+ * The digest sits in params, so it is inside whatever the envelope signature
2566
+ * covers: the decision then attests the content the reviewer saw rather than
2567
+ * a task reference, and a relying party can check it without trusting the
2568
+ * Coordinator that produced it. Absent, behaviour is exactly as before.
2569
+ *
2570
+ * On mismatch nothing is recorded and no state changes, so a client that
2571
+ * computes the wrong digest refuses only its own decision.
2572
+ */
2573
+ checkArtefactDigest(p, task) {
2574
+ const declared = p.approved_artefact_digest;
2575
+ if (declared === void 0 || declared === null) return null;
2576
+ if (typeof declared !== "string") {
2577
+ return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
2578
+ }
2579
+ if (task.pending_artefact === void 0) {
2580
+ return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
2581
+ }
2582
+ const actual = contentHash(task.pending_artefact);
2583
+ if (declared !== actual) {
2584
+ return { error: rpcError(
2585
+ E.SIG_ARTEFACT_DIGEST_MISMATCH,
2586
+ "approved_artefact_digest does not match the artefact under review",
2587
+ { declared, actual }
2588
+ ) };
2589
+ }
2590
+ return null;
2591
+ }
2431
2592
  opDecide(p, kind) {
2432
2593
  const miss = missing(p, ["workspace", "from", "task_id"]);
2433
2594
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
@@ -2444,6 +2605,8 @@ var Coordinator = class {
2444
2605
  }
2445
2606
  const notReviewer = this.requireReviewer(task, p.from);
2446
2607
  if (notReviewer) return notReviewer;
2608
+ const digestError = this.checkArtefactDigest(p, task);
2609
+ if (digestError) return digestError;
2447
2610
  const now = this.now();
2448
2611
  task.review.decisions.push({
2449
2612
  reviewer: p.from,
@@ -2453,8 +2616,10 @@ var Coordinator = class {
2453
2616
  tags: p.tags
2454
2617
  });
2455
2618
  if (kind === "approve") {
2456
- task.output = task.pending_artefact;
2457
- task.state = "completed";
2619
+ if (reviewSatisfied(task.review)) {
2620
+ task.output = task.pending_artefact;
2621
+ task.state = "completed";
2622
+ }
2458
2623
  } else {
2459
2624
  task.state = p.request_revision ? "in_progress" : "declined";
2460
2625
  }
@@ -2478,6 +2643,8 @@ var Coordinator = class {
2478
2643
  }
2479
2644
  const notReviewer = this.requireReviewer(task, p.from);
2480
2645
  if (notReviewer) return notReviewer;
2646
+ const digestError = this.checkArtefactDigest(p, task);
2647
+ if (digestError) return digestError;
2481
2648
  const base = task.pending_artefact;
2482
2649
  if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
2483
2650
  let applied;