@kontourai/survey 6.0.0 → 7.0.0

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.
Files changed (32) hide show
  1. package/dist/src/candidate-verification.d.ts +175 -0
  2. package/dist/src/candidate-verification.js +334 -0
  3. package/dist/src/canonical-reviewed-trust-input.js +30 -1
  4. package/dist/src/console/review-console-server.js +22 -4
  5. package/dist/src/index.d.ts +6 -4
  6. package/dist/src/index.js +3 -2
  7. package/dist/src/mcp/review-mcp.js +161 -44
  8. package/dist/src/review-resource.d.ts +16 -0
  9. package/dist/src/review-session-file.d.ts +7 -0
  10. package/dist/src/review-workbench/audit-rows.d.ts +1 -1
  11. package/dist/src/review-workbench/audit-rows.js +1 -0
  12. package/dist/src/review-workbench/queue-binding.d.ts +45 -2
  13. package/dist/src/review-workbench/queue-binding.js +45 -0
  14. package/dist/src/review-workbench/review-presentation.d.ts +60 -0
  15. package/dist/src/review-workbench/review-presentation.js +107 -10
  16. package/dist/src/review-workbench/review-queue-session.d.ts +47 -4
  17. package/dist/src/review-workbench/review-queue-session.js +73 -8
  18. package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
  19. package/dist/src/review-workbench/review-session-replay.js +35 -1
  20. package/dist/src/review-workbench/review-workbench-css.generated.js +96 -1
  21. package/dist/src/review-workbench/review-workbench-element.d.ts +10 -0
  22. package/dist/src/review-workbench/review-workbench-element.js +21 -3
  23. package/dist/src/review-workbench/review-workbench.css +96 -1
  24. package/dist/src/review-workbench/review-workbench.d.ts +28 -8
  25. package/dist/src/review-workbench/review-workbench.js +174 -63
  26. package/dist/src/review-workbench/review-workbench.standalone.css +96 -1
  27. package/dist/src/review-workbench/server-review-session.d.ts +18 -0
  28. package/dist/src/review-workbench/server-review-session.js +20 -2
  29. package/dist/src/surface-reviewed-extraction.d.ts +23 -0
  30. package/dist/src/surface-reviewed-extraction.js +64 -1
  31. package/dist/src/to-surface.js +32 -3
  32. package/package.json +1 -1
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Support verification: a port for an independent check of whether a
3
+ * candidate's value is supported by its evidence, and the immutable record of
4
+ * what such a check said.
5
+ *
6
+ * A {@link CandidateVerification} is a record of a verifier's output, bound to
7
+ * the exact value and evidence it checked. It is not a trust decision: nothing
8
+ * in Survey reads it to route, auto-accept, or calibrate a candidate. Survey
9
+ * ships no verifier implementations; producers or separate adapter packages
10
+ * implement {@link SupportVerifier}.
11
+ *
12
+ * Runtime-neutral (no Node built-ins) so the review workbench bundle can
13
+ * validate records in the browser.
14
+ */
15
+ export declare const candidateVerificationKind: "survey.candidate-verification";
16
+ export type SupportVerificationMethod = "deterministic" | "model" | "human";
17
+ export type SupportVerificationResult = "supported" | "contradicted" | "not-addressed" | "abstain";
18
+ export type SupportAbstainReason = "malformed" | "empty" | "timeout" | "unsupported" | "error";
19
+ export interface SupportEvidence {
20
+ id: string;
21
+ excerpt: string;
22
+ locator: string;
23
+ context?: string;
24
+ }
25
+ export interface SupportVerificationInput {
26
+ candidateId: string;
27
+ value: unknown;
28
+ /** Declared value type when the producer knows it (for example `ReviewValueType`). */
29
+ valueType?: string;
30
+ /** At least one item; ids are unique. */
31
+ evidence: SupportEvidence[];
32
+ }
33
+ /**
34
+ * What a verifier returns. `score` has no meaning unless a calibration record
35
+ * says otherwise: it is optional, never defaulted, and never feeds a
36
+ * confidence. An abstention carries its reason and no score.
37
+ */
38
+ export type SupportVerdict = {
39
+ result: "supported" | "contradicted" | "not-addressed";
40
+ score?: number;
41
+ } | {
42
+ result: "abstain";
43
+ abstainReason: SupportAbstainReason;
44
+ };
45
+ /** The port. Implementations live with producers or in adapter packages. */
46
+ export interface SupportVerifier {
47
+ readonly id: string;
48
+ readonly version: string;
49
+ readonly method: SupportVerificationMethod;
50
+ verify(input: Readonly<SupportVerificationInput>, options?: {
51
+ signal?: AbortSignal;
52
+ }): Promise<SupportVerdict>;
53
+ }
54
+ export interface CandidateVerification {
55
+ /** `sha256:` digest of every other field; recomputed on validation. */
56
+ id: string;
57
+ kind: typeof candidateVerificationKind;
58
+ schemaVersion: 1;
59
+ candidateId: string;
60
+ /** Sorted, unique ids of the evidence the verifier was given. */
61
+ evidenceIds: string[];
62
+ /** {@link valueDigest} of the value checked. */
63
+ valueDigest: string;
64
+ /** {@link supportVerificationInputDigest} of the full input checked. */
65
+ inputDigest: string;
66
+ verifier: {
67
+ id: string;
68
+ version: string;
69
+ };
70
+ method: SupportVerificationMethod;
71
+ result: SupportVerificationResult;
72
+ /** Present exactly when `result` is `abstain`. */
73
+ abstainReason?: SupportAbstainReason;
74
+ /** Uncalibrated unless a calibration record says otherwise. Never defaulted. */
75
+ score?: number;
76
+ createdAt: string;
77
+ }
78
+ export interface BuildCandidateVerificationInput {
79
+ input: SupportVerificationInput;
80
+ verifier: {
81
+ id: string;
82
+ version: string;
83
+ method: SupportVerificationMethod;
84
+ };
85
+ verdict: SupportVerdict;
86
+ createdAt: string;
87
+ }
88
+ /**
89
+ * `"sha256:" + hex SHA-256` of the canonical JSON of a value: the same
90
+ * definition as Surface's `valueDigest`, so a record binds to a value the way a
91
+ * Surface claim does.
92
+ */
93
+ export declare function valueDigest(value: unknown): string;
94
+ /** Digest of everything a verifier was given: candidate id, value, value type and evidence (ordered by id). */
95
+ export declare function supportVerificationInputDigest(input: SupportVerificationInput): string;
96
+ /**
97
+ * Builds a frozen record from a verifier's verdict. Throws on a malformed
98
+ * verdict: use {@link runSupportVerifier} to turn a verifier's failure into an
99
+ * abstention instead.
100
+ */
101
+ export declare function buildCandidateVerification(input: BuildCandidateVerificationInput): CandidateVerification;
102
+ /** How long {@link runSupportVerifier} waits when no `timeoutMs` is given. */
103
+ export declare const DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS = 30000;
104
+ /**
105
+ * The largest `timeoutMs` {@link runSupportVerifier} accepts: the largest
106
+ * delay a timer honours (2^31 - 1 ms). A larger delay would be clamped to
107
+ * 1 ms, turning a long bound into an immediate timeout, so it is refused.
108
+ */
109
+ export declare const MAX_SUPPORT_VERIFIER_TIMEOUT_MS = 2147483647;
110
+ export interface RunSupportVerifierOptions {
111
+ /**
112
+ * Abstain with `timeout` when the verifier has not answered by then.
113
+ * Defaults to {@link DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS}; a verifier is
114
+ * never waited on without a bound. At most
115
+ * {@link MAX_SUPPORT_VERIFIER_TIMEOUT_MS}.
116
+ */
117
+ timeoutMs?: number;
118
+ /** Clock for `createdAt`; defaults to the current time. */
119
+ now?: () => string;
120
+ }
121
+ /**
122
+ * Calls a verifier and records what it said. A verifier that throws abstains
123
+ * with `error`, one that returns nothing with `empty`, one that returns a
124
+ * malformed verdict with `malformed`, and one that exceeds `timeoutMs`
125
+ * (default {@link DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS}) with `timeout`. None of these ever records a pass or a fail.
126
+ *
127
+ * Throws only for caller errors: a malformed input, or a verifier without a
128
+ * usable identity (a record needs one).
129
+ */
130
+ export declare function runSupportVerifier(verifier: SupportVerifier, input: SupportVerificationInput, options?: RunSupportVerifierOptions): Promise<CandidateVerification>;
131
+ /**
132
+ * Validates a record read back from storage and returns a frozen copy. Throws
133
+ * on any unknown field, inconsistent verdict, or an id that is not the digest
134
+ * of the record's own fields (an edited or inconsistent record). The id is a
135
+ * content digest, not a signature: it does not prove who wrote the record, and
136
+ * a record rebuilt with a recomputed id validates.
137
+ */
138
+ export declare function validateCandidateVerification(value: unknown): CandidateVerification;
139
+ /** The candidate a fold reads records for: its id and its value as it is now. */
140
+ export interface CandidateVerificationSubject {
141
+ candidateId: string;
142
+ value: unknown;
143
+ valueType?: string;
144
+ /** When supplied, records must also match the full input digest. */
145
+ evidence?: SupportEvidence[];
146
+ }
147
+ export type CandidateVerificationStatus = "not-evaluated" | "abstained" | "evaluated";
148
+ export type CandidateVerificationInapplicableReason = "other-candidate" | "value-changed" | "input-changed";
149
+ export interface FoldCandidateVerificationsResult {
150
+ /**
151
+ * `not-evaluated` when no record applies to the subject's current value,
152
+ * `abstained` when every applicable record is an abstention, `evaluated`
153
+ * when at least one applicable record carries a verdict (supported,
154
+ * contradicted or not-addressed). Not itself a verdict.
155
+ */
156
+ status: CandidateVerificationStatus;
157
+ /** Valid records bound to this candidate and value, ordered by id. Abstentions included. */
158
+ applicable: readonly CandidateVerification[];
159
+ /** Valid records bound to another candidate, value, or input. */
160
+ inapplicable: readonly {
161
+ record: CandidateVerification;
162
+ reason: CandidateVerificationInapplicableReason;
163
+ }[];
164
+ /** Records that failed validation, by input position. Never read further. */
165
+ rejected: readonly {
166
+ index: number;
167
+ reason: string;
168
+ }[];
169
+ }
170
+ /**
171
+ * Sorts records for one candidate into those that apply to its current value,
172
+ * those bound to something else, and those that failed validation. Never
173
+ * selects a winner among applicable records and never derives a trust state.
174
+ */
175
+ export declare function foldCandidateVerifications(subject: CandidateVerificationSubject, records: readonly unknown[]): FoldCandidateVerificationsResult;
@@ -0,0 +1,334 @@
1
+ import { sha256Hex } from "./sha256.js";
2
+ /**
3
+ * Support verification: a port for an independent check of whether a
4
+ * candidate's value is supported by its evidence, and the immutable record of
5
+ * what such a check said.
6
+ *
7
+ * A {@link CandidateVerification} is a record of a verifier's output, bound to
8
+ * the exact value and evidence it checked. It is not a trust decision: nothing
9
+ * in Survey reads it to route, auto-accept, or calibrate a candidate. Survey
10
+ * ships no verifier implementations; producers or separate adapter packages
11
+ * implement {@link SupportVerifier}.
12
+ *
13
+ * Runtime-neutral (no Node built-ins) so the review workbench bundle can
14
+ * validate records in the browser.
15
+ */
16
+ export const candidateVerificationKind = "survey.candidate-verification";
17
+ const METHODS = ["deterministic", "model", "human"];
18
+ const RESULTS = ["supported", "contradicted", "not-addressed", "abstain"];
19
+ const ABSTAIN_REASONS = ["malformed", "empty", "timeout", "unsupported", "error"];
20
+ /**
21
+ * Canonical JSON with object keys sorted by UTF-16 code unit, matching
22
+ * `canonicalJson` in `@kontourai/surface` (`src/canonical-digest.ts`) byte for
23
+ * byte. Survey's review-workbench `canonicalJson` sorts with `localeCompare`
24
+ * and would disagree on mixed-case keys, so it is not used here.
25
+ */
26
+ function surfaceCanonicalJson(value) {
27
+ if (value === null || typeof value !== "object")
28
+ return JSON.stringify(value);
29
+ if (Array.isArray(value))
30
+ return `[${value.map(surfaceCanonicalJson).join(",")}]`;
31
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${surfaceCanonicalJson(value[key])}`).join(",")}}`;
32
+ }
33
+ /**
34
+ * `"sha256:" + hex SHA-256` of the canonical JSON of a value: the same
35
+ * definition as Surface's `valueDigest`, so a record binds to a value the way a
36
+ * Surface claim does.
37
+ */
38
+ export function valueDigest(value) {
39
+ return `sha256:${sha256Hex(surfaceCanonicalJson(value))}`;
40
+ }
41
+ function digest(domain, payload) {
42
+ return `sha256:${sha256Hex(surfaceCanonicalJson({ domain, payload }))}`;
43
+ }
44
+ /** Digest of everything a verifier was given: candidate id, value, value type and evidence (ordered by id). */
45
+ export function supportVerificationInputDigest(input) {
46
+ const normalized = normalizeInput(input);
47
+ return digest("survey.support-verification-input/v1", {
48
+ candidateId: normalized.candidateId,
49
+ valueDigest: valueDigest(normalized.value),
50
+ ...(normalized.valueType !== undefined ? { valueType: normalized.valueType } : {}),
51
+ evidence: normalized.evidence,
52
+ });
53
+ }
54
+ /**
55
+ * Builds a frozen record from a verifier's verdict. Throws on a malformed
56
+ * verdict: use {@link runSupportVerifier} to turn a verifier's failure into an
57
+ * abstention instead.
58
+ */
59
+ export function buildCandidateVerification(input) {
60
+ const normalized = normalizeInput(input.input);
61
+ const verifier = normalizeVerifier(input.verifier);
62
+ const verdict = normalizeVerdict(input.verdict);
63
+ const payload = {
64
+ kind: candidateVerificationKind,
65
+ schemaVersion: 1,
66
+ candidateId: normalized.candidateId,
67
+ evidenceIds: normalized.evidence.map(({ id }) => id),
68
+ valueDigest: valueDigest(normalized.value),
69
+ inputDigest: supportVerificationInputDigest(normalized),
70
+ verifier: { id: verifier.id, version: verifier.version },
71
+ method: verifier.method,
72
+ result: verdict.result,
73
+ ...(verdict.result === "abstain" ? { abstainReason: verdict.abstainReason } : {}),
74
+ ...(verdict.result !== "abstain" && verdict.score !== undefined ? { score: verdict.score } : {}),
75
+ createdAt: canonicalTimestamp(input.createdAt, "createdAt"),
76
+ };
77
+ return deepFreeze({ id: recordId(payload), ...payload });
78
+ }
79
+ /** How long {@link runSupportVerifier} waits when no `timeoutMs` is given. */
80
+ export const DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS = 30_000;
81
+ /**
82
+ * The largest `timeoutMs` {@link runSupportVerifier} accepts: the largest
83
+ * delay a timer honours (2^31 - 1 ms). A larger delay would be clamped to
84
+ * 1 ms, turning a long bound into an immediate timeout, so it is refused.
85
+ */
86
+ export const MAX_SUPPORT_VERIFIER_TIMEOUT_MS = 2_147_483_647;
87
+ /**
88
+ * Calls a verifier and records what it said. A verifier that throws abstains
89
+ * with `error`, one that returns nothing with `empty`, one that returns a
90
+ * malformed verdict with `malformed`, and one that exceeds `timeoutMs`
91
+ * (default {@link DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS}) with `timeout`. None of these ever records a pass or a fail.
92
+ *
93
+ * Throws only for caller errors: a malformed input, or a verifier without a
94
+ * usable identity (a record needs one).
95
+ */
96
+ export async function runSupportVerifier(verifier, input, options = {}) {
97
+ const identity = normalizeVerifier(verifier);
98
+ const normalized = normalizeInput(input);
99
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS;
100
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_SUPPORT_VERIFIER_TIMEOUT_MS) {
101
+ throw new Error(`timeoutMs must be a positive finite number no greater than ${MAX_SUPPORT_VERIFIER_TIMEOUT_MS}`);
102
+ }
103
+ const now = options.now ?? (() => new Date().toISOString());
104
+ const verdict = await callVerifier(verifier, normalized, timeoutMs);
105
+ return buildCandidateVerification({ input: normalized, verifier: identity, verdict, createdAt: now() });
106
+ }
107
+ async function callVerifier(verifier, input, timeoutMs) {
108
+ const controller = new AbortController();
109
+ let timer;
110
+ const timedOut = Symbol("timeout");
111
+ // The verifier gets its own frozen copy: it cannot change what the record binds to.
112
+ const frozenInput = deepFreeze(structuredClone(input));
113
+ try {
114
+ const call = Promise.resolve().then(() => verifier.verify(frozenInput, { signal: controller.signal }));
115
+ const raced = await Promise.race([call, new Promise((resolve) => { timer = setTimeout(() => resolve(timedOut), timeoutMs); })]);
116
+ if (raced === timedOut) {
117
+ controller.abort();
118
+ call.catch(() => undefined);
119
+ return { result: "abstain", abstainReason: "timeout" };
120
+ }
121
+ if (raced === undefined || raced === null)
122
+ return { result: "abstain", abstainReason: "empty" };
123
+ try {
124
+ return normalizeVerdict(raced);
125
+ }
126
+ catch {
127
+ return { result: "abstain", abstainReason: "malformed" };
128
+ }
129
+ }
130
+ catch {
131
+ return { result: "abstain", abstainReason: "error" };
132
+ }
133
+ finally {
134
+ if (timer !== undefined)
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ const RECORD_KEYS = new Set(["id", "kind", "schemaVersion", "candidateId", "evidenceIds", "valueDigest", "inputDigest", "verifier", "method", "result", "abstainReason", "score", "createdAt"]);
139
+ /**
140
+ * Validates a record read back from storage and returns a frozen copy. Throws
141
+ * on any unknown field, inconsistent verdict, or an id that is not the digest
142
+ * of the record's own fields (an edited or inconsistent record). The id is a
143
+ * content digest, not a signature: it does not prove who wrote the record, and
144
+ * a record rebuilt with a recomputed id validates.
145
+ */
146
+ export function validateCandidateVerification(value) {
147
+ const record = requiredObject(value, "CandidateVerification");
148
+ for (const key of Object.keys(record)) {
149
+ if (!RECORD_KEYS.has(key))
150
+ throw new Error(`CandidateVerification has unknown field ${key}`);
151
+ }
152
+ if (record.kind !== candidateVerificationKind)
153
+ throw new Error(`CandidateVerification kind must be ${candidateVerificationKind}`);
154
+ if (record.schemaVersion !== 1)
155
+ throw new Error("CandidateVerification schemaVersion must be 1");
156
+ const evidenceIds = record.evidenceIds;
157
+ if (!Array.isArray(evidenceIds) || evidenceIds.length === 0)
158
+ throw new Error("CandidateVerification evidenceIds must be a non-empty array");
159
+ evidenceIds.forEach((id, index) => {
160
+ nonEmpty(id, `evidenceIds[${index}]`);
161
+ if (index > 0 && !(evidenceIds[index - 1] < id))
162
+ throw new Error("CandidateVerification evidenceIds must be sorted and unique");
163
+ });
164
+ const verifier = requiredObject(record.verifier, "verifier");
165
+ if (Object.keys(verifier).some((key) => key !== "id" && key !== "version"))
166
+ throw new Error("CandidateVerification verifier has an unknown field");
167
+ const verdict = normalizeVerdict({
168
+ result: record.result,
169
+ ...("abstainReason" in record ? { abstainReason: record.abstainReason } : {}),
170
+ ...("score" in record ? { score: record.score } : {}),
171
+ });
172
+ const payload = {
173
+ kind: candidateVerificationKind,
174
+ schemaVersion: 1,
175
+ candidateId: nonEmpty(record.candidateId, "candidateId"),
176
+ evidenceIds: evidenceIds,
177
+ valueDigest: sha256Digest(record.valueDigest, "valueDigest"),
178
+ inputDigest: sha256Digest(record.inputDigest, "inputDigest"),
179
+ verifier: { id: nonEmpty(verifier.id, "verifier.id"), version: nonEmpty(verifier.version, "verifier.version") },
180
+ method: oneOf(record.method, METHODS, "method"),
181
+ result: verdict.result,
182
+ ...(verdict.result === "abstain" ? { abstainReason: verdict.abstainReason } : {}),
183
+ ...(verdict.result !== "abstain" && verdict.score !== undefined ? { score: verdict.score } : {}),
184
+ createdAt: canonicalTimestamp(record.createdAt, "createdAt"),
185
+ };
186
+ const id = recordId(payload);
187
+ if (record.id !== id)
188
+ throw new Error("CandidateVerification id is not the digest of its fields");
189
+ return deepFreeze({ id, ...structuredClone(payload) });
190
+ }
191
+ /**
192
+ * Sorts records for one candidate into those that apply to its current value,
193
+ * those bound to something else, and those that failed validation. Never
194
+ * selects a winner among applicable records and never derives a trust state.
195
+ */
196
+ export function foldCandidateVerifications(subject, records) {
197
+ const candidateId = nonEmpty(subject.candidateId, "candidateId");
198
+ const currentValueDigest = valueDigest(subject.value);
199
+ const currentInputDigest = subject.evidence === undefined
200
+ ? undefined
201
+ : supportVerificationInputDigest({ candidateId, value: subject.value, ...(subject.valueType !== undefined ? { valueType: subject.valueType } : {}), evidence: subject.evidence });
202
+ const applicable = new Map();
203
+ const inapplicable = new Map();
204
+ const rejected = [];
205
+ records.forEach((raw, index) => {
206
+ let record;
207
+ try {
208
+ record = validateCandidateVerification(raw);
209
+ }
210
+ catch (error) {
211
+ rejected.push(Object.freeze({ index, reason: error instanceof Error ? error.message : String(error) }));
212
+ return;
213
+ }
214
+ const reason = record.candidateId !== candidateId
215
+ ? "other-candidate"
216
+ : record.valueDigest !== currentValueDigest
217
+ ? "value-changed"
218
+ : currentInputDigest !== undefined && record.inputDigest !== currentInputDigest
219
+ ? "input-changed"
220
+ : undefined;
221
+ if (reason)
222
+ inapplicable.set(record.id, Object.freeze({ record, reason }));
223
+ else
224
+ applicable.set(record.id, record);
225
+ });
226
+ const byId = (entries) => [...entries.keys()].sort().map((id) => entries.get(id));
227
+ const applicableRecords = byId(applicable);
228
+ return Object.freeze({
229
+ status: applicableRecords.length === 0
230
+ ? "not-evaluated"
231
+ : applicableRecords.every((record) => record.result === "abstain") ? "abstained" : "evaluated",
232
+ applicable: Object.freeze(applicableRecords),
233
+ inapplicable: Object.freeze(byId(inapplicable)),
234
+ rejected: Object.freeze(rejected),
235
+ });
236
+ }
237
+ function recordId(payload) {
238
+ return digest("survey.candidate-verification/v1", payload);
239
+ }
240
+ function normalizeInput(input) {
241
+ const value = requiredObject(input, "SupportVerificationInput");
242
+ if (!("value" in value))
243
+ throw new Error("SupportVerificationInput value is required");
244
+ if (!Array.isArray(value.evidence) || value.evidence.length === 0)
245
+ throw new Error("SupportVerificationInput evidence must be a non-empty array");
246
+ const evidence = value.evidence.map((entry, index) => {
247
+ const item = requiredObject(entry, `evidence[${index}]`);
248
+ return {
249
+ id: nonEmpty(item.id, `evidence[${index}].id`),
250
+ excerpt: text(item.excerpt, `evidence[${index}].excerpt`),
251
+ locator: nonEmpty(item.locator, `evidence[${index}].locator`),
252
+ ...(item.context !== undefined ? { context: text(item.context, `evidence[${index}].context`) } : {}),
253
+ };
254
+ }).sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
255
+ evidence.forEach((entry, index) => {
256
+ if (index > 0 && evidence[index - 1].id === entry.id)
257
+ throw new Error(`SupportVerificationInput evidence id ${entry.id} is repeated`);
258
+ });
259
+ return {
260
+ candidateId: nonEmpty(value.candidateId, "candidateId"),
261
+ value: value.value,
262
+ ...(value.valueType !== undefined ? { valueType: nonEmpty(value.valueType, "valueType") } : {}),
263
+ evidence,
264
+ };
265
+ }
266
+ function normalizeVerifier(value) {
267
+ const verifier = requiredObject(value, "verifier");
268
+ return {
269
+ id: nonEmpty(verifier.id, "verifier.id"),
270
+ version: nonEmpty(verifier.version, "verifier.version"),
271
+ method: oneOf(verifier.method, METHODS, "verifier.method"),
272
+ };
273
+ }
274
+ function normalizeVerdict(value) {
275
+ const verdict = requiredObject(value, "verdict");
276
+ for (const key of Object.keys(verdict)) {
277
+ if (key !== "result" && key !== "abstainReason" && key !== "score")
278
+ throw new Error(`verdict has unknown field ${key}`);
279
+ }
280
+ const result = oneOf(verdict.result, RESULTS, "verdict.result");
281
+ if (result === "abstain") {
282
+ if ("score" in verdict)
283
+ throw new Error("an abstaining verdict carries no score");
284
+ return { result, abstainReason: oneOf(verdict.abstainReason, ABSTAIN_REASONS, "verdict.abstainReason") };
285
+ }
286
+ if ("abstainReason" in verdict)
287
+ throw new Error("abstainReason is only allowed when result is abstain");
288
+ if (!("score" in verdict))
289
+ return { result };
290
+ const score = verdict.score;
291
+ if (typeof score !== "number" || !Number.isFinite(score) || score < 0 || score > 1)
292
+ throw new Error("verdict.score must be a number from 0 to 1");
293
+ return { result, score };
294
+ }
295
+ function oneOf(value, allowed, label) {
296
+ if (typeof value !== "string" || !allowed.includes(value))
297
+ throw new Error(`${label} must be one of ${allowed.join(", ")}`);
298
+ return value;
299
+ }
300
+ function requiredObject(value, label) {
301
+ if (!value || typeof value !== "object" || Array.isArray(value))
302
+ throw new Error(`${label} must be an object`);
303
+ return value;
304
+ }
305
+ function nonEmpty(value, label) {
306
+ if (typeof value !== "string" || value.trim() === "")
307
+ throw new Error(`${label} must be a non-empty string`);
308
+ return value;
309
+ }
310
+ function text(value, label) {
311
+ if (typeof value !== "string")
312
+ throw new Error(`${label} must be a string`);
313
+ return value;
314
+ }
315
+ function sha256Digest(value, label) {
316
+ if (typeof value !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value))
317
+ throw new Error(`${label} must be a sha256:<64 hex> digest`);
318
+ return value;
319
+ }
320
+ function canonicalTimestamp(value, label) {
321
+ const timestamp = nonEmpty(value, label);
322
+ const parsed = new Date(timestamp);
323
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== timestamp)
324
+ throw new Error(`${label} must be a canonical ISO timestamp`);
325
+ return timestamp;
326
+ }
327
+ function deepFreeze(value) {
328
+ if (value && typeof value === "object") {
329
+ for (const nested of Object.values(value))
330
+ deepFreeze(nested);
331
+ Object.freeze(value);
332
+ }
333
+ return value;
334
+ }
@@ -39,10 +39,17 @@ export function buildCanonicalReviewedTrustInput(options) {
39
39
  const rejectsAll = selectsNone && result.decision === "reject-proposed";
40
40
  const rejectedRole = workbenchDecisionDefinitions[result.decision].candidateRole;
41
41
  const rejectAllReason = result.rationale?.trim() || "Every proposed value for this claim was rejected.";
42
+ // A choice of one conflicting value passes over the others: each keeps a
43
+ // record that it was seen and not chosen (and so its own rejected-candidate
44
+ // learning), never silently dropped.
45
+ const unchosenIds = new Set(result.decision === "select-proposed" ? result.reviewDecision.spec.unselectedCandidateIds ?? [] : []);
46
+ const notChosenReason = `Not chosen: the reviewer chose candidate ${recordIdFor(item, result.selectedCandidateId ?? "")} for this claim.`;
42
47
  const candidates = item.spec.candidates.map((candidate) => {
43
48
  const records = projectCandidate(item, rejectsAll && candidate.role === rejectedRole
44
49
  ? { ...candidate, rejectionReason: candidate.rejectionReason ?? rejectAllReason }
45
- : candidate);
50
+ : unchosenIds.has(candidate.id)
51
+ ? { ...candidate, rejectionReason: candidate.rejectionReason ?? notChosenReason }
52
+ : candidate);
46
53
  addConsistent(rawSources, records.rawSource, "raw source");
47
54
  addConsistent(extractions, records.extraction, "extraction");
48
55
  return records.candidate;
@@ -96,6 +103,9 @@ export function buildCanonicalReviewedTrustInput(options) {
96
103
  metadata: {
97
104
  workbenchDecision: result.decision,
98
105
  ...(result.editedValue !== undefined ? { editedValue: result.editedValue } : {}),
106
+ ...(decision.unselectedCandidateIds?.length
107
+ ? { unselectedCandidateIds: decision.unselectedCandidateIds.map((id) => recordIdFor(item, id)) }
108
+ : {}),
99
109
  },
100
110
  };
101
111
  addConsistent(reviewOutcomes, reviewOutcome, "review outcome");
@@ -192,6 +202,20 @@ function assertCanonicalResult(item, result) {
192
202
  || canonicalJson(decision.editedValue) !== canonicalJson(result.editedValue)) {
193
203
  throw new Error(`Review result ${result.reviewItemName} contradicts its canonical ReviewDecision.`);
194
204
  }
205
+ // Accepting one value of a conflict would project it as if it had been the
206
+ // only value; a choice between conflicting values is select-proposed.
207
+ if (result.decision === "accept-proposed" && item.spec.candidates.filter((candidate) => candidate.role === "proposed").length > 1) {
208
+ throw new Error(`Review result ${result.reviewItemName} accepts one proposed value of a conflict; a choice between conflicting values must be a select-proposed decision that records the values it passed over.`);
209
+ }
210
+ // A choice names exactly the proposed values it passed over; no other
211
+ // decision names any.
212
+ const expectedUnselected = result.decision === "select-proposed" && selected
213
+ ? item.spec.candidates.filter((candidate) => candidate.role === "proposed" && candidate.id !== selected.id).map((candidate) => candidate.id)
214
+ : [];
215
+ if (canonicalJson(decision.unselectedCandidateIds ?? []) !== canonicalJson(expectedUnselected)
216
+ || (result.decision === "select-proposed" && expectedUnselected.length === 0)) {
217
+ throw new Error(`Review result ${result.reviewItemName} does not record the proposed values its ${result.decision} decision passed over.`);
218
+ }
195
219
  if ((result.decision === "could-not-confirm") !== (decision.resolution === "could_not_confirm")) {
196
220
  throw new Error(`Review result ${result.reviewItemName} contradicts its canonical review resolution.`);
197
221
  }
@@ -210,6 +234,11 @@ function assertCanonicalResult(item, result) {
210
234
  }
211
235
  }
212
236
  }
