@kontourai/survey 6.1.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 (31) hide show
  1. package/dist/example-data/public-directory-review-resource.d.ts +3 -3
  2. package/dist/src/canonical-reviewed-trust-input.js +30 -1
  3. package/dist/src/console/review-console-server.js +22 -4
  4. package/dist/src/index.d.ts +3 -3
  5. package/dist/src/index.js +1 -1
  6. package/dist/src/mcp/review-mcp.js +139 -36
  7. package/dist/src/review-resource.d.ts +7 -0
  8. package/dist/src/review-session-file.d.ts +7 -0
  9. package/dist/src/review-workbench/audit-rows.d.ts +1 -1
  10. package/dist/src/review-workbench/audit-rows.js +1 -0
  11. package/dist/src/review-workbench/queue-binding.d.ts +45 -2
  12. package/dist/src/review-workbench/queue-binding.js +45 -0
  13. package/dist/src/review-workbench/review-presentation.d.ts +23 -0
  14. package/dist/src/review-workbench/review-presentation.js +51 -10
  15. package/dist/src/review-workbench/review-queue-session.d.ts +47 -4
  16. package/dist/src/review-workbench/review-queue-session.js +73 -8
  17. package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
  18. package/dist/src/review-workbench/review-session-replay.js +35 -1
  19. package/dist/src/review-workbench/review-workbench-css.generated.js +94 -0
  20. package/dist/src/review-workbench/review-workbench-element.d.ts +10 -0
  21. package/dist/src/review-workbench/review-workbench-element.js +21 -3
  22. package/dist/src/review-workbench/review-workbench.css +94 -0
  23. package/dist/src/review-workbench/review-workbench.d.ts +28 -8
  24. package/dist/src/review-workbench/review-workbench.js +157 -62
  25. package/dist/src/review-workbench/review-workbench.standalone.css +94 -0
  26. package/dist/src/review-workbench/server-review-session.d.ts +18 -0
  27. package/dist/src/review-workbench/server-review-session.js +20 -2
  28. package/dist/src/surface-reviewed-extraction.d.ts +23 -0
  29. package/dist/src/surface-reviewed-extraction.js +64 -1
  30. package/dist/src/to-surface.js +32 -3
  31. package/package.json +1 -1
@@ -3,9 +3,9 @@ import { resolve, dirname } from "node:path";
3
3
  import { McpServer } from "@modelcontextprotocol/server";
4
4
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
5
  import { z } from "zod";
