@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/README.md CHANGED
@@ -216,7 +216,12 @@ task's `assignee`.
216
216
 
217
217
  Audit entries are hash-linked when the profile is active.
218
218
  `audit.verify_chain` replays the chain locally and confirms
219
- `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.
220
225
 
221
226
  `audit.submit_to_scitt` builds a SCITT-shaped signed statement for
222
227
  each audit entry in the requested range and passes it to a
@@ -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;
@@ -175,6 +177,19 @@ declare class Coordinator {
175
177
  private opTaskComplete;
176
178
  private opAuditRead;
177
179
  private opReviewRequest;
180
+ /**
181
+ * Verify an optional `approved_artefact_digest` against the artefact under
182
+ * review (review/1.0 S3.2).
183
+ *
184
+ * The digest sits in params, so it is inside whatever the envelope signature
185
+ * covers: the decision then attests the content the reviewer saw rather than
186
+ * a task reference, and a relying party can check it without trusting the
187
+ * Coordinator that produced it. Absent, behaviour is exactly as before.
188
+ *
189
+ * On mismatch nothing is recorded and no state changes, so a client that
190
+ * computes the wrong digest refuses only its own decision.
191
+ */
192
+ private checkArtefactDigest;
178
193
  private opDecide;
179
194
  private opDecideOverride;
180
195
  private opAbstainDeclare;
@@ -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;
@@ -175,6 +177,19 @@ declare class Coordinator {
175
177
  private opTaskComplete;
176
178
  private opAuditRead;
177
179
  private opReviewRequest;
180
+ /**
181
+ * Verify an optional `approved_artefact_digest` against the artefact under
182
+ * review (review/1.0 S3.2).
183
+ *
184
+ * The digest sits in params, so it is inside whatever the envelope signature
185
+ * covers: the decision then attests the content the reviewer saw rather than
186
+ * a task reference, and a relying party can check it without trusting the
187
+ * Coordinator that produced it. Absent, behaviour is exactly as before.
188
+ *
189
+ * On mismatch nothing is recorded and no state changes, so a client that
190
+ * computes the wrong digest refuses only its own decision.
191
+ */
192
+ private checkArtefactDigest;
178
193
  private opDecide;
179
194
  private opDecideOverride;
180
195
  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-DE78DA0o.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-CPEk0V6i.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
@@ -259,6 +259,7 @@ var E = {
259
259
  NOT_AUTHORISED: -32011,
260
260
  PATCH_FAILED: -32012,
261
261
  REVIEW_LAPSED: -32013,
262
+ REVIEW_ALREADY_OPEN: -32014,
262
263
  // whisper/1.0 profile (profiles/whisper.md S6)
263
264
  WHISPER_ALREADY_ANSWERED: -32020,
264
265
  WHISPER_LAPSED: -32021,
@@ -285,6 +286,7 @@ var E = {
285
286
  SIG_KEY_NOT_FOUND: -32071,
286
287
  SIG_KEY_REVOKED: -32072,
287
288
  SIG_ROTATION_KEY_MISMATCH: -32073,
289
+ SIG_ARTEFACT_DIGEST_MISMATCH: -32074,
288
290
  // audit-scitt/1.0 profile (profiles/audit-scitt.md S8)
289
291
  SCITT_UNREACHABLE: -32080,
290
292
  SCITT_STATEMENT_REJECTED: -32081,
@@ -321,6 +323,11 @@ function isValidEnvelope(env) {
321
323
  var PatchError = class extends Error {
322
324
  };
323
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
+ }
324
331
  function unescape(token) {
325
332
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
326
333
  }
@@ -345,8 +352,8 @@ function navigate(doc, parts) {
345
352
  for (let i = 0; i < parts.length - 1; i++) {
346
353
  const part = parts[i];
347
354
  if (Array.isArray(parent)) {
348
- const idx = Number(part);
349
- if (!Number.isInteger(idx) || idx < 0 || idx >= parent.length) {
355
+ const idx = arrayIndex(part);
356
+ if (idx >= parent.length) {
350
357
  throw new PatchError(`Index out of range at /${parts.slice(0, i + 1).join("/")}`);
351
358
  }
352
359
  parent = parent[idx];
@@ -362,11 +369,7 @@ function navigate(doc, parts) {
362
369
  const last = parts[parts.length - 1];
363
370
  if (Array.isArray(parent)) {
364
371
  if (last === "-") return [parent, "-"];
365
- const idx = Number(last);
366
- if (!Number.isInteger(idx)) {
367
- throw new PatchError(`Array index expected at ${JSON.stringify(last)}`);
368
- }
369
- return [parent, idx];
372
+ return [parent, arrayIndex(last)];
370
373
  }
371
374
  return [parent, last];
372
375
  }
@@ -752,6 +755,12 @@ function registerAuditScitt(coord) {
752
755
  if (!(ws.chain_enabled || coord.options.enableChain)) {
753
756
  return { error: rpcError(E.PARAMS, "Chain not enabled for this workspace") };
754
757
  }
758
+ if (p.from_seq !== void 0 || p.to_seq !== void 0) {
759
+ return { error: rpcError(
760
+ E.PARAMS,
761
+ "Range verification is not implemented; omit from_seq and to_seq to verify the whole log."
762
+ ) };
763
+ }
755
764
  let start = ws.audit.findIndex((e) => e.prev_hash != null);
756
765
  if (start < 0) start = ws.audit.length;
757
766
  const errors = [];
@@ -763,16 +772,35 @@ function registerAuditScitt(coord) {
763
772
  }
764
773
  prev = sha256Hex(Buffer.concat([canonicalize(e.envelope), Buffer.from(expectedPrev, "utf-8")]));
765
774
  }
766
- const storedHead = ws.chain_head ?? ZERO_HASH;
775
+ const storedHead = ws.chain_head === void 0 || ws.chain_head === null ? ZERO_HASH : ws.chain_head;
767
776
  if (prev !== storedHead) {
768
777
  errors.push("chain_head mismatch: replay does not match stored head");
769
778
  }
770
779
  if (errors.length) {
771
780
  return { error: rpcError(E.PARAMS, errors.join("; ")) };
772
781
  }
782
+ const entriesTotal = ws.audit.length;
783
+ const entriesChecked = entriesTotal - start;
784
+ const entriesUnchecked = start;
785
+ if (entriesUnchecked > 0) {
786
+ return { result: {
787
+ status: "not_evaluated",
788
+ ok: false,
789
+ reason: "unchained_prefix",
790
+ entries_total: entriesTotal,
791
+ entries_checked: entriesChecked,
792
+ entries_unchecked: entriesUnchecked,
793
+ checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
794
+ chain_head: storedHead
795
+ } };
796
+ }
773
797
  return { result: {
798
+ status: "verified",
774
799
  ok: true,
775
- entries_checked: ws.audit.length - start,
800
+ entries_total: entriesTotal,
801
+ entries_checked: entriesChecked,
802
+ entries_unchecked: 0,
803
+ checked_from_seq: entriesChecked > 0 ? ws.audit[start].seq : null,
776
804
  chain_head: storedHead
777
805
  } };
778
806
  });
@@ -792,7 +820,7 @@ function registerControl(coord) {
792
820
  if (scope === "task") {
793
821
  const task = ws.tasks.get(p.task_id);
794
822
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
795
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
823
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
796
824
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot pause ${task.state} task`) };
797
825
  }
