@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 +6 -1
- package/dist/{api-CPEk0V6i.d.ts → api-BOGEBNmX.d.ts} +15 -0
- package/dist/{api-DE78DA0o.d.cts → api-DiiL0sil.d.cts} +15 -0
- package/dist/api.d.cts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/index.cjs +190 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +190 -23
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc.cjs +2 -0
- package/dist/jsonrpc.cjs.map +1 -1
- package/dist/jsonrpc.d.cts +2 -0
- package/dist/jsonrpc.d.ts +2 -0
- package/dist/jsonrpc.js +2 -0
- package/dist/jsonrpc.js.map +1 -1
- package/dist/methods.cjs.map +1 -1
- package/dist/methods.d.cts +22 -2
- package/dist/methods.d.ts +22 -2
- package/dist/patch.cjs +8 -7
- package/dist/patch.cjs.map +1 -1
- package/dist/patch.js +8 -7
- package/dist/patch.js.map +1 -1
- package/dist/storage/sqlite.cjs +5 -2
- package/dist/storage/sqlite.cjs.map +1 -1
- package/dist/storage/sqlite.js +4 -9
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/storage/store.cjs.map +1 -1
- package/dist/storage/store.d.cts +26 -1
- package/dist/storage/store.d.ts +26 -1
- package/dist/storage/store.js.map +1 -1
- package/package.json +79 -27
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-
|
|
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-
|
|
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 =
|
|
349
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
2108
|
-
|
|
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")
|
|
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"
|
|
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
|
|
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.
|
|
2505
|
-
|
|
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;
|