@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
@@ -3,8 +3,8 @@ export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
3
3
  export { reviewResourceApiVersion } from "./review-resource.js";
4
4
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
5
5
  export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
6
- export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
7
- export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssue, ReviewQueueBindingIssueCode, ReviewQueueExtractionIssue, ReviewQueueExtractionIssueCode, ValidateReviewQueueBindingOptions, } from "./review-workbench/queue-binding.js";
6
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, attestReviewQueueExtraction, bindReviewQueue, reviewItemCarriesExtractionBinding, unverifiedExtractionQueueSentence, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
7
+ export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssue, ReviewQueueBindingIssueCode, ReviewQueueExtractionAttestation, ReviewQueueExtractionIssue, ReviewQueueExtractionIssueCode, ValidateReviewQueueBindingOptions, } from "./review-workbench/queue-binding.js";
8
8
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
9
9
  export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
10
10
  export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
@@ -35,8 +35,10 @@ export { repeatedObservation } from "./repeated-observation.js";
35
35
  export type { RepeatedObservationInput } from "./repeated-observation.js";
36
36
  export { sourceOfAuthorityObservation, sourceOfAuthorityObservationBuilder, SourceOfAuthorityObservationBuilder, } from "./source-of-authority-observation.js";
37
37
  export type { SourceAuthorityClass, SourceAuthorityMetadata, SourceOfAuthorityObservationBuilderArgs, SourceOfAuthorityObservationInput, } from "./source-of-authority-observation.js";
38
- export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
39
- export type { InterpretationReadingPresentation, InterpretationReadingSource, ReviewCandidatePresentation, ReviewCandidatePresentationContext, ReviewItemPresentation, ReviewItemPresentationContext, ReviewPresentationAdapter, ReviewPresentationLink, ReviewResultPresentation, ReviewTracePresentationContext, ReviewTraceRef, ReviewValuePresentationContext, } from "./review-workbench/review-presentation.js";
38
+ export { buildCandidateVerification, candidateVerificationKind, DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS, MAX_SUPPORT_VERIFIER_TIMEOUT_MS, foldCandidateVerifications, runSupportVerifier, supportVerificationInputDigest, validateCandidateVerification, valueDigest, } from "./candidate-verification.js";
39
+ export type { BuildCandidateVerificationInput, CandidateVerification, CandidateVerificationInapplicableReason, CandidateVerificationStatus, CandidateVerificationSubject, FoldCandidateVerificationsResult, RunSupportVerifierOptions, SupportAbstainReason, SupportEvidence, SupportVerdict, SupportVerificationInput, SupportVerificationMethod, SupportVerificationResult, SupportVerifier, } from "./candidate-verification.js";
40
+ export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, candidateVerificationNotes, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
41
+ export type { CandidateVerificationNote, CandidateVerificationRecordPresentation, ExcludedProposalPresentation, ExcludedProposalsUnreadable, InterpretationReadingPresentation, InterpretationReadingSource, ReviewCandidatePresentation, ReviewCandidatePresentationContext, ReviewItemPresentation, ReviewItemPresentationContext, ReviewPresentationAdapter, ReviewPresentationLink, ReviewResultPresentation, ReviewTracePresentationContext, ReviewTraceRef, ReviewValuePresentationContext, } from "./review-workbench/review-presentation.js";
40
42
  export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
41
43
  export type { ApiRecordSourceInput, ChecksumInput, ManualEntrySourceInput, PolicyStandardMetadata, PolicyStandardSourceInput, RawSourceInput, UploadedDocumentSourceInput, WebPageSourceInput, } from "./raw-source.js";
42
44
  export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
package/dist/src/index.js CHANGED
@@ -2,7 +2,7 @@ export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
2
2
  export { reviewResourceApiVersion } from "./review-resource.js";
3
3
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
4
4
  export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
- export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
5
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, attestReviewQueueExtraction, bindReviewQueue, reviewItemCarriesExtractionBinding, unverifiedExtractionQueueSentence, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
6
6
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
7
7
  export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