798
826
  task.paused = true;
@@ -849,7 +877,7 @@ function registerControl(coord) {
849
877
  if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
850
878
  const task = ws.tasks.get(p.task_id);
851
879
  if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
852
- if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
880
+ if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
853
881
  return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
854
882
  }
855
883
  task.state = "cancelled";
@@ -871,7 +899,7 @@ function registerControl(coord) {
871
899
  }));
872
900
  }
873
901
  if (include.includes("open_tasks")) {
874
- 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 }));
875
903
  }
876
904
  if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
877
905
  if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
@@ -939,6 +967,13 @@ function registerControl(coord) {
939
967
  if (!ws.members.has(assignee)) {
940
968
  return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
941
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
+ }
942
977
  const now = coord.now();
943
978
  const newId = coord.ids.taskId();
944
979
  const newTask = {
@@ -950,7 +985,7 @@ function registerControl(coord) {
950
985
  input: newSpec.input ?? {},
951
986
  created_at: now,
952
987
  updated_at: now,
953
- mode: newSpec.mode ?? old.mode,
988
+ mode: requestedMode,
954
989
  supersedes: old.id,
955
990
  history: [{
956
991
  ts: now,
@@ -960,6 +995,8 @@ function registerControl(coord) {
960
995
  }],
961
996
  paused: false
962
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;
963
1000
  ws.tasks.set(newId, newTask);
964
1001
  old.state = "superseded";
965
1002
  old.superseded_by = newId;
@@ -1713,6 +1750,8 @@ var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1713
1750
  "audit.verify_chain",
1714
1751
  "audit.verify_receipt"
1715
1752
  ]);
1753
+ var MAX_IDEMPOTENCY_KEYS = 1e4;
1754
+ var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
1716
1755
  function nowIso(clockMs) {
1717
1756
  const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
1718
1757
  return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
@@ -1731,6 +1770,25 @@ function tagsError(p) {
1731
1770
  function linkHash(envelope, prev) {
1732
1771
  return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
1733
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
+ }
1734
1792
  function reply(env, body) {
1735
1793
  const out = { jsonrpc: "2.0", id: env.id };
1736
1794
  if (body.error !== void 0) out.error = body.error;
@@ -1956,6 +2014,18 @@ var Coordinator = class {
1956
2014
  if (!isValidEnvelope(envelope) || !envelope.method) {
1957
2015
  return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
1958
2016
  }
2017
+ const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
2018
+ let size = 0;
2019
+ try {
2020
+ size = canonicalize(envelope).length;
2021
+ } catch {
2022
+ }
2023
+ if (size > maxBytes) {
2024
+ return reply(envelope, { error: rpcError(
2025
+ E.REQUEST,
2026
+ `Envelope exceeds max_envelope_bytes (${size} > ${maxBytes})`
2027
+ ) });
2028
+ }
1959
2029
  const method = envelope.method;
1960
2030
  const rawParams = envelope.params;
1961
2031
  if (rawParams !== void 0 && rawParams !== null && (typeof rawParams !== "object" || Array.isArray(rawParams))) {
@@ -1980,7 +2050,7 @@ var Coordinator = class {
1980
2050
  const stale = this.checkStepUp(params);
1981
2051
  if (stale) return reply(envelope, { error: stale });
1982
2052
  }
1983
- if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.")) {
2053
+ if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.")) {
1984
2054
  const wsId2 = params.workspace;
1985
2055
  const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
1986
2056
  if (ws) {
@@ -2089,6 +2159,9 @@ var Coordinator = class {
2089
2159
  if (member.keys.some((k) => k.kid === kid && k.revoked_at !== void 0 && now >= k.revoked_at)) {
2090
2160
  return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
2091
2161
  }
2162
+ if (member.keys.some((k) => k.kid === kid && k.valid_until !== void 0 && now >= k.valid_until)) {
2163
+ return rpcError(E.SIG_KEY_NOT_FOUND, `Key ${kid} is no longer valid`);
2164
+ }
2092
2165
  const key = this.lookupKey(member, kid, ts);
2093
2166
  if (!key) {
2094
2167
  const revoked = member.keys.find((k) => k.kid === kid && k.revoked_at);
@@ -2104,9 +2177,8 @@ var Coordinator = class {
2104
2177
  return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2105
2178
  }
2106
2179
  return null;
2107
- } catch (e) {
2108
- const msg = e instanceof Error ? e.message : String(e);
2109
- return rpcError(E.INTERNAL, `Signature check error: ${msg}`);
2180
+ } catch {
2181
+ return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
2110
2182
  }
2111
2183
  }
2112
2184
  /** Lookup the key valid for a given participant at a given time. */
@@ -2230,6 +2302,7 @@ var Coordinator = class {
2230
2302
  state: ws.state,
2231
2303
  mode: ws.mode,
2232
2304
  mode_ceiling: ws.mode_ceiling,
2305
+ max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
2233
2306
  step_up_window_sec: ws.step_up_window_sec,
2234
2307
  profiles: ws.profiles,
2235
2308
  members: Array.from(ws.members.values()).map(memberToDict),
@@ -2370,10 +2443,14 @@ var Coordinator = class {
2370
2443
  history: [{ ts: now, from: p.from, state: "created" }],
2371
2444
  paused: false
2372
2445
  };
2373
- if (task.mode === "trial") task.review_required = true;
2446
+ if (ws.profiles.some((pr) => pr.startsWith("modes/")) && task.mode === "trial") task.review_required = true;
2374
2447
  else if ("review_required" in p) task.review_required = !!p.review_required;
2375
2448
  ws.tasks.set(taskId, task);
2376
- if (typeof p.idempotency_key === "string") ws.idempotency_keys[p.idempotency_key] = taskId;
2449
+ if (typeof p.idempotency_key === "string") {
2450
+ ws.idempotency_keys[p.idempotency_key] = taskId;
2451
+ const keys = Object.keys(ws.idempotency_keys);
2452
+ if (keys.length > MAX_IDEMPOTENCY_KEYS) delete ws.idempotency_keys[keys[0]];
2453
+ }
2377
2454
  return { result: { task_id: taskId, state: "created" } };
2378
2455
  }
2379
2456
  opTaskUpdate(p) {
@@ -2387,7 +2464,7 @@ var Coordinator = class {
2387
2464
  const legal = {
2388
2465
  created: ["in_progress", "declined", "paused"],
2389
2466
  in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
2390
- review_requested: ["in_progress", "completed", "declined"],
2467
+ review_requested: ["in_progress"],
2391
2468
  paused: ["in_progress", "cancelled"]
2392
2469
  };
2393
2470
  if (!legal[task.state]?.includes(newState)) {
@@ -2413,6 +2490,30 @@ var Coordinator = class {
2413
2490
  if (task.state !== "created" && task.state !== "in_progress") {
2414
2491
  return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
2415
2492
  }
2493
+ if (task.review_required) {
2494
+ const now = this.now();
2495
+ task.pending_artefact = p.output;
2496
+ if (!task.review) {
2497
+ const producer = p.from;
2498
+ const eligible = [...ws.members.keys()].filter((uri) => uri !== producer && uri !== task.assignee);
2499
+ if (eligible.length === 0) {
2500
+ return { error: rpcError(
2501
+ E.NOT_AUTHORISED,
2502
+ "Task requires review but has no eligible reviewer (needs a member other than its assignee and completer)"
2503
+ ) };
2504
+ }
2505
+ task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
2506
+ }
2507
+ task.state = "review_requested";
2508
+ task.updated_at = now;
2509
+ task.history.push({
2510
+ ts: now,
2511
+ from: p.from,
2512
+ state: "review_requested",
2513
+ note: "review required; opened on task.complete"
2514
+ });
2515
+ return { result: { state: "review_requested", review_id: task.id } };
2516
+ }
2416
2517
  task.output = p.output;
2417
2518
  if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
2418
2519
  task.state = "completed";
@@ -2463,12 +2564,41 @@ var Coordinator = class {
2463
2564
  const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
2464
2565
  if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
2465
2566
  const now = this.now();
2567
+ const rule = p.rule || "any_one_approves";
2568
+ if (!reviewRuleSupported(rule)) {
2569
+ return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
2570
+ }
2571
+ if (task.state === "review_requested" && task.review) {
2572
+ if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
2573
+ return { error: rpcError(
2574
+ E.REVIEW_ALREADY_OPEN,
2575
+ "A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
2576
+ ) };
2577
+ }
2578
+ if (rule !== task.review.rule) {
2579
+ return { error: rpcError(
2580
+ E.REVIEW_ALREADY_OPEN,
2581
+ `Cannot change the decision rule of an open review (currently ${task.review.rule})`
2582
+ ) };
2583
+ }
2584
+ for (const r of reviewers) {
2585
+ if (!task.review.requested_to.includes(r)) task.review.requested_to.push(r);
2586
+ }
2587
+ if (p.deadline !== void 0) task.review.deadline = p.deadline;
2588
+ task.updated_at = now;
2589
+ return { result: {
2590
+ state: "review_requested",
2591
+ review_id: task.id,
2592
+ amended: true,
2593
+ requested_to: [...task.review.requested_to]
2594
+ } };
2595
+ }
2466
2596
  task.state = "review_requested";
2467
2597
  task.updated_at = now;
2468
2598
  task.review = {
2469
2599
  requested_at: now,
2470
2600
  requested_to: reviewers,
2471
- rule: p.rule || "any_one_approves",
2601
+ rule,
2472
2602
  deadline: p.deadline,
2473
2603
  decisions: []
2474
2604
  };
@@ -2476,6 +2606,37 @@ var Coordinator = class {
2476
2606
  task.pending_artefact = p.artefact;
2477
2607
  return { result: { state: "review_requested", review_id: task.id } };
2478
2608
  }
2609
+ /**
2610
+ * Verify an optional `approved_artefact_digest` against the artefact under
2611
+ * review (review/1.0 S3.2).
2612
+ *
2613
+ * The digest sits in params, so it is inside whatever the envelope signature
2614
+ * covers: the decision then attests the content the reviewer saw rather than
2615
+ * a task reference, and a relying party can check it without trusting the
2616
+ * Coordinator that produced it. Absent, behaviour is exactly as before.
2617
+ *
2618
+ * On mismatch nothing is recorded and no state changes, so a client that
2619
+ * computes the wrong digest refuses only its own decision.
2620
+ */
2621
+ checkArtefactDigest(p, task) {
2622
+ const declared = p.approved_artefact_digest;
2623
+ if (declared === void 0 || declared === null) return null;
2624
+ if (typeof declared !== "string") {
2625
+ return { error: rpcError(E.PARAMS, "approved_artefact_digest must be a string") };
2626
+ }
2627
+ if (task.pending_artefact === void 0) {
2628
+ return { error: rpcError(E.PARAMS, "No artefact under review to bind a digest to") };
2629
+ }
2630
+ const actual = contentHash(task.pending_artefact);
2631
+ if (declared !== actual) {
2632
+ return { error: rpcError(
2633
+ E.SIG_ARTEFACT_DIGEST_MISMATCH,
2634
+ "approved_artefact_digest does not match the artefact under review",
2635
+ { declared, actual }
2636
+ ) };
2637
+ }
2638
+ return null;
2639
+ }
2479
2640
  opDecide(p, kind) {
2480
2641
  const miss = missing(p, ["workspace", "from", "task_id"]);
2481
2642
  if (miss) return { error: rpcError(E.PARAMS, `Missing field: ${miss}`) };
@@ -2492,6 +2653,8 @@ var Coordinator = class {
2492
2653
  }
2493
2654
  const notReviewer = this.requireReviewer(task, p.from);
2494
2655
  if (notReviewer) return notReviewer;
2656
+ const digestError = this.checkArtefactDigest(p, task);
2657
+ if (digestError) return digestError;
2495
2658
  const now = this.now();
2496
2659
  task.review.decisions.push({
2497
2660
  reviewer: p.from,
@@ -2501,8 +2664,10 @@ var Coordinator = class {
2501
2664
  tags: p.tags
2502
2665
  });
2503
2666
  if (kind === "approve") {
2504
- task.output = task.pending_artefact;
2505
- task.state = "completed";
2667
+ if (reviewSatisfied(task.review)) {
2668
+ task.output = task.pending_artefact;
2669
+ task.state = "completed";
2670
+ }
2506
2671
  } else {
2507
2672
  task.state = p.request_revision ? "in_progress" : "declined";
2508
2673
  }
@@ -2526,6 +2691,8 @@ var Coordinator = class {
2526
2691
  }
2527
2692
  const notReviewer = this.requireReviewer(task, p.from);
2528
2693
  if (notReviewer) return notReviewer;
2694
+ const digestError = this.checkArtefactDigest(p, task);
2695
+ if (digestError) return digestError;
2529
2696
  const base = task.pending_artefact;
2530
2697
  if (base === void 0) return { error: rpcError(E.PARAMS, "No base artefact for override") };
2531
2698
  let applied;