237
+ /** The projected Candidate record id of a ReviewItem candidate. */
238
+ function recordIdFor(item, candidateId) {
239
+ const candidate = item.spec.candidates.find((entry) => entry.id === candidateId);
240
+ return candidate?.projection?.candidateId ?? candidateId;
241
+ }
213
242
  function claimTargetIdentity(target) {
214
243
  const { claimId: _claimId, ...identity } = target;
215
244
  return identity;
@@ -18,6 +18,7 @@ import { readFile as readFileAsync } from "node:fs/promises";
18
18
  import { resolve, join, dirname, extname, normalize, sep } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
21
+ import { attestReviewQueueExtraction } from "../review-workbench/queue-binding.js";
21
22
  import { appendReviewSessionEvents, readReviewSessionFile, reviewSessionRevision, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
22
23
  // ---------------------------------------------------------------------------
23
24
  // Constants
@@ -404,9 +405,15 @@ let activeItemName = null;
404
405
  async function fetchAndMount() {
405
406
  try {
406
407
  const res = await fetch("/api/session");
407
- if (!res.ok) throw new Error("Session fetch failed: " + res.status);
408
+ if (!res.ok) {
409
+ const body = await res.json().catch(() => ({}));
410
+ showSaveStatus(body.error || ("The session could not be loaded (HTTP " + res.status + ")."));
411
+ const root = document.getElementById("review-workbench");
412
+ if (root) root.innerHTML = "";
413
+ return;
414
+ }
408
415
  const data = await res.json();
409
- const { snapshot, events, revision } = data;
416
+ const { snapshot, events, revision, extractionImport } = data;
410
417
  const state = events && events.length > 0
411
418
  ? replayReviewSessionEvents(snapshot, events)
412
419
  : snapshot;
@@ -419,7 +426,7 @@ async function fetchAndMount() {
419
426
  const root = document.getElementById("review-workbench");
420
427
  if (!root) return;
421
428
 
422
- mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision) });
429
+ mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision), ...(extractionImport ? { extractionImport } : {}) });
423
430
  } catch (err) {
424
431
  console.error("[console] Mount error:", err);
425
432
  }