8
8
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
@@ -17,7 +17,8 @@ export { buildCanonicalReviewProofPayload, buildReviewProofAnchor, canonicalRevi
17
17
  export { fieldObservation } from "./field-observation.js";
18
18
  export { repeatedObservation } from "./repeated-observation.js";
19
19
  export { sourceOfAuthorityObservation, sourceOfAuthorityObservationBuilder, SourceOfAuthorityObservationBuilder, } from "./source-of-authority-observation.js";
20
- export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
20
+ export { buildCandidateVerification, candidateVerificationKind, DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS, MAX_SUPPORT_VERIFIER_TIMEOUT_MS, foldCandidateVerifications, runSupportVerifier, supportVerificationInputDigest, validateCandidateVerification, valueDigest, } from "./candidate-verification.js";
21
+ export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, candidateVerificationNotes, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
21
22
  export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
22
23
  export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
23
24
  export { referenceUtteranceExtractor, surveyAgentUtterance, utteranceToSurveyInput, } from "./agent-utterance.js";
@@ -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, 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,27 +92,38 @@ 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}`] : []),
81
106
  ])),
82
107
  ...extractionImportLines(item),
108
+ ...candidateVerificationNotes(item, editedValueFor(item, current)).flatMap((entry) => [``, `Verification: ${entry.sentence}`]),
83
109
  ];
84
110
  if (item.spec.rationale) {
85
111
  lines.push(``, `Rationale: ${item.spec.rationale}`);
86
112
  }
87
113
  return lines.join("\n");
88
114
  }
115
+ /** The reviewer's edit when the item's decision accepts one; verifier records are read against it. */
116
+ function editedValueFor(item, state) {
117
+ return state.decisionsByItemName[item.metadata.name] === "accept-proposed" ? state.editedValuesByItemName?.[item.metadata.name] : undefined;
118
+ }
89
119
  /** What an envelope import says about the item's evidence, including rival values it excluded. */
90
120
  function extractionImportLines(item) {
91
121
  const presentation = buildReviewItemPresentation(item);
92
122
  const excluded = excludedProposalsSentence(presentation.excludedProposals);
123
+ const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
93
124
  return [
94
125
  ...(excluded ? [``, `Excluded: ${excluded} Check the source before accepting.`] : []),
126
+ ...(unreadable ? [``, `Excluded: ${unreadable} Check the source before accepting.`] : []),
95
127
  ...(presentation.excerptVerification ? [``, `Excerpts ${presentation.excerptVerification === "verified" ? "were" : "were not"} checked against the prepared source text at import.`] : []),
96
128
  ];
97
129
  }
@@ -109,17 +141,19 @@ function escapeHtml(text) {
109
141
  .replace(/>/g, ">")
110
142
  .replace(/"/g, """);
111
143
  }
112
- function buildReviewCardHtml(item, snapshot, events) {
144
+ function buildReviewCardHtml(item, snapshot, events, attestation) {
113
145
  const current = currentSessionState(snapshot, events);
114
146
  const summary = reviewSessionSummary(current);
115
147
  const total = current.items.length;
116
148
  const resolved = total - summary.unresolved;
117
149
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
118
150
  const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
119
- // Several proposed values are a conflict: every value is shown, and accept
120
- // (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.
121
154
  const conflict = proposedCandidates.length > 1;
122
155
  const decision = current.decisionsByItemName[item.metadata.name];
156
+ const chosenId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
123
157
  const status = deriveQueueRowStatus(item, current);
124
158
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
125
159
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
@@ -130,21 +164,32 @@ function buildReviewCardHtml(item, snapshot, events) {
130
164
  const proposedCard = (candidate, label) => {
131
165
  const value = valueStr(candidate?.value ?? "—");
132
166
  const excerpt = candidate?.locator?.excerpt ?? "";
133
- 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)}"` : ""}>
134
175
  <div class="card-label">${escapeHtml(label)}</div>
135
176
  <div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
136
177
  <div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
137
178
  <div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
138
179
  ${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
180
+ ${choice}
139
181
  </div>`;
140
182
  };
141
183
  const proposedCards = conflict
142
184
  ? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
143
185
  : proposedCard(proposedCandidates[0], "Proposed");
144
186
  const itemNameJson = escapeJsonInHtml(item.metadata.name);
145
- const excludedNote = excludedProposalsSentence(buildReviewItemPresentation(item).excludedProposals);
187
+ const itemPresentation = buildReviewItemPresentation(item);
188
+ const excludedNote = excludedProposalsSentence(itemPresentation.excludedProposals);
189
+ const unreadableNote = excludedProposalsUnreadableSentence(itemPresentation.excludedProposalsUnreadable);
190
+ const verificationNotes = candidateVerificationNotes(item, editedValueFor(item, current));
146
191
  const decisionBadge = decision
147
- ? `<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>`
148
193
  : `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
149
194
  return `<!doctype html>
150
195
  <html lang="en">
@@ -222,9 +267,15 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
222
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)}
223
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)}
224
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}
225
275
  </style>
226
276
  </head>
227
277
  <body>
278
+ ${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-note">${escapeHtml(attestation.message)}</p>` : ""}
228
279
  <p class="eyebrow">Survey Review</p>
229
280
  <h1>${escapeHtml(item.spec.target)}</h1>
230
281
  <div class="meta">
@@ -245,7 +296,11 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
245
296
  ${proposedCards}
246
297
  </div>
247
298
  ${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
248
- ${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>` : ""}
299
+ ${unreadableNote ? `<p class="feedback" id="excluded-unreadable-note">${escapeHtml(unreadableNote)}</p>` : ""}
300
+ ${verificationNotes.map((entry) => `<p class="feedback verification-note" data-candidate-id="${escapeHtml(entry.candidateId)}">${escapeHtml(entry.sentence)}</p>`).join("\n")}
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>` : ""}
249
304
 
250
305
  <div class="divider"></div>
251
306
 
@@ -265,7 +320,7 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
265
320
  var itemName = ${itemNameJson};
266
321
  var msgId = 1;
267
322
 
268
- function postDecision(decision) {
323
+ function postDecision(decision, candidateId) {
269
324
  var note = document.getElementById('note').value;
270
325
  if (decision === 'could-not-confirm' && !note.trim()) {
271
326
  document.getElementById('feedback').textContent = 'A reason is required when you could not confirm.';
@@ -280,12 +335,18 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
280
335
  name: "survey_review_decide",
281
336
  arguments: decision === 'could-not-confirm'
282
337
  ? { itemName: itemName, decision: decision, reason: note }
283
- : { 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 }
284
341
  }
285
342
  }, "*");
286
343
  return true;
287
344
  }
288
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
+
289
350
  var acceptButton = document.getElementById('btn-accept');
290
351
  if (acceptButton) acceptButton.addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
291
352
  var holdButton = document.getElementById('btn-hold');
@@ -311,16 +372,21 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
311
372
  // ---- Tool implementations ------------------------------------------------
312
373
  async function toolQueue(options) {
313
374
  const file = await readSessionFile(options.sessionPath);
314
- const { snapshot, events } = file;
315
- const text = queueSummaryText(snapshot, events);
375
+ const { snapshot, events, attestation } = file;
376
+ const text = queueSummaryText(snapshot, events, attestation);
316
377
  const queueData = {
378
+ queueAttestation: attestation.state,
317
379
  items: snapshot.items.map((item) => {
318
380
  const current = currentSessionState(snapshot, events);
381
+ const selectedCandidateId = current.decisionsByItemName[item.metadata.name] === "select-proposed"
382
+ ? current.selectedCandidateIdsByItemName?.[item.metadata.name]
383
+ : undefined;
319
384
  return {
320
385
  name: item.metadata.name,
321
386
  target: item.spec.target,
322
387
  status: deriveQueueRowStatus(item, current),
323
388
  decision: current.decisionsByItemName[item.metadata.name],
389
+ ...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
324
390
  candidateSetStatus: item.spec.candidateSetStatus,
325
391
  };
326
392
  }),
@@ -332,24 +398,28 @@ async function toolQueue(options) {
332
398
  ];
333
399
  if (!options.noUi) {
334
400
  const activeItem = currentReviewItem(currentSessionState(snapshot, events));
335
- content.push(buildUiResource(activeItem, snapshot, events, "queue"));
401
+ content.push(buildUiResource(activeItem, snapshot, events, "queue", attestation));
336
402
  }
337
403
  return content;
338
404
  }
339
405
  async function toolItem(itemName, options) {
340
406
  const file = await readSessionFile(options.sessionPath);
341
- const { snapshot, events } = file;
407
+ const { snapshot, events, attestation } = file;
342
408
  const current = currentSessionState(snapshot, events);
343
409
  const item = current.items.find((i) => i.metadata.name === itemName);
344
410
  if (!item) {
345
411
  throw new DomainError(`Unknown review item: ${itemName}`);
346
412
  }
347
- 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;
348
416
  const itemData = {
417
+ queueAttestation: attestation.state,
349
418
  name: item.metadata.name,
350
419
  target: item.spec.target,
351
420
  status: deriveQueueRowStatus(item, current),
352
- decision: current.decisionsByItemName[item.metadata.name],
421
+ decision,
422
+ ...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
353
423
  note: current.notesByItemName[item.metadata.name],
354
424
  candidateSetStatus: item.spec.candidateSetStatus,
355
425
  ...(() => {
@@ -357,37 +427,47 @@ async function toolItem(itemName, options) {
357
427
  return {
358
428
  ...(presentation.excerptVerification ? { excerptVerification: presentation.excerptVerification } : {}),
359
429
  ...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
430
+ ...(presentation.excludedProposalsUnreadable ? { excludedProposalsUnreadable: presentation.excludedProposalsUnreadable } : {}),
360
431
  };
361
432
  })(),
362
- candidates: item.spec.candidates.map((c) => ({
363
- id: c.id,
364
- role: c.role,
365
- value: c.value,
366
- confidence: c.extraction?.confidence ?? c.confidence,
367
- sourceRef: c.source?.sourceRef,
368
- excerpt: c.locator?.excerpt,
369
- })),
433
+ candidates: (() => {
434
+ const notes = candidateVerificationNotes(item, editedValueFor(item, current));
435
+ return item.spec.candidates.map((c, index) => {
436
+ const note = notes.find((entry) => entry.candidateIndex === index);
437
+ return {
438
+ id: c.id,
439
+ role: c.role,
440
+ value: c.value,
441
+ confidence: c.extraction?.confidence ?? c.confidence,
442
+ sourceRef: c.source?.sourceRef,
443
+ excerpt: c.locator?.excerpt,
444
+ ...(selectedCandidateId !== undefined && c.role === "proposed" ? { chosen: c.id === selectedCandidateId } : {}),
445
+ ...(note ? { verification: { subject: note.subjectLabel, status: note.status, records: note.records, inapplicableCount: note.inapplicableCount, rejectedCount: note.rejectedCount } } : {}),
446
+ };
447
+ });
448
+ })(),
370
449
  };
371
450
  const content = [
372
451
  { type: "text", text: `${text}\n\n${JSON.stringify(itemData, null, 2)}` },
373
452
  ];
374
453
  if (!options.noUi) {
375
- content.push(buildUiResource(item, snapshot, events, itemName));
454
+ content.push(buildUiResource(item, snapshot, events, itemName, attestation));
376
455
  }
377
456
  return content;
378
457
  }
379
- async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options) {
458
+ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options, selectedCandidateId) {
380
459
  const wbDecision = MCP_DECISION_MAP[mcpDecision];
381
460
  if (!wbDecision) {
382
- 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.`);
383
462
  }
384
463
  if (wbDecision === "could-not-confirm" && !note?.trim()) {
385
464
  throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
386
465
  }
387
466
  // Read, validate and write inside the shared session lock so a concurrent
388
467
  // decide or console save cannot interleave and drop this decision (#281).
389
- const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
468
+ const { snapshot, sessionWithDecision, newEvents, attestation } = await updateReviewSessionFile(options.sessionPath, (file) => {
390
469
  const { snapshot, events } = file;
470
+ const attestation = attestedQueue(file);
391
471
  const current = currentSessionState(snapshot, events);
392
472
  const item = current.items.find((i) => i.metadata.name === itemName);
393
473
  if (!item) {
@@ -397,6 +477,12 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
397
477
  if (existingDecision) {
398
478
  throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
399
479
  }
480
+ if (wbDecision === "select-proposed") {
481
+ const selectionIssue = conflictSelectionIssue(item, selectedCandidateId);
482
+ if (selectionIssue) {
483
+ throw new DomainError(selectionIssue);
484
+ }
485
+ }
400
486
  // Build the updated session state with the decision
401
487
  const sessionWithDecision = {
402
488
  ...current,
@@ -420,6 +506,14 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
420
506
  },
421
507
  }
422
508
  : {}),
509
+ ...(wbDecision === "select-proposed" && selectedCandidateId !== undefined
510
+ ? {
511
+ selectedCandidateIdsByItemName: {
512
+ ...current.selectedCandidateIdsByItemName,
513
+ [itemName]: selectedCandidateId,
514
+ },
515
+ }
516
+ : {}),
423
517
  };
424
518
  // Append only this decision's events (its note, then the decision) to the
425
519
  // stored log. Regenerating the whole log from state would erase earlier
@@ -441,26 +535,36 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
441
535
  record,
442
536
  events: newEvents,
443
537
  requiredResolvedItems: "none",
538
+ extractionImport: file.extractionImport,
444
539
  });
445
540
  if (!applyResult.ok) {
446
541
  throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
447
542
  }
448
543
  const updatedFile = {
544
+ ...file,
449
545
  session: file.session,
450
546
  snapshot,
451
547
  events: newEvents,
452
548
  };
453
- return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
549
+ return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents, attestation } };
454
550
  });
455
551
  // Summarize the result
456
552
  const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
457
- const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
458
- const remainingText = queueSummaryText(snapshot, newEvents);
553
+ const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents, attestation) : `Item: ${itemName}`;
554
+ const remainingText = queueSummaryText(snapshot, newEvents, attestation);
459
555
  const definition = workbenchDecisionDefinitions[wbDecision];
460
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;
461
565
  const text = [
462
566
  `Decision recorded: ${conflictRejected ? "Reject all values" : definition.label}`,
463
- `Effect: ${conflictRejected ? "Every proposed value is rejected; none becomes the claim's value." : definition.effect}`,
567
+ `Effect: ${effect}`,
464
568
  "",
465
569
  itemText,
466
570
  "",
@@ -469,13 +573,13 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
469
573
  ].join("\n");
470
574
  return [{ type: "text", text }];
471
575
  }
472
- function buildUiResource(item, snapshot, events, instance) {
576
+ function buildUiResource(item, snapshot, events, instance, attestation) {
473
577
  return {
474
578
  type: "resource",
475
579
  resource: {
476
580
  uri: `ui://survey/review-card/${encodeURIComponent(instance)}`,
477
581
  mimeType: "text/html;profile=mcp-app",
478
- text: buildReviewCardHtml(item, snapshot, events),
582
+ text: buildReviewCardHtml(item, snapshot, events, attestation),
479
583
  _meta: {
480
584
  ui: {
481
585
  csp: {
@@ -491,10 +595,10 @@ function buildUiResource(item, snapshot, events, instance) {
491
595
  // Render the declared review card from the same function used by embedded tool
492
596
  // results, so Apps and text-first hosts cannot drift.
493
597
  async function readQueuePanelResource(options) {
494
- const { snapshot, events } = await readSessionFile(options.sessionPath);
598
+ const { snapshot, events, attestation } = await readSessionFile(options.sessionPath);
495
599
  const current = currentSessionState(snapshot, events);
496
600
  const activeItem = currentReviewItem(current);
497
- return buildUiResource(activeItem, snapshot, events, "queue").resource;
601
+ return buildUiResource(activeItem, snapshot, events, "queue", attestation).resource;
498
602
  }
499
603
  // ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
500
604
  class DomainError extends Error {
@@ -543,7 +647,7 @@ function createReviewMcpServer(options, serverVersion) {
543
647
  }, async ({ itemName }) => runReviewTool(() => toolItem(itemName, options)));
544
648
  server.registerTool("survey_review_decide", {
545
649
  title: "Record a review decision",
546
- 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.",
547
651
  inputSchema: z.discriminatedUnion("decision", [
548
652
  z.object({
549
653
  itemName: z.string().min(1).describe("The ReviewItem name to decide."),
@@ -567,10 +671,23 @@ function createReviewMcpServer(options, serverVersion) {
567
671
  .optional()
568
672
  .describe("Evidence ids attempted before a could-not-confirm decision."),
569
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
+ }),
570
685
  ]),
571
686
  }, async (input) => runReviewTool(() => input.decision === "could-not-confirm"
572
687
  ? toolDecide(input.itemName, input.decision, input.reason, input.attemptEvidenceIds, options)
573
- : 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)));
574
691
  if (!options.noUi) {
575
692
  server.registerResource("survey-review-workbench", QUEUE_PANEL_URI, {
576
693
  title: "Survey review workbench",
@@ -1,3 +1,4 @@
1
+ import type { CandidateVerification } from "./candidate-verification.js";
1
2
  import type { CandidateSetStatus, ClaimTarget, Extraction, RawSource, ReviewAuthorizing, ReviewOutcome } from "./types.js";
2
3
  export declare const reviewResourceApiVersion = "survey.kontourai.io/v1alpha1";
3
4
  export type ReviewResourceApiVersion = typeof reviewResourceApiVersion;
@@ -104,6 +105,14 @@ export interface ReviewCandidate {
104
105
  claimTarget: ClaimTargetHint;
105
106
  projection?: SurveyRecordProjectionHint;
106
107
  producer?: Record<string, unknown>;
108
+ /**
109
+ * Records of what independent support verifiers said about this candidate's
110
+ * value (see `CandidateVerification`). A record of a verifier's output, not a
111
+ * trust decision: nothing routes, accepts, or calibrates on it. Every decision
112
+ * surface validates each record on read, ignores any that fail, and shows only
113
+ * those bound to the value under review.
114
+ */
115
+ verifications?: CandidateVerification[];
107
116
  }
108
117
  /**
109
118
  * Well-known neutral value-type vocabulary for {@link ReviewValueDescriptor}.
@@ -191,6 +200,13 @@ export interface ReviewDecisionSpec {
191
200
  * Additive/optional: absent means the candidate's original value was used unchanged.
192
201
  */
193
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[];
194
210
  }
195
211
  export interface ReviewDecisionStatus {
196
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",