@kontourai/survey 3.0.0 → 5.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 (46) hide show
  1. package/README.md +4 -0
  2. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  3. package/dist/examples/calibrated-auto-accept.js +40 -36
  4. package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
  5. package/dist/src/calibration.d.ts +48 -21
  6. package/dist/src/calibration.js +72 -33
  7. package/dist/src/canonical-reviewed-trust-input.js +73 -34
  8. package/dist/src/console/review-console-server.d.ts +3 -1
  9. package/dist/src/console/review-console-server.js +203 -50
  10. package/dist/src/extraction-envelope.d.ts +81 -3
  11. package/dist/src/extraction-envelope.js +183 -24
  12. package/dist/src/index.d.ts +9 -8
  13. package/dist/src/index.js +3 -3
  14. package/dist/src/inquiry-mapping.d.ts +15 -1
  15. package/dist/src/inquiry-mapping.js +10 -2
  16. package/dist/src/mcp/review-mcp.js +112 -90
  17. package/dist/src/producer-profile.d.ts +41 -2
  18. package/dist/src/producer-profile.js +29 -2
  19. package/dist/src/review-session-file.d.ts +64 -0
  20. package/dist/src/review-session-file.js +320 -0
  21. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  22. package/dist/src/review-workbench/edited-value.js +147 -0
  23. package/dist/src/review-workbench/extraction-inspector.d.ts +15 -1
  24. package/dist/src/review-workbench/extraction-inspector.js +55 -19
  25. package/dist/src/review-workbench/queue-binding.js +1 -1
  26. package/dist/src/review-workbench/review-presentation.d.ts +46 -1
  27. package/dist/src/review-workbench/review-presentation.js +72 -1
  28. package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
  29. package/dist/src/review-workbench/review-queue-session.js +54 -14
  30. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  31. package/dist/src/review-workbench/review-session-replay.js +82 -3
  32. package/dist/src/review-workbench/review-workbench-css.generated.js +2 -0
  33. package/dist/src/review-workbench/review-workbench.css +2 -0
  34. package/dist/src/review-workbench/review-workbench.d.ts +22 -11
  35. package/dist/src/review-workbench/review-workbench.js +91 -26
  36. package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
  37. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  38. package/dist/src/review-workbench/server-review-session.js +1 -0
  39. package/dist/src/reviewed-candidate-resolution.js +13 -7
  40. package/dist/src/schema-mapping.d.ts +23 -0
  41. package/dist/src/schema-mapping.js +30 -20
  42. package/dist/src/surface-reviewed-extraction.js +4 -0
  43. package/dist/src/to-surface.d.ts +30 -6
  44. package/dist/src/to-surface.js +312 -18
  45. package/dist/src/types.d.ts +44 -1
  46. package/package.json +5 -4
@@ -26,14 +26,19 @@ export function buildExtractionInspectorModel(input) {
26
26
  if (sourceKeys.has(sourceKey))
27
27
  throw new Error("Extraction inspector source identity collision.");
28
28
  sourceKeys.add(sourceKey);
29
- const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.state, entry.artifact, envelope.result.ocrDerived);
29
+ const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived);
30
30
  sources.push(source);
31
31
  const candidateStart = candidates.length;
32
+ const itemByProposalIndex = new Map();
33
+ for (const item of entry.importResult.reviewItems) {
34
+ for (const proposalIndex of envelopeItemProposalIndices(item))
35
+ itemByProposalIndex.set(proposalIndex, item.metadata.name);
36
+ }
32
37
  envelope.result.proposals.forEach((proposal, proposalIndex) => {
33
- const item = entry.importResult.reviewItems[proposalIndex];
34
- if (!item)
38
+ const itemName = itemByProposalIndex.get(proposalIndex);
39
+ if (!itemName)
35
40
  return; // unresolved imports legitimately produce no ReviewItems
36
- candidates.push(candidateModel(source, item.metadata.name, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass, envelope.result.pdfPageOffsets, envelope.result.pdfLayout, envelope.result.ocrDerived));
41
+ candidates.push(candidateModel(source, itemName, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass, envelope.result.pdfPageOffsets, envelope.result.pdfLayout, envelope.result.ocrDerived));
37
42
  });