6
- import { buildReviewSessionEvents, currentReviewItem, decisionSelectsNoCandidate, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
6
+ import { attestReviewQueueExtraction, buildReviewSessionEvents, conflictSelectionIssue, currentReviewItem, decisionSelectsNoCandidate, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
7
7
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
8
- import { buildReviewItemPresentation, candidateVerificationNotes, excludedProposalsSentence } from "../review-workbench/review-presentation.js";
8
+ import { buildReviewItemPresentation, candidateVerificationNotes, excludedProposalsSentence, excludedProposalsUnreadableSentence } from "../review-workbench/review-presentation.js";
9
9
  import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
10
10
  const SESSION_NAME = "mcp-review-session";
11
11
  const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
@@ -16,15 +16,33 @@ const SERVER_INSTRUCTIONS = "Use survey_review_queue to inspect the queue, surve
16
16
  // MCP tool decision strings → ReviewWorkbenchDecision
17
17
  const MCP_DECISION_MAP = {
18
18
  accept: "accept-proposed",
19
+ select: "select-proposed",
19
20
  hold: "keep-current",
20
21
  reject: "reject-proposed",
21
22
  "could-not-confirm": "could-not-confirm",
22
23
  };
24
+ /**
25
+ * Reads the session and checks its queue against the extraction import stored
26
+ * beside it. A queue that does not match its import is refused outright; one
27
+ * whose items came from an import but has no record stored is presented with
28
+ * an "Unverified queue" notice on every surface.
29
+ */
23
30
  async function readSessionFile(path) {
24
- return readReviewSessionFile(path);
31
+ const file = await readReviewSessionFile(path);
32
+ return { ...file, attestation: attestedQueue(file) };
33
+ }
34
+ function attestedQueue(file) {
35
+ const attestation = attestReviewQueueExtraction(file.snapshot.items, file.extractionImport);
36
+ if (attestation.state === "diverges") {
37
+ throw new DomainError(attestation.message);
38
+ }
39
+ return attestation;
40
+ }
41
+ function attestationLines(attestation) {
42
+ return attestation.state === "unverified" ? [attestation.message, ``] : [];
25
43
  }
26
44
  // ---- Queue helpers -------------------------------------------------------
27
- function queueSummaryText(snapshot, events) {
45
+ function queueSummaryText(snapshot, events, attestation) {
28
46
  const current = currentSessionState(snapshot, events);
29
47
  const summary = reviewSessionSummary(current);
30
48
  const total = current.items.length;
@@ -38,6 +56,7 @@ function queueSummaryText(snapshot, events) {
38
56
  return ` ${item.metadata.name} — ${item.spec.target} — ${status}${marker}`;
39
57
  });
40
58
  return [
59
+ ...attestationLines(attestation),
41
60
  `Review queue: ${resolved}/${total} resolved`,
42
61
  `Active item: ${activeItem.metadata.name} (${activeItem.spec.target})`,
43
62
  ...(nextItem ? [`Next unresolved: ${nextItem}`] : ["All items resolved."]),
@@ -48,16 +67,18 @@ function queueSummaryText(snapshot, events) {
48
67
  ...rows,
49
68
  ].join("\n");
50
69
  }
51
- function itemDetailText(item, snapshot, events) {
70
+ function itemDetailText(item, snapshot, events, attestation) {
52
71
  const current = currentSessionState(snapshot, events);
53
72
  const status = deriveQueueRowStatus(item, current);
54
73
  const decision = current.decisionsByItemName[item.metadata.name];
55
74
  const note = current.notesByItemName[item.metadata.name];
75
+ const chosenId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
56
76
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
57
77
  const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
58
78
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
59
79
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
60
80
  const lines = [
81
+ ...attestationLines(attestation),
61
82
  `Item: ${item.metadata.name}`,
62
83
  `Target: ${item.spec.target}`,
63
84
  `Status: ${status}`,
@@ -71,10 +92,14 @@ function itemDetailText(item, snapshot, events) {
71
92
  ...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
72
93
  ``,
73
94
  ...(proposedCandidates.length > 1
74
- ? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; reject them all or use could-not-confirm with a reason.`]
95
+ ? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; choose one with decision "select" and its candidateId, reject them all, or use could-not-confirm with a reason.`]
96
+ : []),
97
+ ...(chosenId !== undefined
98
+ ? [`Chosen value: ${valueStr(proposedCandidates.find((c) => c.id === chosenId)?.value)} (candidate ${chosenId}); not chosen: ${proposedCandidates.filter((c) => c.id !== chosenId).map((c) => `${valueStr(c.value)} (candidate ${c.id})`).join(", ")}`]
75
99
  : []),
76
100
  ...(proposedCandidates.length === 0 ? [`Proposed value: (none)`] : proposedCandidates.flatMap((candidate) => [
77
- `Proposed value: ${valueStr(candidate.value)}`,
101
+ `Proposed value: ${valueStr(candidate.value)}${chosenId === undefined ? "" : candidate.id === chosenId ? " [chosen]" : " [not chosen]"}`,
102
+ ...(proposedCandidates.length > 1 ? [` candidateId: ${candidate.id}`] : []),
78
103
  ` confidence: ${confStr(candidate.extraction?.confidence ?? candidate.confidence)}`,
79
104
  ` source: ${candidate.source?.sourceRef ?? "none"}`,
80
105
  ...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
@@ -95,8 +120,10 @@ function editedValueFor(item, state) {
95
120
  function extractionImportLines(item) {
96
121
  const presentation = buildReviewItemPresentation(item);
97
122
  const excluded = excludedProposalsSentence(presentation.excludedProposals);
123
+ const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
98
124
  return [
99
125
  ...(excluded ? [``, `Excluded: ${excluded} Check the source before accepting.`] : []),
126
+ ...(unreadable ? [``, `Excluded: ${unreadable} Check the source before accepting.`] : []),
100
127
  ...(presentation.excerptVerification ? [``, `Excerpts ${presentation.excerptVerification === "verified" ? "were" : "were not"} checked against the prepared source text at import.`] : []),
101
128
  ];
102
129
  }
@@ -114,17 +141,19 @@ function escapeHtml(text) {
114
141
  .replace(/>/g, ">")
115
142
  .replace(/"/g, """);
116
143
  }
117
- function buildReviewCardHtml(item, snapshot, events) {
144
+ function buildReviewCardHtml(item, snapshot, events, attestation) {
118
145
  const current = currentSessionState(snapshot, events);
119
146
  const summary = reviewSessionSummary(current);
120
147
  const total = current.items.length;
121
148
  const resolved = total - summary.unresolved;
122
149
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
123
150
  const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
124
- // Several proposed values are a conflict: every value is shown, and accept
125
- // (which names a role, not a value) is not offered.
151
+ // Several proposed values are a conflict: every value is shown, accept
152
+ // (which names a role, not a value) is not offered, and each value has its
153
+ // own "Use this value" control that names its candidate id.
126
154
  const conflict = proposedCandidates.length > 1;
127
155
  const decision = current.decisionsByItemName[item.metadata.name];
156
+ const chosenId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
128
157
  const status = deriveQueueRowStatus(item, current);
129
158
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
130
159
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
@@ -135,22 +164,32 @@ function buildReviewCardHtml(item, snapshot, events) {
135
164
  const proposedCard = (candidate, label) => {
136
165
  const value = valueStr(candidate?.value ?? "—");
137
166
  const excerpt = candidate?.locator?.excerpt ?? "";
138
- return `<div class="card is-proposed">
167
+ const choice = conflict && candidate
168
+ ? chosenId !== undefined
169
+ ? `<div class="choice ${candidate.id === chosenId ? "chosen" : "not-chosen"}" data-candidate-id="${escapeHtml(candidate.id)}">${candidate.id === chosenId ? "Chosen" : "Not chosen"}</div>`
170
+ : decision === undefined
171
+ ? `<button class="btn btn-accept btn-select" data-candidate-id="${escapeHtml(candidate.id)}">Use this value</button>`
172
+ : ""
173
+ : "";
174
+ return `<div class="card is-proposed"${conflict && candidate ? ` data-candidate-id="${escapeHtml(candidate.id)}"` : ""}>
139
175
  <div class="card-label">${escapeHtml(label)}</div>
140
176
  <div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
141
177
  <div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
142
178
  <div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
143
179
  ${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
180
+ ${choice}
144
181
  </div>`;
145
182
  };
146
183
  const proposedCards = conflict
147
184
  ? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
148
185
  : proposedCard(proposedCandidates[0], "Proposed");
149
186
  const itemNameJson = escapeJsonInHtml(item.metadata.name);
150
- const excludedNote = excludedProposalsSentence(buildReviewItemPresentation(item).excludedProposals);
187
+ const itemPresentation = buildReviewItemPresentation(item);
188
+ const excludedNote = excludedProposalsSentence(itemPresentation.excludedProposals);
189
+ const unreadableNote = excludedProposalsUnreadableSentence(itemPresentation.excludedProposalsUnreadable);
151
190
  const verificationNotes = candidateVerificationNotes(item, editedValueFor(item, current));
152
191
  const decisionBadge = decision
153
- ? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
192
+ ? `<span class="badge badge-${decision === "accept-proposed" || decision === "select-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(decision === "select-proposed" ? `Chose 1 of ${proposedCandidates.length} values` : workbenchDecisionDefinitions[decision].label)}</span>`
154
193
  : `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
155
194
  return `<!doctype html>
156
195
  <html lang="en">
@@ -228,9 +267,15 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
228
267
  .btn-reject:hover,.btn-reject.active{background:color-mix(in srgb,var(--k-negative) 16%,transparent);border-color:var(--k-negative);color:var(--k-negative)}
229
268
  .btn-unconfirmed:hover,.btn-unconfirmed.active{background:color-mix(in srgb,var(--k-caution) 16%,transparent);border-color:var(--k-caution);color:var(--k-caution)}
230
269
  .feedback{font-size:11px;color:var(--k-text-faint);margin-top:8px;min-height:16px}
270
+ .btn-select{width:100%;margin-top:8px}
271
+ .choice{font-family:var(--k-font-mono);font-size:10px;font-weight:600;margin-top:8px;text-transform:uppercase;letter-spacing:.06em}
272
+ .choice.chosen{color:var(--k-positive)}
273
+ .choice.not-chosen{color:var(--k-text-faint)}
274
+ .notice{font-size:11px;color:var(--k-caution);background:color-mix(in srgb,var(--k-caution) 12%,transparent);border-radius:var(--k-radius-sm);padding:7px 10px;margin:0 0 12px}
231
275
  </style>
232
276
  </head>
233
277
  <body>
278
+ ${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-note">${escapeHtml(attestation.message)}</p>` : ""}
234
279
  <p class="eyebrow">Survey Review</p>
235
280
  <h1>${escapeHtml(item.spec.target)}</h1>
236
281
  <div class="meta">
@@ -251,8 +296,11 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
251
296
  ${proposedCards}
252
297
  </div>
253
298
  ${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
299
+ ${unreadableNote ? `<p class="feedback" id="excluded-unreadable-note">${escapeHtml(unreadableNote)}</p>` : ""}
254
300
  ${verificationNotes.map((entry) => `<p class="feedback verification-note" data-candidate-id="${escapeHtml(entry.candidateId)}">${escapeHtml(entry.sentence)}</p>`).join("\n")}
255
- ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length} different values were proposed. This card cannot choose one of them yet: reject them all, or use Could not confirm with a reason.</p>` : ""}
301
+ ${conflict ? `<p class="feedback" id="conflict-note">${escapeHtml(chosenId !== undefined
302
+ ? `${proposedCandidates.length} different values were proposed. One was chosen; ${proposedCandidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
303
+ : `${proposedCandidates.length} different values were proposed. Use the value the source supports, reject them all, or use Could not confirm with a reason.`)}</p>` : ""}
256
304
 
257
305
  <div class="divider"></div>
258
306
 
@@ -272,7 +320,7 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
272
320
  var itemName = ${itemNameJson};
273
321
  var msgId = 1;
274
322
 
275
- function postDecision(decision) {
323
+ function postDecision(decision, candidateId) {
276
324
  var note = document.getElementById('note').value;
277
325
  if (decision === 'could-not-confirm' && !note.trim()) {
278
326
  document.getElementById('feedback').textContent = 'A reason is required when you could not confirm.';
@@ -287,12 +335,18 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
287
335
  name: "survey_review_decide",
288
336
  arguments: decision === 'could-not-confirm'
289
337
  ? { itemName: itemName, decision: decision, reason: note }
290
- : { itemName: itemName, decision: decision, note: note || undefined }
338
+ : decision === 'select'
339
+ ? { itemName: itemName, decision: decision, candidateId: candidateId, note: note || undefined }
340
+ : { itemName: itemName, decision: decision, note: note || undefined }
291
341
  }
292
342
  }, "*");
293
343
  return true;
294
344
  }
295
345
 
346
+ Array.prototype.forEach.call(document.querySelectorAll('.btn-select'), function (button) {
347
+ button.addEventListener('click', function () { postDecision('select', button.getAttribute('data-candidate-id')); document.getElementById('feedback').textContent = 'Submitting choice…'; });
348
+ });
349
+
296
350
  var acceptButton = document.getElementById('btn-accept');
297
351
  if (acceptButton) acceptButton.addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
298
352
  var holdButton = document.getElementById('btn-hold');
@@ -318,16 +372,21 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
318
372
  // ---- Tool implementations ------------------------------------------------
319
373
  async function toolQueue(options) {
320
374
  const file = await readSessionFile(options.sessionPath);
321
- const { snapshot, events } = file;
322
- const text = queueSummaryText(snapshot, events);
375
+ const { snapshot, events, attestation } = file;
376
+ const text = queueSummaryText(snapshot, events, attestation);
323
377
  const queueData = {
378
+ queueAttestation: attestation.state,
324
379
  items: snapshot.items.map((item) => {
325
380
  const current = currentSessionState(snapshot, events);
381
+ const selectedCandidateId = current.decisionsByItemName[item.metadata.name] === "select-proposed"
382
+ ? current.selectedCandidateIdsByItemName?.[item.metadata.name]
383
+ : undefined;
326
384
  return {
327
385
  name: item.metadata.name,
328
386
  target: item.spec.target,
329
387
  status: deriveQueueRowStatus(item, current),
330
388
  decision: current.decisionsByItemName[item.metadata.name],
389
+ ...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
331
390
  candidateSetStatus: item.spec.candidateSetStatus,
332
391
  };
333
392
  }),
@@ -339,24 +398,28 @@ async function toolQueue(options) {
339
398
  ];
340
399
  if (!options.noUi) {
341
400
  const activeItem = currentReviewItem(currentSessionState(snapshot, events));
342
- content.push(buildUiResource(activeItem, snapshot, events, "queue"));
401
+ content.push(buildUiResource(activeItem, snapshot, events, "queue", attestation));
343
402
  }
344
403
  return content;
345
404
  }
346
405
  async function toolItem(itemName, options) {
347
406
  const file = await readSessionFile(options.sessionPath);
348
- const { snapshot, events } = file;
407
+ const { snapshot, events, attestation } = file;
349
408
  const current = currentSessionState(snapshot, events);
350
409
  const item = current.items.find((i) => i.metadata.name === itemName);
351
410
  if (!item) {
352
411
  throw new DomainError(`Unknown review item: ${itemName}`);
353
412
  }
354
- const text = itemDetailText(item, snapshot, events);
413
+ const text = itemDetailText(item, snapshot, events, attestation);
414
+ const decision = current.decisionsByItemName[item.metadata.name];
415
+ const selectedCandidateId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
355
416
  const itemData = {
417
+ queueAttestation: attestation.state,
356
418
  name: item.metadata.name,
357
419
  target: item.spec.target,
358
420
  status: deriveQueueRowStatus(item, current),
359
- decision: current.decisionsByItemName[item.metadata.name],
421
+ decision,
422
+ ...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
360
423
  note: current.notesByItemName[item.metadata.name],
361
424
  candidateSetStatus: item.spec.candidateSetStatus,
362
425
  ...(() => {
@@ -364,6 +427,7 @@ async function toolItem(itemName, options) {
364
427
  return {
365
428
  ...(presentation.excerptVerification ? { excerptVerification: presentation.excerptVerification } : {}),
366
429
  ...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
430
+ ...(presentation.excludedProposalsUnreadable ? { excludedProposalsUnreadable: presentation.excludedProposalsUnreadable } : {}),
367
431
  };
368
432
  })(),
369
433
  candidates: (() => {
@@ -377,6 +441,7 @@ async function toolItem(itemName, options) {
377
441
  confidence: c.extraction?.confidence ?? c.confidence,
378
442
  sourceRef: c.source?.sourceRef,
379
443
  excerpt: c.locator?.excerpt,
444
+ ...(selectedCandidateId !== undefined && c.role === "proposed" ? { chosen: c.id === selectedCandidateId } : {}),
380
445
  ...(note ? { verification: { subject: note.subjectLabel, status: note.status, records: note.records, inapplicableCount: note.inapplicableCount, rejectedCount: note.rejectedCount } } : {}),
381
446
  };
382
447
  });
@@ -386,22 +451,23 @@ async function toolItem(itemName, options) {
386
451
  { type: "text", text: `${text}\n\n${JSON.stringify(itemData, null, 2)}` },
387
452
  ];
388
453
  if (!options.noUi) {
389
- content.push(buildUiResource(item, snapshot, events, itemName));
454
+ content.push(buildUiResource(item, snapshot, events, itemName, attestation));
390
455
  }
391
456
  return content;
392
457
  }
393
- async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options) {
458
+ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options, selectedCandidateId) {
394
459
  const wbDecision = MCP_DECISION_MAP[mcpDecision];
395
460
  if (!wbDecision) {
396
- throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, reject, or could-not-confirm.`);
461
+ throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, select, hold, reject, or could-not-confirm.`);
397
462
  }
398
463
  if (wbDecision === "could-not-confirm" && !note?.trim()) {
399
464
  throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
400
465
  }
401
466
  // Read, validate and write inside the shared session lock so a concurrent
402
467
  // decide or console save cannot interleave and drop this decision (#281).
403
- const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
468
+ const { snapshot, sessionWithDecision, newEvents, attestation } = await updateReviewSessionFile(options.sessionPath, (file) => {
404
469
  const { snapshot, events } = file;
470
+ const attestation = attestedQueue(file);
405
471
  const current = currentSessionState(snapshot, events);
406
472
  const item = current.items.find((i) => i.metadata.name === itemName);
407
473
  if (!item) {
@@ -411,6 +477,12 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
411
477
  if (existingDecision) {
412
478
  throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
413
479
  }
480
+ if (wbDecision === "select-proposed") {
481
+ const selectionIssue = conflictSelectionIssue(item, selectedCandidateId);
482
+ if (selectionIssue) {
483
+ throw new DomainError(selectionIssue);
484
+ }
485
+ }
414
486
  // Build the updated session state with the decision
415
487
  const sessionWithDecision = {
416
488
  ...current,
@@ -434,6 +506,14 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
434
506
  },
435
507
  }
436
508
  : {}),
509
+ ...(wbDecision === "select-proposed" && selectedCandidateId !== undefined
510
+ ? {
511
+ selectedCandidateIdsByItemName: {
512
+ ...current.selectedCandidateIdsByItemName,
513
+ [itemName]: selectedCandidateId,
514
+ },
515
+ }
516
+ : {}),
437
517
  };
438
518
  // Append only this decision's events (its note, then the decision) to the
439
519
  // stored log. Regenerating the whole log from state would erase earlier
@@ -455,26 +535,36 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
455
535
  record,
456
536
  events: newEvents,
457
537
  requiredResolvedItems: "none",
538
+ extractionImport: file.extractionImport,
458
539
  });
459
540
  if (!applyResult.ok) {
460
541
  throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
461
542
  }
462
543
  const updatedFile = {
544
+ ...file,
463
545
  session: file.session,
464
546
  snapshot,
465
547
  events: newEvents,
466
548
  };
467
- return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
549
+ return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents, attestation } };
468
550
  });
469
551
  // Summarize the result
470
552
  const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
471
- const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
472
- const remainingText = queueSummaryText(snapshot, newEvents);
553
+ const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents, attestation) : `Item: ${itemName}`;
554
+ const remainingText = queueSummaryText(snapshot, newEvents, attestation);
473
555
  const definition = workbenchDecisionDefinitions[wbDecision];
474
556
  const conflictRejected = wbDecision === "reject-proposed" && updatedItem !== undefined && decisionSelectsNoCandidate(updatedItem, wbDecision);
557
+ const valueStr = (v) => (typeof v === "string" ? v : JSON.stringify(v));
558
+ const proposed = updatedItem?.spec.candidates.filter((c) => c.role === "proposed") ?? [];
559
+ const chosen = proposed.find((c) => c.id === selectedCandidateId);
560
+ const effect = conflictRejected
561
+ ? "Every proposed value is rejected; none becomes the claim's value."
562
+ : wbDecision === "select-proposed"
563
+ ? `${valueStr(chosen?.value)} becomes the verified value; not chosen: ${proposed.filter((c) => c.id !== selectedCandidateId).map((c) => valueStr(c.value)).join(", ")}.`
564
+ : definition.effect;
475
565
  const text = [
476
566
  `Decision recorded: ${conflictRejected ? "Reject all values" : definition.label}`,
477
- `Effect: ${conflictRejected ? "Every proposed value is rejected; none becomes the claim's value." : definition.effect}`,
567
+ `Effect: ${effect}`,
478
568
  "",
479
569
  itemText,
480
570
  "",
@@ -483,13 +573,13 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
483
573
  ].join("\n");
484
574
  return [{ type: "text", text }];
485
575
  }
486
- function buildUiResource(item, snapshot, events, instance) {
576
+ function buildUiResource(item, snapshot, events, instance, attestation) {
487
577
  return {
488
578
  type: "resource",
489
579
  resource: {
490
580
  uri: `ui://survey/review-card/${encodeURIComponent(instance)}`,
491
581
  mimeType: "text/html;profile=mcp-app",
492
- text: buildReviewCardHtml(item, snapshot, events),
582
+ text: buildReviewCardHtml(item, snapshot, events, attestation),
493
583
  _meta: {
494
584
  ui: {
495
585
  csp: {
@@ -505,10 +595,10 @@ function buildUiResource(item, snapshot, events, instance) {
505
595
  // Render the declared review card from the same function used by embedded tool
506
596
  // results, so Apps and text-first hosts cannot drift.
507
597
  async function readQueuePanelResource(options) {
508
- const { snapshot, events } = await readSessionFile(options.sessionPath);
598
+ const { snapshot, events, attestation } = await readSessionFile(options.sessionPath);
509
599
  const current = currentSessionState(snapshot, events);
510
600
  const activeItem = currentReviewItem(current);
511
- return buildUiResource(activeItem, snapshot, events, "queue").resource;
601
+ return buildUiResource(activeItem, snapshot, events, "queue", attestation).resource;
512
602
  }
513
603
  // ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
514
604
  class DomainError extends Error {
@@ -557,7 +647,7 @@ function createReviewMcpServer(options, serverVersion) {
557
647
  }, async ({ itemName }) => runReviewTool(() => toolItem(itemName, options)));
558
648
  server.registerTool("survey_review_decide", {
559
649
  title: "Record a review decision",
560
- description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
650
+ description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, select, hold, reject, or could-not-confirm. Select chooses one value of a conflict (several proposed values) by candidateId. Could-not-confirm requires a reason. Domain failures return isError:true.",
561
651
  inputSchema: z.discriminatedUnion("decision", [
562
652
  z.object({
563
653
  itemName: z.string().min(1).describe("The ReviewItem name to decide."),
@@ -581,10 +671,23 @@ function createReviewMcpServer(options, serverVersion) {
581
671
  .optional()
582
672
  .describe("Evidence ids attempted before a could-not-confirm decision."),
583
673
  }),
674
+ z.object({
675
+ itemName: z.string().min(1).describe("The ReviewItem name to decide."),
676
+ decision: z
677
+ .literal("select")
678
+ .describe("select = select-proposed: choose one value of a conflict; the other proposed values are recorded as seen and not chosen."),
679
+ candidateId: z
680
+ .string()
681
+ .min(1)
682
+ .describe("The id of the proposed candidate to use, from survey_review_item."),
683
+ note: z.string().optional().describe("Optional reviewer note or rationale."),
684
+ }),
584
685
  ]),
585
686
  }, async (input) => runReviewTool(() => input.decision === "could-not-confirm"
586
687
  ? toolDecide(input.itemName, input.decision, input.reason, input.attemptEvidenceIds, options)
587
- : toolDecide(input.itemName, input.decision, input.note, undefined, options)));
688
+ : input.decision === "select"
689
+ ? toolDecide(input.itemName, input.decision, input.note, undefined, options, input.candidateId)
690
+ : toolDecide(input.itemName, input.decision, input.note, undefined, options)));
588
691
  if (!options.noUi) {
589
692
  server.registerResource("survey-review-workbench", QUEUE_PANEL_URI, {
590
693
  title: "Survey review workbench",
@@ -200,6 +200,13 @@ export interface ReviewDecisionSpec {
200
200
  * Additive/optional: absent means the candidate's original value was used unchanged.
201
201
  */
202
202
  editedValue?: unknown;
203
+ /**
204
+ * The proposed candidates a `select-proposed` decision saw and did not
205
+ * choose, when the reviewer chose one value of a conflict (`candidateId` is
206
+ * the chosen one). Additive/optional: absent on every other decision, and on
207
+ * decisions recorded before choosing between conflicting values existed.
208
+ */
209
+ unselectedCandidateIds?: string[];
203
210
  }
204
211
  export interface ReviewDecisionStatus {
205
212
  appliedToClaimIds?: string[];
@@ -14,6 +14,13 @@ export interface ReviewSessionFileContent {
14
14
  readonly session: unknown;
15
15
  readonly snapshot: ReviewQueueSessionState;
16
16
  readonly events: readonly ReviewSessionEvent[];
17
+ /**
18
+ * The extraction import record (`ExtractionEnvelopeImport`) the snapshot's
19
+ * items were built from, stored beside them so every reload path can check
20
+ * the queue against it. Optional: a session without one whose items came
21
+ * from an import is presented as an unverified queue.
22
+ */
23
+ readonly extractionImport?: unknown;
17
24
  }
18
25
  export interface ReviewSessionFileLockOptions {
19
26
  /** How long to wait for a busy lock before failing. Defaults to 10s. */
@@ -25,7 +25,7 @@
25
25
  * it is a duplicate placement or a constant, but a key is never renamed or
26
26
  * repointed at a different fact. Hosts may hold selectors against these values.
27
27
  */
28
- export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "excluded-proposal", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
28
+ export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "excluded-proposal", "excluded-proposals-unreadable", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
29
29
  /** One of {@link reviewAuditRowKeys}. */
30
30
  export type ReviewAuditRowKey = (typeof reviewAuditRowKeys)[number];
31
31
  /**
@@ -36,6 +36,7 @@ export const reviewAuditRowKeys = [
36
36
  "extractor",
37
37
  "extracted-at",
38
38
  "excluded-proposal",
39
+ "excluded-proposals-unreadable",
39
40
  // Unselected candidate history
40
41
  "history-value",
41
42
  "candidate-id",
@@ -1,6 +1,6 @@
1
1
  import type { ReviewQueueSessionState } from "./review-queue-session.js";
2
2
  import { reviewResourceApiVersion, type ReviewItem } from "../review-resource.js";
3
- import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
3
+ import { type ExtractionEnvelopeImport, type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
4
4
  /**
5
5
  * The durable attestation record. Serializable; a consumer stores it beside the
6
6
  * queue when the round opens and presents it, unchanged, at every later
@@ -78,7 +78,7 @@ export declare function bindReviewQueue(snapshot: ReviewQueueSessionState, optio
78
78
  */
79
79
  export declare function validateReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): ReviewQueueBindingIssue[];
80
80
  export declare function assertReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): void;
81
- export type ReviewQueueExtractionIssueCode = "import-not-grounded" | "empty-queue" | "item-missing-from-queue" | "item-not-in-extraction" | "item-diverges-from-extraction";
81
+ export type ReviewQueueExtractionIssueCode = "import-invalid" | "import-not-grounded" | "empty-queue" | "item-missing-from-queue" | "item-not-in-extraction" | "item-diverges-from-extraction";
82
82
  export interface ReviewQueueExtractionIssue {
83
83
  readonly code: ReviewQueueExtractionIssueCode;
84
84
  readonly message: string;
@@ -124,4 +124,47 @@ export declare class UnattestedExtractionQueueError extends Error {
124
124
  * cross-check the label.
125
125
  */
126
126
  export declare function validateReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): ReviewQueueExtractionIssue[];
127
+ /**
128
+ * What a reload path can say about a stored queue and the extraction import it
129
+ * came from:
130
+ *
131
+ * - `attested`: an import record was supplied and the queue matches it (see
132
+ * {@link validateReviewQueueAgainstExtractionImport}).
133
+ * - `diverges`: an import record was supplied and the queue does not match it,
134
+ * or the record itself is invalid. The queue must not be reviewed.
135
+ * - `unverified`: items carry the extraction-envelope binding but no import
136
+ * record was supplied, so nothing checked them. A rival value deleted from
137
+ * the stored queue would not show; every surface says so.
138
+ * - `not-extraction`: no item carries the binding and no record was supplied;
139
+ * there is no import to check against.
140
+ *
141
+ * Detection reads the binding the import writes on every item and candidate.
142
+ * A writer that strips it from every item also strips every other extraction
143
+ * fact from the queue; keeping the stored import record beside the queue is
144
+ * what makes a reload path able to check it.
145
+ */
146
+ export type ReviewQueueExtractionAttestation = {
147
+ readonly state: "attested";
148
+ } | {
149
+ readonly state: "not-extraction";
150
+ } | {
151
+ readonly state: "unverified";
152
+ readonly itemNames: readonly string[];
153
+ readonly message: string;
154
+ } | {
155
+ readonly state: "diverges";
156
+ readonly issues: readonly ReviewQueueExtractionIssue[];
157
+ readonly message: string;
158
+ };
159
+ /** Whether a ReviewItem, or any of its candidates, carries the extraction-envelope binding. */
160
+ export declare function reviewItemCarriesExtractionBinding(item: ReviewItem): boolean;
161
+ /**
162
+ * Check a queue a reload path is about to present against its extraction
163
+ * import. Pass the import record (or import result) stored beside the queue;
164
+ * pass `undefined` when the path has none, and the result says whether that
165
+ * leaves the queue unverified.
166
+ */
167
+ export declare function attestReviewQueueExtraction(items: readonly ReviewItem[], extractionImport: ExtractionEnvelopeImport | ExtractionEnvelopeImportResult | undefined): ReviewQueueExtractionAttestation;
168
+ /** The notice every surface shows for an `unverified` queue. */
169
+ export declare function unverifiedExtractionQueueSentence(itemCount: number): string;
127
170
  export declare function assertReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): void;
@@ -276,6 +276,51 @@ export function validateReviewQueueAgainstExtractionImport(items, importResult)
276
276
  }
277
277
  return issues;
278
278
  }
279
+ const EXTRACTION_ENVELOPE_PRODUCER = "survey.kontourai.io/extraction-envelope";
280
+ /** Whether a ReviewItem, or any of its candidates, carries the extraction-envelope binding. */
281
+ export function reviewItemCarriesExtractionBinding(item) {
282
+ const bound = (producer) => producer !== undefined && producer !== null && typeof producer === "object" && Object.hasOwn(producer, EXTRACTION_ENVELOPE_PRODUCER);
283
+ return bound(item.metadata?.producer) || (Array.isArray(item.spec?.candidates) && item.spec.candidates.some((candidate) => bound(candidate?.producer)));
284
+ }
285
+ /**
286
+ * Check a queue a reload path is about to present against its extraction
287
+ * import. Pass the import record (or import result) stored beside the queue;
288
+ * pass `undefined` when the path has none, and the result says whether that
289
+ * leaves the queue unverified.
290
+ */
291
+ export function attestReviewQueueExtraction(items, extractionImport) {
292
+ if (extractionImport === undefined || extractionImport === null) {
293
+ const itemNames = items.filter(reviewItemCarriesExtractionBinding).map((item) => item.metadata.name);
294
+ return itemNames.length === 0
295
+ ? { state: "not-extraction" }
296
+ : { state: "unverified", itemNames, message: unverifiedExtractionQueueSentence(itemNames.length) };
297
+ }
298
+ const record = isImportResult(extractionImport) ? extractionImport.record : extractionImport;
299
+ let issues;
300
+ try {
301
+ issues = validateReviewQueueAgainstExtractionImport(items, { record, reviewItems: [] });
302
+ }
303
+ catch (error) {
304
+ issues = [{
305
+ code: "import-invalid",
306
+ message: `The stored extraction import is invalid: ${error instanceof Error ? error.message : String(error)}`,
307
+ }];
308
+ }
309
+ return issues.length === 0
310
+ ? { state: "attested" }
311
+ : {
312
+ state: "diverges",
313
+ issues,
314
+ message: `This review queue does not match the extraction import stored with it, so it cannot be reviewed: ${issues.map((issue) => issue.message).join(" ")}`,
315
+ };
316
+ }
317
+ /** The notice every surface shows for an `unverified` queue. */
318
+ export function unverifiedExtractionQueueSentence(itemCount) {
319
+ return `Unverified queue: ${itemCount === 1 ? "1 item came" : `${itemCount} items came`} from an extraction import, but no import record was stored with this queue, so its items were not checked against it. A rival value removed from the stored queue would not be shown.`;
320
+ }
321
+ function isImportResult(value) {
322
+ return typeof value === "object" && value !== null && "record" in value && !("kind" in value);
323
+ }
279
324
  export function assertReviewQueueAgainstExtractionImport(items, importResult) {
280
325
  const issues = validateReviewQueueAgainstExtractionImport(items, importResult);
281
326
  if (issues.length > 0) {