@@ -602,12 +609,22 @@ export async function startReviewConsoleServer(options) {
602
609
  // ---- Session read ----
603
610
  if (pathname === "/api/session" && req.method === "GET") {
604
611
  const content = await readSession(sessionPath);
612
+ // A queue that does not match the extraction import stored with it is
613
+ // not served for review; one without a stored import is served, and the
614
+ // workbench marks it unverified.
615
+ const attestation = attestReviewQueueExtraction(content.snapshot.items, content.extractionImport);
616
+ if (attestation.state === "diverges") {
617
+ sendJson(res, 409, { error: attestation.message, queueAttestation: attestation.state });
618
+ return;
619
+ }
605
620
  sendJson(res, 200, {
606
621
  session: content.session,
607
622
  snapshot: content.snapshot,
608
623
  events: content.events,
609
624
  revision: reviewSessionRevision(content.events),
610
625
  state: currentSessionState(content.snapshot, content.events),
626
+ queueAttestation: attestation.state,
627
+ ...(content.extractionImport !== undefined ? { extractionImport: content.extractionImport } : {}),
611
628
  });
612
629
  return;
613
630
  }
@@ -660,6 +677,7 @@ export async function startReviewConsoleServer(options) {
660
677
  record,
661
678
  events: nextEvents,
662
679
  requiredResolvedItems: "none",
680
+ extractionImport: content.extractionImport,
663
681
  });
664
682
  issueMessages = applyResult.ok
665
683
  ? []
@@ -672,7 +690,7 @@ export async function startReviewConsoleServer(options) {
672
690
  return { result: { status: 422, body: { error: `Validation failed: ${issueMessages.join("; ")}` } } };
673
691
  }
674
692
  return {
675
- next: { session: content.session, snapshot, events: nextEvents },
693
+ next: { ...content, session: content.session, snapshot, events: nextEvents },
676
694
  result: {
677
695
  status: 200,
678
696
  body: { ok: true, eventCount: nextEvents.length, revision: reviewSessionRevision(nextEvents) },