38
43
  if (source.alignment === "excerpt-mismatch") {
39
44
  delete source.artifactText;
@@ -141,28 +146,53 @@ function assertImportedResult(result, record) {
141
146
  if (!record.metadata?.name || !record.metadata.producerNamespace || !record.spec?.envelope?.result || !Array.isArray(record.spec.envelope.result.proposals))
142
147
  throw new Error("Malformed extraction import result.");
143
148
  const grounded = record.status?.state === "grounded";
144
- if ((!grounded && reviewItems.length !== 0) || (grounded && reviewItems.length !== record.spec.envelope.result.proposals.length))
149
+ const proposals = record.spec.envelope.result.proposals;
150
+ const covered = reviewItems.flatMap(envelopeItemProposalIndices).sort((left, right) => left - right);
151
+ if ((!grounded && reviewItems.length !== 0) || (grounded && canonicalJson(covered) !== canonicalJson(proposals.map((_proposal, index) => index))))
145
152
  throw new Error("Extraction import ReviewItems do not match its grounding state.");
146
153
  const canonicalItems = buildReviewItemsFromExtractionEnvelopeImport(record);
147
154
  if (canonicalJson(reviewItems) !== canonicalJson(canonicalItems))
148
155
  throw new Error("Extraction import ReviewItems do not match their canonical identities and bindings.");
149
- reviewItems.forEach((item, index) => {
150
- const proposal = record.spec.envelope.result.proposals[index];
156
+ reviewItems.forEach((item, itemIndex) => {
151
157
  const metadata = item.metadata?.producer?.["survey.kontourai.io/extraction-envelope"];
152
- const candidate = item.spec?.candidates?.[0];
153
- const binding = candidate?.producer?.["survey.kontourai.io/extraction-envelope"];
154
- if (item.kind !== "ReviewItem" || !item.metadata.name || item.spec.candidates.length !== 1
155
- || metadata?.importName !== record.metadata.name || binding?.importName !== record.metadata.name
156
- || binding.proposalIndex !== index || binding.runId !== record.spec.envelope.result.runId || binding.provider !== record.spec.envelope.result.provider
157
- || item.spec.target !== proposal.fieldPath || candidate?.locator?.locator !== proposal.provenance.locator || candidate.locator.excerpt !== proposal.provenance.excerpt) {
158
- throw new Error(`Extraction import ReviewItem ${index} is inconsistent with its validated proposal.`);
158
+ if (item.kind !== "ReviewItem" || !item.metadata.name || item.spec.candidates.length === 0 || metadata?.importName !== record.metadata.name) {
159
+ throw new Error(`Extraction import ReviewItem ${itemIndex} is inconsistent with its validated proposals.`);
160
+ }
161
+ for (const candidate of item.spec.candidates) {
162
+ const binding = candidate.producer?.["survey.kontourai.io/extraction-envelope"];
163
+ const proposal = typeof binding?.proposalIndex === "number" ? proposals[binding.proposalIndex] : undefined;
164
+ if (!proposal || binding?.importName !== record.metadata.name || binding.runId !== record.spec.envelope.result.runId || binding.provider !== record.spec.envelope.result.provider
165
+ || candidate.extraction.target !== proposal.fieldPath || candidate.locator?.locator !== proposal.provenance.locator || candidate.locator.excerpt !== proposal.provenance.excerpt) {
166
+ throw new Error(`Extraction import ReviewItem ${itemIndex} is inconsistent with its validated proposals.`);
167
+ }
159
168
  }
160
169
  });
161
170
  }
162
- function sourceModel(key, importName, prepared, state, artifact, ocrDerived) {
171
+ /** The proposal indices one imported ReviewItem stands for, as its producer metadata records them. */
172
+ function envelopeItemProposalIndices(item) {
173
+ const metadata = item.metadata?.producer?.["survey.kontourai.io/extraction-envelope"];
174
+ const indices = metadata?.proposalIndices;
175
+ if (!Array.isArray(indices) || indices.length === 0 || !indices.every((index) => Number.isSafeInteger(index) && index >= 0)) {
176
+ throw new Error(`Extraction import ReviewItem ${item.metadata?.name ?? "(unnamed)"} does not record its proposals.`);
177
+ }
178
+ return indices;
179
+ }
180
+ /**
181
+ * The posture a source is shown with: the extraction's own failure or early
182
+ * stop when there is one, otherwise the artifact alignment. A failed
183
+ * extraction over an aligned artifact must not show the aligned posture.
184
+ */
185
+ export function inspectorSourcePosture(source) {
186
+ if (source.extractionDiagnostic)
187
+ return source.extractionDiagnostic.kind;
188
+ return source.alignment;
189
+ }
190
+ function sourceModel(key, importName, prepared, diagnostics, artifact, ocrDerived) {
163
191
  let alignment;
164
192
  let message;
165
- if (state !== "grounded" || artifact.status === "unavailable") {
193
+ const artifactUnresolved = diagnostics.some((diagnostic) => diagnostic.kind === "artifact-unavailable" || diagnostic.kind === "digest-mismatch");
194
+ const extractionDiagnostic = diagnostics.find((diagnostic) => diagnostic.kind === "extraction-failed" || diagnostic.kind === "extraction-incomplete");
195
+ if (artifactUnresolved || artifact.status === "unavailable") {
166
196
  alignment = "artifact-unavailable";
167
197
  message = `Prepared artifact unavailable (${artifact.status === "unavailable" ? artifact.code : "invalid-artifact"}). Candidates are not grounded.`;
168
198
  }
@@ -177,9 +207,15 @@ function sourceModel(key, importName, prepared, state, artifact, ocrDerived) {
177
207
  }
178
208
  else {
179
209
  alignment = "aligned";
180
- message = `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
210
+ message = extractionDiagnostic
211
+ ? "Prepared artifact identity verified."
212
+ : `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
181
213
  }
182
- return { key, importName, ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), ...(ocrDerived ? { ocrDerived: true } : {}), alignment, message };
214
+ // The extraction's own failure leads: an aligned artifact with no candidates
215
+ // must not read as a complete run that found nothing.
216
+ if (extractionDiagnostic)
217
+ message = `${extractionDiagnostic.message} ${message}`;
218
+ return { key, importName, ...(extractionDiagnostic ? { extractionDiagnostic } : {}), ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), ...(ocrDerived ? { ocrDerived: true } : {}), alignment, message };
183
219
  }
184
220
  function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass, pdfPageOffsets, pdfLayout, ocrDerived) {
185
221
  const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
@@ -270,7 +306,7 @@ export function mountExtractionInspector(container, model, options = {}) {
270
306
  next.hidden = pageCount === 1;
271
307
  previous.disabled = page === 0;
272
308
  next.disabled = page >= pageCount - 1;
273
- postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
309
+ postures.innerHTML = model.sources.map(s => { const posture = inspectorSourcePosture(s); return `<div class="inspector-posture ${s.alignment}${posture !== s.alignment ? ` ${posture}` : ""}" role="status" data-posture="${escapeHtml(posture)}"><strong>${escapeHtml(s.importName)}: ${escapeHtml(posture)}</strong><span>${escapeHtml(s.message)}</span></div>`; }).join("");
274
310
  sourcesRoot.innerHTML = model.sources.map(s => { const anchored = model.candidates.filter(c => c.sourceKey === s.key); const marked = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `${anchored.map(c => anchorHtml(c, highlightIdFor(c))).join("")}<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, anchored, marked, highlightIdFor)}</pre></div>`; }).join("");
275
311
  };
276
312
  root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
@@ -229,7 +229,7 @@ export function validateReviewQueueAgainstExtractionImport(items, importResult)
229
229
  if (record.status.state !== "grounded") {
230
230
  return [{
231
231
  code: "import-not-grounded",
232
- message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded; it cannot attest a review queue.`,
232
+ message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded (${record.status.diagnostics.map((diagnostic) => diagnostic.message).join(" ")}); it cannot attest a review queue.`,
233
233
  }];
234
234
  }
235
235
  if (items.length === 0) {
@@ -1,4 +1,5 @@
1
1
  import { type ReviewCandidate, type ReviewItem } from "../review-resource.js";
2
+ import type { InterpretationAnswerImpact, InterpretationReadingKind } from "../types.js";
2
3
  import { type ReviewWorkbenchResult } from "./review-workbench.js";
3
4
  export interface ReviewPresentationAdapter {
4
5
  readonly labelForTarget?: (target: string, context: ReviewItemPresentationContext) => string | undefined;
@@ -56,11 +57,55 @@ export interface ReviewResultPresentation {
56
57
  readonly target: string;
57
58
  readonly targetLabel: string;
58
59
  readonly decisionLabel: string;
59
- readonly selectedValueText: string;
60
+ /** Absent when the decision selects no candidate (reject-all or could-not-confirm on a conflict). */
61
+ readonly selectedValueText?: string;
60
62
  readonly applyMeaning: string;
61
63
  readonly reviewItemLink?: ReviewPresentationLink;
62
64
  readonly traceRefs: readonly ReviewTraceRef[];
63
65
  }
66
+ /**
67
+ * Structural input for {@link buildInterpretationReadingPresentation}: either
68
+ * a Survey `Interpretation` record (`id`) or the entry Survey projects onto a
69
+ * claim at `metadata.survey.interpretations[]` (`interpretationId`). Kind and
70
+ * impact arrive as plain strings when read back from projected metadata.
71
+ */
72
+ export interface InterpretationReadingSource {
73
+ readonly id?: string;
74
+ readonly interpretationId?: string;
75
+ readonly readingKind?: string;
76
+ readonly answerImpact?: string;
77
+ readonly ruleLocator: string;
78
+ readonly reading: string;
79
+ readonly actor: string;
80
+ readonly recordedAt: string;
81
+ }
82
+ export interface InterpretationReadingPresentation {
83
+ readonly interpretationId: string;
84
+ readonly readingKind: InterpretationReadingKind;
85
+ readonly kindLabel: string;
86
+ readonly answerImpact?: InterpretationAnswerImpact;
87
+ readonly answerImpactLabel?: string;
88
+ readonly reading: string;
89
+ readonly actor: string;
90
+ readonly recordedAt: string;
91
+ readonly ruleLocator: string;
92
+ /**
93
+ * Always `"authored-judgment"`. This is DERIVED from the record type, not a
94
+ * stored flag: every Interpretation reading is a producer-authored reading
95
+ * by contract (CONTEXT.md "Interpretation Record"), never a machine-observed
96
+ * fact. Renderers must present readings under this marking, visually
97
+ * distinct from machine-observed values — the StatementBadge / ADR 0003 §4
98
+ * discipline (blending the two is the defect class of #247).
99
+ */
100
+ readonly provenance: "authored-judgment";
101
+ readonly provenanceLabel: string;
102
+ }
103
+ /**
104
+ * Presents one interpretation reading as authored judgment. Fails closed on
105
+ * unknown reading-kind / answer-impact vocabulary rather than rendering an
106
+ * authored record under a label nothing derived.
107
+ */
108
+ export declare function buildInterpretationReadingPresentation(source: InterpretationReadingSource): InterpretationReadingPresentation;
64
109
  export declare function buildReviewItemPresentation(item: ReviewItem, adapter?: ReviewPresentationAdapter): ReviewItemPresentation;
65
110
  export declare function buildReviewCandidatePresentation(item: ReviewItem, candidate: ReviewCandidate, adapter?: ReviewPresentationAdapter, targetLabel?: string): ReviewCandidatePresentation;
66
111
  export declare function buildReviewResultPresentation(result: ReviewWorkbenchResult, item: ReviewItem | undefined, adapter?: ReviewPresentationAdapter): ReviewResultPresentation;
@@ -1,5 +1,54 @@
1
1
  import { findSoleCandidateById } from "../review-resource.js";
2
2
  import { formatValue } from "./review-surface-preview.js";
3
+ const INTERPRETATION_KIND_LABELS = {
4
+ "policy-standard": "Policy-standard reading",
5
+ gleaned: "Gleaned from results",
6
+ answerImpact: "Answer impact",
7
+ };
8
+ const ANSWER_IMPACT_LABELS = {
9
+ supported: "Supported the answer",
10
+ narrowed: "Narrowed the answer",
11
+ "accepted-risk": "Accepted as a risk",
12
+ };
13
+ /**
14
+ * Presents one interpretation reading as authored judgment. Fails closed on
15
+ * unknown reading-kind / answer-impact vocabulary rather than rendering an
16
+ * authored record under a label nothing derived.
17
+ */
18
+ export function buildInterpretationReadingPresentation(source) {
19
+ const interpretationId = source.interpretationId ?? source.id;
20
+ if (!interpretationId) {
21
+ throw new Error("Interpretation reading presentation requires an id or interpretationId.");
22
+ }
23
+ const readingKind = (source.readingKind ?? "policy-standard");
24
+ const kindLabel = INTERPRETATION_KIND_LABELS[readingKind];
25
+ if (!kindLabel) {
26
+ throw new Error(`Interpretation ${interpretationId} has unknown readingKind ${String(source.readingKind)}`);
27
+ }
28
+ const answerImpact = source.answerImpact;
29
+ const answerImpactLabel = answerImpact === undefined ? undefined : ANSWER_IMPACT_LABELS[answerImpact];
30
+ if (answerImpact !== undefined && !answerImpactLabel) {
31
+ throw new Error(`Interpretation ${interpretationId} has unknown answerImpact ${String(source.answerImpact)}`);
32
+ }
33
+ if (readingKind === "answerImpact" && answerImpact === undefined) {
34
+ throw new Error(`Interpretation ${interpretationId} readingKind answerImpact requires an answerImpact value`);
35
+ }
36
+ if (readingKind !== "answerImpact" && answerImpact !== undefined) {
37
+ throw new Error(`Interpretation ${interpretationId} sets answerImpact but readingKind is ${readingKind}`);
38
+ }
39
+ return {
40
+ interpretationId,
41
+ readingKind,
42
+ kindLabel,
43
+ ...(answerImpact !== undefined ? { answerImpact, answerImpactLabel } : {}),
44
+ reading: source.reading,
45
+ actor: source.actor,
46
+ recordedAt: source.recordedAt,
47
+ ruleLocator: source.ruleLocator,
48
+ provenance: "authored-judgment",
49
+ provenanceLabel: "Authored judgment",
50
+ };
51
+ }
3
52
  export function buildReviewItemPresentation(item, adapter = {}) {
4
53
  const context = { item };
5
54
  const targetLabel = adapter.labelForTarget?.(item.spec.target, context) ?? humanizeIdentifier(item.spec.target);
@@ -36,6 +85,28 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
36
85
  ? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
37
86
  : humanizeIdentifier(target);
38
87
  const selectedCandidate = item ? selectedCandidateForResult(item, result) : undefined;
88
+ // A decision that selects no candidate presents no selected value and no
89
+ // selected trace; it names every candidate instead.
90
+ if (result.selectedCandidateId === undefined) {
91
+ const rejected = result.decision === "reject-proposed";
92
+ return {
93
+ result,
94
+ item,
95
+ target,
96
+ targetLabel,
97
+ decisionLabel: humanizeIdentifier(result.decision),
98
+ applyMeaning: rejected
99
+ ? "Saved decision rejects every proposed value; none is applied"
100
+ : "Saved decision records that no proposed value could be confirmed; none is applied",
101
+ reviewItemLink: item && itemContext ? adapter.linkForReviewItem?.(item, itemContext) : undefined,
102
+ traceRefs: [
103
+ { label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item", context: undefined },
104
+ ...result.unselectedCandidates.map((candidate) => ({
105
+ label: rejected ? "Rejected candidate" : "Unconfirmed candidate", value: candidate.id, kind: "candidate", context: candidate,
106
+ })),
107
+ ].flatMap(({ context, ...ref }) => (item ? withTraceLinks([ref], { item, candidate: context }, adapter) : [ref])),
108
+ };
109
+ }
39
110
  return {
40
111
  result,
41
112
  item,
@@ -119,7 +190,7 @@ function traceRefsForCandidate(item, candidate, adapter) {
119
190
  function traceRefsForResult(item, result, selectedCandidate, adapter) {
120
191
  return withTraceLinks([
121
192
  { label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" },
122
- { label: "Selected candidate", value: result.selectedCandidateId, kind: "candidate" },
193
+ { label: "Selected candidate", value: result.selectedCandidateId ?? "none", kind: "candidate" },
123
194
  {
124
195
  label: "Selected claim",
125
196
  value: selectedCandidate?.claimTarget.claimId ?? "not provided",
@@ -94,15 +94,29 @@ export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boole
94
94
  /**
95
95
  * The candidate a workbench decision applies to.
96
96
  *
97
- * Selection is by role, but the id this returns is what every caller makes
98
- * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
99
- * replay expectation. So the id has to name exactly one candidate before it
100
- * leaves here. Guarding the render path alone let the workbench emit an
101
- * undecidable decision through the export path and then present a different
97
+ * Selection is by role, so the role has to name exactly one candidate before a
98
+ * decision may trust it. Guarding the render path alone let the workbench emit
99
+ * an undecidable decision through the export path and then present a different
102
100
  * candidate's value against it; this is the shared selector all of those go
103
101
  * through, which is why the check belongs here rather than at each of them.
102
+ *
103
+ * A decision that would make one of several candidates in its role the trusted
104
+ * value is refused: picking the first would settle the conflict for the
105
+ * reviewer without showing it. A value-neutral decision (reject, could not
106
+ * confirm) on such an item selects none of them. It still returns the first
107
+ * candidate so in-process callers that need one candidate (rendering, the
108
+ * in-memory `ReviewWorkbenchResult`) have one, but that anchor is not recorded:
109
+ * {@link decisionCandidateId} is `undefined` for it, so the decision, its
110
+ * session events and the canonical projection name no candidate.
104
111
  */
105
112
  export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
113
+ /** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
114
+ export declare function decisionSelectsNoCandidate(item: ReviewItem, decision: ReviewWorkbenchDecision): boolean;
115
+ /**
116
+ * The candidate id a decision records: the selected candidate's, or
117
+ * `undefined` when the decision selects no candidate.
118
+ */
119
+ export declare function decisionCandidateId(item: ReviewItem, decision: ReviewWorkbenchDecision): string | undefined;
106
120
  /**
107
121
  * The value that should actually be applied for a decision: the reviewer's inline
108
122
  * edit when one was made for an accept-proposed decision, otherwise the selected
@@ -1,5 +1,6 @@
1
1
  import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
+ import { checkEditedValueForItem } from "./edited-value.js";
3
4
  import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
4
5
  export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
5
6
  export const defaultReviewSessionName = "review-workbench-session";
@@ -142,32 +143,65 @@ export function reviewSessionSummary(session) {
142
143
  * would invent a prior value, with provenance, that the source never had.
143
144
  */
144
145
  export function keepActionDecision(item, flaggedWrong) {
145
- const hasRole = (role) => item.spec.candidates.some((candidate) => candidate.role === role);
146
- if (flaggedWrong || !hasRole("current")) {
147
- return hasRole("proposed") ? "reject-proposed" : undefined;
146
+ // Keeping the current value is recordable only when exactly one candidate is
147
+ // current (see candidateForDecision); rejecting the proposed values is
148
+ // recordable however many there are.
149
+ const count = (role) => item.spec.candidates.filter((candidate) => candidate.role === role).length;
150
+ if (flaggedWrong || count("current") === 0) {
151
+ return count("proposed") > 0 ? "reject-proposed" : undefined;
148
152
  }
149
- return "keep-current";
153
+ return count("current") === 1 ? "keep-current" : undefined;
150
154
  }
155
+ /**
156
+ * Decisions that make no candidate the trusted value: rejecting the proposed
157
+ * values and ending the round as could-not-confirm. On an item whose role holds
158
+ * several candidates (conflicting values for one claim) they select none of them.
159
+ */
160
+ const VALUE_NEUTRAL_DECISIONS = new Set(["reject-proposed", "could-not-confirm"]);
151
161
  /**
152
162
  * The candidate a workbench decision applies to.
153
163
  *
154
- * Selection is by role, but the id this returns is what every caller makes
155
- * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
156
- * replay expectation. So the id has to name exactly one candidate before it
157
- * leaves here. Guarding the render path alone let the workbench emit an
158
- * undecidable decision through the export path and then present a different
164
+ * Selection is by role, so the role has to name exactly one candidate before a
165
+ * decision may trust it. Guarding the render path alone let the workbench emit
166
+ * an undecidable decision through the export path and then present a different
159
167
  * candidate's value against it; this is the shared selector all of those go
160
168
  * through, which is why the check belongs here rather than at each of them.
169
+ *
170
+ * A decision that would make one of several candidates in its role the trusted
171
+ * value is refused: picking the first would settle the conflict for the
172
+ * reviewer without showing it. A value-neutral decision (reject, could not
173
+ * confirm) on such an item selects none of them. It still returns the first
174
+ * candidate so in-process callers that need one candidate (rendering, the
175
+ * in-memory `ReviewWorkbenchResult`) have one, but that anchor is not recorded:
176
+ * {@link decisionCandidateId} is `undefined` for it, so the decision, its
177
+ * session events and the canonical projection name no candidate.
161
178
  */
162
179
  export function candidateForDecision(item, decision) {
163
180
  const definition = workbenchDecisionDefinitions[decision];
164
- const candidate = item.spec.candidates.find((entry) => entry.role === definition.candidateRole);
181
+ const matches = item.spec.candidates.filter((entry) => entry.role === definition.candidateRole);
182
+ const candidate = matches[0];
165
183
  if (!candidate) {
166
184
  throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
167
185
  }
186
+ if (matches.length > 1 && !VALUE_NEUTRAL_DECISIONS.has(decision)) {
187
+ throw new Error(`ReviewItem ${item.metadata.name} has ${matches.length} ${definition.candidateRole} candidates; the ${decision} decision cannot choose between them.`);
188
+ }
168
189
  assertSoleCandidateId(item, candidate.id);
169
190
  return candidate;
170
191
  }
192
+ /** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
193
+ export function decisionSelectsNoCandidate(item, decision) {
194
+ const role = workbenchDecisionDefinitions[decision].candidateRole;
195
+ return VALUE_NEUTRAL_DECISIONS.has(decision) && item.spec.candidates.filter((entry) => entry.role === role).length > 1;
196
+ }
197
+ /**
198
+ * The candidate id a decision records: the selected candidate's, or
199
+ * `undefined` when the decision selects no candidate.
200
+ */
201
+ export function decisionCandidateId(item, decision) {
202
+ const candidate = candidateForDecision(item, decision);
203
+ return decisionSelectsNoCandidate(item, decision) ? undefined : candidate.id;
204
+ }
171
205
  /**
172
206
  * The value that should actually be applied for a decision: the reviewer's inline
173
207
  * edit when one was made for an accept-proposed decision, otherwise the selected
@@ -253,7 +287,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
253
287
  if (decision === "could-not-confirm" && !note?.trim()) {
254
288
  throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
255
289
  }
256
- const candidate = candidateForDecision(item, decision);
290
+ const candidateId = decisionCandidateId(item, decision);
257
291
  const definition = workbenchDecisionDefinitions[decision];
258
292
  const reviewDecisionName = `${item.metadata.name}-${decision}`;
259
293
  // Carry the reviewer's inline edit in the event itself (accept-proposed
@@ -283,7 +317,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
283
317
  occurredAt: session.reviewedAt,
284
318
  reviewItemName: item.metadata.name,
285
319
  reviewDecisionName,
286
- candidateId: candidate.id,
320
+ ...(candidateId !== undefined ? { candidateId } : {}),
287
321
  status: definition.status,
288
322
  ...(decision === "could-not-confirm"
289
323
  ? {
@@ -301,7 +335,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
301
335
  occurredAt: session.reviewedAt,
302
336
  reviewItemName: item.metadata.name,
303
337
  reviewDecisionName,
304
- candidateId: candidate.id,
338
+ ...(candidateId !== undefined ? { candidateId } : {}),
305
339
  status: definition.status,
306
340
  rationale: note,
307
341
  ...(decision === "could-not-confirm"
@@ -368,7 +402,13 @@ export function replayReviewSessionEvents(startState, events) {
368
402
  const editedValuesByItemName = { ...session.editedValuesByItemName };
369
403
  const attemptEvidenceIdsByItemName = { ...session.attemptEvidenceIdsByItemName };
370
404
  if (decision === "accept-proposed" && editedValue !== undefined) {
371
- editedValuesByItemName[itemName] = editedValue;
405
+ // Legacy sessions stored typed edits as editor text ("42"); store the
406
+ // descriptor-typed value so effectiveValue is 42. An edit the item does
407
+ // not allow is left as carried: validated replay refuses it before this
408
+ // point (kontourai/survey#278).
409
+ const item = session.items.find((entry) => entry.metadata.name === itemName);
410
+ const check = item ? checkEditedValueForItem(item, editedValue) : undefined;
411
+ editedValuesByItemName[itemName] = check?.ok ? check.value : editedValue;
372
412
  }
373
413
  else {
374
414
  delete editedValuesByItemName[itemName];
@@ -1,6 +1,6 @@
1
1
  import type { ReviewSessionEvent } from "../review-resource.js";
2
2
  import { type ReviewQueueSessionState } from "./review-queue-session.js";
3
- export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch" | "decision-resolution-mismatch" | "missing-resolution-reason";
3
+ export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch" | "decision-resolution-mismatch" | "missing-resolution-reason" | "edited-value-not-editable" | "edited-value-type-mismatch";
4
4
  export interface ReviewSessionReplayIssue {
5
5
  readonly code: ReviewSessionReplayIssueCode;
6
6
  readonly eventName: string;
@@ -10,3 +10,37 @@ export interface ReviewSessionReplayIssue {
10
10
  readonly message: string;
11
11
  }
12
12
  export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
13
+ /**
14
+ * A ReviewSessionEvent whose `data.workbenchEditedValue` was rewritten from
15
+ * legacy editor text to its descriptor-typed value during replay (see
16
+ * checkEditedValueForItem). Not an error: the apply result still succeeds, and
17
+ * the warning lets a consumer see that a saved session was normalized.
18
+ */
19
+ export type ReviewSessionReplayWarning = {
20
+ readonly code: "edited-value-converted-from-text";
21
+ readonly eventName: string;
22
+ readonly sequence: number;
23
+ readonly reviewItemName: string;
24
+ readonly originalValue: string;
25
+ readonly convertedValue: unknown;
26
+ readonly message: string;
27
+ } | {
28
+ /**
29
+ * An edit that would be refused (see checkEditedValueForItem) on a
30
+ * decision event that a later decision for the same item superseded. It
31
+ * never reaches effectiveValue, so it does not fail the session.
32
+ */
33
+ readonly code: "superseded-edited-value-refused";
34
+ readonly eventName: string;
35
+ readonly sequence: number;
36
+ readonly reviewItemName: string;
37
+ readonly refusedCode: "edited-value-not-editable" | "edited-value-type-mismatch";
38
+ readonly editedValue: unknown;
39
+ readonly message: string;
40
+ };
41
+ /**
42
+ * Lists the legacy text edits replay converts to typed values, and refused
43
+ * edits that a later decision superseded. Call it only on an event stream that
44
+ * validateReviewSessionEventsForSnapshot accepted.
45
+ */
46
+ export declare function reviewSessionReplayWarningsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayWarning[];
@@ -1,7 +1,9 @@
1
- import { candidateForDecision, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
1
+ import { candidateForDecision, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
2
+ import { checkEditedValueForItem } from "./edited-value.js";
2
3
  export function validateReviewSessionEventsForSnapshot(snapshot, events) {
3
4
  const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
4
5
  const sequenceIssues = validateEventSequence(events);
6
+ const finalDecisionEvents = finalDecisionEventPerItem(events);
5
7
  const replayIssues = events.flatMap((event) => {
6
8
  const issues = [];
7
9
  const activeItemName = event.spec.activeItemName;
@@ -54,17 +56,19 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
54
56
  else if (itemName && itemsByName.has(itemName)) {
55
57
  const item = itemsByName.get(itemName);
56
58
  const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
59
+ // A decision that selects no candidate must reference none.
60
+ const expectedCandidateId = item && !decisionSelectsNoCandidate(item, decision) ? expectedCandidate?.id : undefined;
57
61
  const expectedStatus = workbenchDecisionDefinitions[decision].status;
58
62
  const referencedCandidateExists = event.spec.candidateId
59
63
  ? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
60
64
  : false;
61
- if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
65
+ if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidateId) {
62
66
  issues.push({
63
67
  ...eventRef,
64
68
  code: "decision-candidate-mismatch",
65
69
  reviewItemName: itemName,
66
70
  candidateId: event.spec.candidateId,
67
- message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${expectedCandidate.id}, but references ${event.spec.candidateId ?? "no candidate"}.`,
71
+ message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects ${expectedCandidateId ? `candidate ${expectedCandidateId}` : "no candidate"}, but references ${event.spec.candidateId ?? "no candidate"}.`,
68
72
  });
69
73
  }
70
74
  if (event.spec.status !== expectedStatus) {
@@ -86,6 +90,22 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
86
90
  message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects resolution ${expectedResolution ?? "none"}, but references ${event.spec.resolution ?? "no resolution"}.`,
87
91
  });
88
92
  }
93
+ const editedValue = editedValueFromDecisionEvent(event);
94
+ // Only the item's final decision decides its effectiveValue, so only an
95
+ // edit on that event is refused. An edit a later decision superseded is
96
+ // reported as a warning instead of failing the whole session.
97
+ if (item && decision === "accept-proposed" && editedValue !== undefined && finalDecisionEvents.has(event)) {
98
+ const check = checkEditedValueForItem(item, editedValue);
99
+ if (!check.ok) {
100
+ issues.push({
101
+ ...eventRef,
102
+ code: check.code,
103
+ reviewItemName: itemName,
104
+ candidateId: event.spec.candidateId,
105
+ message: `ReviewSessionEvent ${event.metadata.name}: ${check.message}`,
106
+ });
107
+ }
108
+ }
89
109
  if (decision === "could-not-confirm" && !event.spec.resolutionReason?.trim()) {
90
110
  issues.push({
91
111
  ...eventRef,
@@ -114,6 +134,65 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
114
134
  });
115
135
  return [...sequenceIssues, ...replayIssues];
116
136
  }
137
+ /**
138
+ * Lists the legacy text edits replay converts to typed values, and refused
139
+ * edits that a later decision superseded. Call it only on an event stream that
140
+ * validateReviewSessionEventsForSnapshot accepted.
141
+ */
142
+ export function reviewSessionReplayWarningsForSnapshot(snapshot, events) {
143
+ const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
144
+ const finalDecisionEvents = finalDecisionEventPerItem(events);
145
+ return events.flatMap((event) => {
146
+ const itemName = event.spec.reviewItemName;
147
+ const item = itemName ? itemsByName.get(itemName) : undefined;
148
+ const editedValue = editedValueFromDecisionEvent(event);
149
+ if (!item || !itemName || editedValue === undefined
150
+ || replayableWorkbenchDecision(event.spec.data?.workbenchDecision) !== "accept-proposed") {
151
+ return [];
152
+ }
153
+ const check = checkEditedValueForItem(item, editedValue);
154
+ if (!check.ok) {
155
+ return finalDecisionEvents.has(event) ? [] : [{
156
+ code: "superseded-edited-value-refused",
157
+ eventName: event.metadata.name,
158
+ sequence: event.spec.sequence,
159
+ reviewItemName: itemName,
160
+ refusedCode: check.code,
161
+ editedValue,
162
+ message: `ReviewSessionEvent ${event.metadata.name} carries an edit a later decision superseded, which would be refused: ${check.message}`,
163
+ }];
164
+ }
165
+ if (!check.convertedFromText || typeof editedValue !== "string") {
166
+ return [];
167
+ }
168
+ return [{
169
+ code: "edited-value-converted-from-text",
170
+ eventName: event.metadata.name,
171
+ sequence: event.spec.sequence,
172
+ reviewItemName: itemName,
173
+ originalValue: editedValue,
174
+ convertedValue: check.value,
175
+ message: `ReviewSessionEvent ${event.metadata.name} stores edited value ${JSON.stringify(editedValue)} as text; replay converted it to ${JSON.stringify(check.value)} per ReviewItem ${itemName}'s value descriptor.`,
176
+ }];
177
+ });
178
+ }
179
+ /** The last decision event (by sequence, as replay applies them) for each ReviewItem. */
180
+ function finalDecisionEventPerItem(events) {
181
+ const lastByItem = new Map();
182
+ for (const event of [...events].sort((left, right) => left.spec.sequence - right.spec.sequence)) {
183
+ if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
184
+ && event.spec.reviewItemName) {
185
+ lastByItem.set(event.spec.reviewItemName, event);
186
+ }
187
+ }
188
+ return new Set(lastByItem.values());
189
+ }
190
+ function editedValueFromDecisionEvent(event) {
191
+ return (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
192
+ && event.spec.data && "workbenchEditedValue" in event.spec.data
193
+ ? event.spec.data.workbenchEditedValue
194
+ : undefined;
195
+ }
117
196
  function replayableWorkbenchDecision(value) {
118
197
  return typeof value === "string" && value in workbenchDecisionDefinitions
119
198
  ? value
@@ -247,6 +247,8 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
247
247
  .survey-workbench-embed .inspector-heading h2{ margin: 0; }
248
248
  .survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
249
249
  .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
250
+ .survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
251
+ .survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
250
252
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
251
253
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
252
254
  .survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }