@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
@@ -57,6 +57,14 @@ export interface ReviewItemPresentation {
57
57
  * disproven: every decision surface shows them. Empty for other items.
58
58
  */
59
59
  readonly excludedProposals: readonly ExcludedProposalPresentation[];
60
+ /**
61
+ * Stored `excludedProposals` entries that {@link excludedProposals} cannot
62
+ * show: malformed entries, or every entry of an item whose envelope binding
63
+ * is not intact. Hiding a rival is the unsafe direction, so every surface
64
+ * that shows excluded proposals shows this notice too. Absent when every
65
+ * stored entry is shown.
66
+ */
67
+ readonly excludedProposalsUnreadable?: ExcludedProposalsUnreadable;
60
68
  /**
61
69
  * For an envelope-imported item, whether its import checked excerpts
62
70
  * against the prepared artifact. Absent for other items, and for an item
@@ -71,6 +79,16 @@ export interface ExcludedProposalPresentation {
71
79
  readonly locator: string;
72
80
  readonly excerpt: string;
73
81
  }
82
+ export interface ExcludedProposalsUnreadable {
83
+ /**
84
+ * `malformed-entries`: some stored entries are not well-formed.
85
+ * `binding-broken`: the item's extraction binding is not intact, so none of
86
+ * its stored entries can be vouched for.
87
+ */
88
+ readonly reason: "malformed-entries" | "binding-broken";
89
+ /** How many stored entries are not shown; absent when the stored field is not a list. */
90
+ readonly count?: number;
91
+ }
74
92
  export interface ReviewResultPresentation {
75
93
  readonly result: ReviewWorkbenchResult;
76
94
  readonly item?: ReviewItem;
@@ -131,6 +149,11 @@ export declare function buildInterpretationReadingPresentation(source: Interpret
131
149
  * text (the MCP item, the recorded decision prompt). Undefined when none.
132
150
  */
133
151
  export declare function excludedProposalsSentence(excluded: readonly ExcludedProposalPresentation[]): string | undefined;
152
+ /**
153
+ * One sentence saying that stored excluded proposals cannot be shown, for
154
+ * every surface that shows excluded proposals. Undefined when none are hidden.
155
+ */
156
+ export declare function excludedProposalsUnreadableSentence(unreadable: ExcludedProposalsUnreadable | undefined): string | undefined;
134
157
  export interface CandidateVerificationRecordPresentation {
135
158
  readonly recordId: string;
136
159
  readonly verifierId: string;
@@ -69,20 +69,39 @@ function extractionEnvelopeBinding(item) {
69
69
  });
70
70
  return bound ? binding : undefined;
71
71
  }
72
- /** The well-formed `excludedProposals` entries of a bound envelope item; malformed entries are ignored, never rendered. */
72
+ /**
73
+ * The well-formed `excludedProposals` entries of a bound envelope item, and
74
+ * what the item stores but cannot show: malformed entries, or every entry
75
+ * when the binding is not intact. Nothing stored is dropped silently.
76
+ */
73
77
  function excludedProposalsOf(item, binding, adapter) {
74
- if (!binding || !Array.isArray(binding.excludedProposals))
75
- return [];
76
- return binding.excludedProposals.flatMap((entry) => {
78
+ const metadata = item.metadata?.producer?.[EXTRACTION_ENVELOPE_PRODUCER];
79
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
80
+ // Candidates that still carry the envelope binding came from an import,
81
+ // whose item metadata is gone or replaced: what it stored is unknown.
82
+ const candidatesBound = item.spec.candidates.some((candidate) => candidate.producer?.[EXTRACTION_ENVELOPE_PRODUCER] !== undefined);
83
+ return candidatesBound ? { excludedProposals: [], excludedProposalsUnreadable: { reason: "binding-broken" } } : { excludedProposals: [] };
84
+ }
85
+ const stored = metadata.excludedProposals;
86
+ if (stored === undefined)
87
+ return { excludedProposals: [] };
88
+ const count = Array.isArray(stored) ? stored.length : undefined;
89
+ if (!binding)
90
+ return count === 0 ? { excludedProposals: [] } : { excludedProposals: [], excludedProposalsUnreadable: { reason: "binding-broken", ...(count !== undefined ? { count } : {}) } };
91
+ if (!Array.isArray(stored))
92
+ return { excludedProposals: [], excludedProposalsUnreadable: { reason: "malformed-entries" } };
93
+ const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
94
+ const excludedProposals = stored.flatMap((entry) => {
77
95
  if (!entry || typeof entry !== "object" || Array.isArray(entry))
78
96
  return [];
79
97
  const e = entry;
80
98
  if (!Number.isSafeInteger(e.proposalIndex) || typeof e.locator !== "string" || typeof e.excerpt !== "string" || !("value" in e))
81
99
  return [];
82
- const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
83
100
  const valueText = (proposed ? adapter.summarizeValue?.(e.value, { item, candidate: proposed, value: e.value }) : undefined) ?? formatValue(e.value);
84
101
  return [{ proposalIndex: e.proposalIndex, value: e.value, valueText, locator: e.locator, excerpt: e.excerpt }];
85
102
  });
103
+ const unreadable = stored.length - excludedProposals.length;
104
+ return { excludedProposals, ...(unreadable > 0 ? { excludedProposalsUnreadable: { reason: "malformed-entries", count: unreadable } } : {}) };
86
105
  }
87
106
  /**
88
107
  * One sentence naming every excluded proposal, for surfaces that speak in
@@ -94,6 +113,20 @@ export function excludedProposalsSentence(excluded) {
94
113
  const named = excluded.map((entry) => `${entry.valueText} (proposal ${entry.proposalIndex}, ${entry.locator})`).join(", ");
95
114
  return `${excluded.length === 1 ? "Another proposed value was" : `${excluded.length} other proposed values were`} excluded at import because the source text at the cited span is not the excerpt: ${named}. Unverifiable is not disproven.`;
96
115
  }
116
+ /**
117
+ * One sentence saying that stored excluded proposals cannot be shown, for
118
+ * every surface that shows excluded proposals. Undefined when none are hidden.
119
+ */
120
+ export function excludedProposalsUnreadableSentence(unreadable) {
121
+ if (!unreadable)
122
+ return undefined;
123
+ const { count } = unreadable;
124
+ const entries = count === undefined ? "Stored excluded proposals are" : `${count} stored excluded ${count === 1 ? "entry is" : "entries are"}`;
125
+ const why = unreadable.reason === "binding-broken"
126
+ ? "not shown because this item's extraction binding is broken"
127
+ : "unreadable and not shown";
128
+ return `${entries} ${why}. Another value may have been proposed for this field.`;
129
+ }
97
130
  const VERIFICATION_RESULT_TEXT = {
98
131
  supported: "supported",
99
132
  contradicted: "contradicted",
@@ -167,7 +200,7 @@ export function buildReviewItemPresentation(item, adapter = {}) {
167
200
  function extractionPresentation(item, adapter) {
168
201
  const binding = extractionEnvelopeBinding(item);
169
202
  return {
170
- excludedProposals: excludedProposalsOf(item, binding, adapter),
203
+ ...excludedProposalsOf(item, binding, adapter),
171
204
  ...(binding ? { excerptVerification: binding.excerptVerification === "verified" ? "verified" : "unverified" } : {}),
172
205
  };
173
206
  }
@@ -224,12 +257,20 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
224
257
  selectedValueText: selectedCandidate && item
225
258
  ? buildReviewCandidatePresentation(item, selectedCandidate, adapter, targetLabel).valueText
226
259
  : result.selectedDisplayValue,
227
- applyMeaning: result.selectedCandidateRole === "proposed"
228
- ? "Saved decision applies proposed value"
229
- : "Saved decision keeps current value",
260
+ applyMeaning: result.decision === "select-proposed"
261
+ ? "Saved decision applies the chosen value; the other proposed values were seen and not chosen"
262
+ : result.selectedCandidateRole === "proposed"
263
+ ? "Saved decision applies proposed value"
264
+ : "Saved decision keeps current value",
230
265
  reviewItemLink: item && itemContext ? adapter.linkForReviewItem?.(item, itemContext) : undefined,
231
266
  traceRefs: item
232
- ? traceRefsForResult(item, result, selectedCandidate, adapter)
267
+ ? [
268
+ ...traceRefsForResult(item, result, selectedCandidate, adapter),
269
+ // A choice between conflicting values names the values it passed over.
270
+ ...(result.decision === "select-proposed"
271
+ ? result.unselectedCandidates.filter((candidate) => candidate.role === "proposed").flatMap((candidate) => withTraceLinks([{ label: "Not chosen candidate", value: candidate.id, kind: "candidate" }], { item, candidate }, adapter))
272
+ : []),
273
+ ]
233
274
  : [{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" }],
234
275
  };
235
276
  }
@@ -1,5 +1,15 @@
1
1
  import { type ReviewCandidate, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewSessionEventSpec } from "../../src/review-resource.js";
2
- export type ReviewWorkbenchDecision = "accept-proposed" | "keep-current" | "reject-proposed" | "could-not-confirm";
2
+ /**
3
+ * The decisions a reviewer can record on a ReviewItem.
4
+ *
5
+ * `select-proposed` chooses one value of a conflict (two or more `proposed`
6
+ * candidates) by candidate id, which the state carries beside the decision
7
+ * (`selectedCandidateId` / `selectedCandidateIdsByItemName`). It is its own
8
+ * kind rather than `accept-proposed` with an id so that the record always says
9
+ * a rival was seen and not chosen, and `accept-proposed` keeps its meaning:
10
+ * accept the one proposed value, refused on a conflict.
11
+ */
12
+ export type ReviewWorkbenchDecision = "accept-proposed" | "select-proposed" | "keep-current" | "reject-proposed" | "could-not-confirm";
3
13
  export type ReviewQueueRowStatus = "pending" | "in-review" | "resolved" | "rejected" | "could-not-confirm" | "escalated";
4
14
  export declare const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
5
15
  export declare const defaultReviewSessionName = "review-workbench-session";
@@ -16,6 +26,11 @@ export interface ReviewWorkbenchState {
16
26
  */
17
27
  readonly editedValue?: unknown;
18
28
  readonly attemptEvidenceIds?: readonly string[];
29
+ /**
30
+ * The candidate a `select-proposed` decision chose. Required for that
31
+ * decision and ignored for every other one.
32
+ */
33
+ readonly selectedCandidateId?: string;
19
34
  }
20
35
  export interface ReviewQueueSessionState {
21
36
  readonly items: readonly ReviewItem[];
@@ -32,6 +47,12 @@ export interface ReviewQueueSessionState {
32
47
  */
33
48
  readonly editedValuesByItemName?: Readonly<Record<string, unknown>>;
34
49
  readonly attemptEvidenceIdsByItemName?: Readonly<Record<string, readonly string[]>>;
50
+ /**
51
+ * The candidate each `select-proposed` decision chose, keyed by ReviewItem
52
+ * name. Additive/optional: sessions stored before this field existed have no
53
+ * `select-proposed` decision and never read it.
54
+ */
55
+ readonly selectedCandidateIdsByItemName?: Readonly<Record<string, string>>;
35
56
  }
36
57
  export interface ReviewSessionSummary {
37
58
  readonly accepted: number;
@@ -48,6 +69,12 @@ export declare const workbenchDecisionDefinitions: {
48
69
  candidateRole: "proposed";
49
70
  status: "verified";
50
71
  };
72
+ "select-proposed": {
73
+ label: string;
74
+ effect: string;
75
+ candidateRole: "proposed";
76
+ status: "verified";
77
+ };
51
78
  "keep-current": {
52
79
  label: string;
53
80
  effect: string;
@@ -109,14 +136,30 @@ export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boole
109
136
  * {@link decisionCandidateId} is `undefined` for it, so the decision, its
110
137
  * session events and the canonical projection name no candidate.
111
138
  */
112
- export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
139
+ export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision, selectedCandidateId?: string): ReviewCandidate;
140
+ /**
141
+ * Why a `select-proposed` decision naming `candidateId` cannot be recorded on
142
+ * this item, or `undefined` when it can.
143
+ *
144
+ * The choice must name, by a unique id, one of the item's `proposed`
145
+ * candidates, and the item must hold at least two of them: choosing "over a
146
+ * rival" on an item with no rival would record a conflict that never existed
147
+ * (use `accept-proposed` there). Proposals excluded at import are not
148
+ * candidates, so they can never be chosen.
149
+ */
150
+ export declare function conflictSelectionIssue(item: ReviewItem, candidateId: string | undefined): string | undefined;
151
+ /**
152
+ * The proposed candidates a `select-proposed` decision saw and did not choose,
153
+ * in item order. Empty for every other decision.
154
+ */
155
+ export declare function unchosenProposedCandidates(item: ReviewItem, decision: ReviewWorkbenchDecision, selectedCandidateId: string | undefined): ReviewCandidate[];
113
156
  /** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
114
157
  export declare function decisionSelectsNoCandidate(item: ReviewItem, decision: ReviewWorkbenchDecision): boolean;
115
158
  /**
116
159
  * The candidate id a decision records: the selected candidate's, or
117
160
  * `undefined` when the decision selects no candidate.
118
161
  */
119
- export declare function decisionCandidateId(item: ReviewItem, decision: ReviewWorkbenchDecision): string | undefined;
162
+ export declare function decisionCandidateId(item: ReviewItem, decision: ReviewWorkbenchDecision, selectedCandidateId?: string): string | undefined;
120
163
  /**
121
164
  * The value that should actually be applied for a decision: the reviewer's inline
122
165
  * edit when one was made for an accept-proposed decision, otherwise the selected
@@ -125,7 +168,7 @@ export declare function decisionCandidateId(item: ReviewItem, decision: ReviewWo
125
168
  * this rule; this helper exists for callers deriving the value from raw session
126
169
  * state directly.
127
170
  */
128
- export declare function effectiveValueForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision, editedValue?: unknown): unknown;
171
+ export declare function effectiveValueForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision, editedValue?: unknown, selectedCandidateId?: string): unknown;
129
172
  export declare function selectedCandidateRole(state: ReviewWorkbenchState): ReviewCandidate["role"] | undefined;
130
173
  export declare function buildReviewSessionResource(session: ReviewQueueSessionState, events?: readonly ReviewSessionEvent[], sessionName?: string): ReviewSession;
131
174
  export declare function buildReviewSessionEvents(session: ReviewQueueSessionState, sessionName?: string): ReviewSessionEvent[];
@@ -11,6 +11,12 @@ export const workbenchDecisionDefinitions = {
11
11
  candidateRole: "proposed",
12
12
  status: "verified",
13
13
  },
14
+ "select-proposed": {
15
+ label: "Use this value",
16
+ effect: "The chosen value becomes the verified review outcome; the other proposed values were seen and not chosen.",
17
+ candidateRole: "proposed",
18
+ status: "verified",
19
+ },
14
20
  "keep-current": {
15
21
  label: "Keep current",
16
22
  effect: "Current value remains the verified review outcome.",
@@ -58,6 +64,7 @@ export function currentReviewWorkbenchState(session) {
58
64
  decision: session.decisionsByItemName[item.metadata.name],
59
65
  editedValue: session.editedValuesByItemName?.[item.metadata.name],
60
66
  attemptEvidenceIds: session.attemptEvidenceIdsByItemName?.[item.metadata.name],
67
+ selectedCandidateId: session.selectedCandidateIdsByItemName?.[item.metadata.name],
61
68
  reviewedAt: session.reviewedAt,
62
69
  actorId: session.actorId,
63
70
  };
@@ -77,7 +84,7 @@ export function deriveQueueRowStatus(item, session) {
77
84
  if (decision === "could-not-confirm") {
78
85
  return "could-not-confirm";
79
86
  }
80
- if (decision === "accept-proposed" || decision === "keep-current" || item.spec.candidateSetStatus === "resolved") {
87
+ if (decision === "accept-proposed" || decision === "select-proposed" || decision === "keep-current" || item.spec.candidateSetStatus === "resolved") {
81
88
  return "resolved";
82
89
  }
83
90
  if (item.spec.candidateSetStatus === "escalated") {
@@ -99,7 +106,7 @@ export function nextUnresolvedItemName(session) {
99
106
  export function reviewSessionSummary(session) {
100
107
  return session.items.reduce((summary, item) => {
101
108
  const decision = session.decisionsByItemName[item.metadata.name];
102
- if (decision === "accept-proposed") {
109
+ if (decision === "accept-proposed" || decision === "select-proposed") {
103
110
  return { ...summary, accepted: summary.accepted + 1 };
104
111
  }
105
112
  if (decision === "keep-current") {
@@ -176,7 +183,14 @@ const VALUE_NEUTRAL_DECISIONS = new Set(["reject-proposed", "could-not-confirm"]
176
183
  * {@link decisionCandidateId} is `undefined` for it, so the decision, its
177
184
  * session events and the canonical projection name no candidate.
178
185
  */
179
- export function candidateForDecision(item, decision) {
186
+ export function candidateForDecision(item, decision, selectedCandidateId) {
187
+ if (decision === "select-proposed") {
188
+ const issue = conflictSelectionIssue(item, selectedCandidateId);
189
+ if (issue) {
190
+ throw new Error(issue);
191
+ }
192
+ return item.spec.candidates.find((entry) => entry.id === selectedCandidateId);
193
+ }
180
194
  const definition = workbenchDecisionDefinitions[decision];
181
195
  const matches = item.spec.candidates.filter((entry) => entry.role === definition.candidateRole);
182
196
  const candidate = matches[0];
@@ -189,6 +203,45 @@ export function candidateForDecision(item, decision) {
189
203
  assertSoleCandidateId(item, candidate.id);
190
204
  return candidate;
191
205
  }
206
+ /**
207
+ * Why a `select-proposed` decision naming `candidateId` cannot be recorded on
208
+ * this item, or `undefined` when it can.
209
+ *
210
+ * The choice must name, by a unique id, one of the item's `proposed`
211
+ * candidates, and the item must hold at least two of them: choosing "over a
212
+ * rival" on an item with no rival would record a conflict that never existed
213
+ * (use `accept-proposed` there). Proposals excluded at import are not
214
+ * candidates, so they can never be chosen.
215
+ */
216
+ export function conflictSelectionIssue(item, candidateId) {
217
+ const proposed = item.spec.candidates.filter((entry) => entry.role === "proposed");
218
+ if (proposed.length < 2) {
219
+ return `ReviewItem ${item.metadata.name} has ${proposed.length} proposed candidate${proposed.length === 1 ? "" : "s"}; select-proposed chooses between conflicting proposed values, so it needs at least two.`;
220
+ }
221
+ if (!candidateId) {
222
+ return `ReviewItem ${item.metadata.name}: select-proposed must name the chosen candidate id.`;
223
+ }
224
+ const matches = item.spec.candidates.filter((entry) => entry.id === candidateId);
225
+ if (matches.length === 0) {
226
+ return `ReviewItem ${item.metadata.name} has no candidate ${candidateId}; select-proposed can only choose one of its proposed candidates.`;
227
+ }
228
+ if (matches.length > 1) {
229
+ return `ReviewItem ${item.metadata.name} has ${matches.length} candidates with id ${candidateId}; candidate ids must be unique.`;
230
+ }
231
+ if (matches[0].role !== "proposed") {
232
+ return `ReviewItem ${item.metadata.name} candidate ${candidateId} has role ${matches[0].role ?? "none"}; select-proposed can only choose a proposed candidate.`;
233
+ }
234
+ return undefined;
235
+ }
236
+ /**
237
+ * The proposed candidates a `select-proposed` decision saw and did not choose,
238
+ * in item order. Empty for every other decision.
239
+ */
240
+ export function unchosenProposedCandidates(item, decision, selectedCandidateId) {
241
+ return decision === "select-proposed"
242
+ ? item.spec.candidates.filter((entry) => entry.role === "proposed" && entry.id !== selectedCandidateId)
243
+ : [];
244
+ }
192
245
  /** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
193
246
  export function decisionSelectsNoCandidate(item, decision) {
194
247
  const role = workbenchDecisionDefinitions[decision].candidateRole;
@@ -198,8 +251,8 @@ export function decisionSelectsNoCandidate(item, decision) {
198
251
  * The candidate id a decision records: the selected candidate's, or
199
252
  * `undefined` when the decision selects no candidate.
200
253
  */
201
- export function decisionCandidateId(item, decision) {
202
- const candidate = candidateForDecision(item, decision);
254
+ export function decisionCandidateId(item, decision, selectedCandidateId) {
255
+ const candidate = candidateForDecision(item, decision, selectedCandidateId);
203
256
  return decisionSelectsNoCandidate(item, decision) ? undefined : candidate.id;
204
257
  }
205
258
  /**
@@ -210,8 +263,8 @@ export function decisionCandidateId(item, decision) {
210
263
  * this rule; this helper exists for callers deriving the value from raw session
211
264
  * state directly.
212
265
  */
213
- export function effectiveValueForDecision(item, decision, editedValue) {
214
- const candidate = candidateForDecision(item, decision);
266
+ export function effectiveValueForDecision(item, decision, editedValue, selectedCandidateId) {
267
+ const candidate = candidateForDecision(item, decision, selectedCandidateId);
215
268
  return decision === "accept-proposed" && editedValue !== undefined ? editedValue : candidate.value;
216
269
  }
217
270
  export function selectedCandidateRole(state) {
@@ -287,7 +340,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
287
340
  if (decision === "could-not-confirm" && !note?.trim()) {
288
341
  throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
289
342
  }
290
- const candidateId = decisionCandidateId(item, decision);
343
+ const candidateId = decisionCandidateId(item, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name]);
291
344
  const definition = workbenchDecisionDefinitions[decision];
292
345
  const reviewDecisionName = `${item.metadata.name}-${decision}`;
293
346
  // Carry the reviewer's inline edit in the event itself (accept-proposed
@@ -383,11 +436,13 @@ export function replayReviewSessionEvents(startState, events) {
383
436
  const { [itemName]: _removedDecision, ...remainingDecisions } = session.decisionsByItemName;
384
437
  const { [itemName]: _removedEdit, ...remainingEdits } = session.editedValuesByItemName ?? {};
385
438
  const { [itemName]: _removedAttempts, ...remainingAttempts } = session.attemptEvidenceIdsByItemName ?? {};
439
+ const { [itemName]: _removedSelection, ...remainingSelections } = session.selectedCandidateIdsByItemName ?? {};
386
440
  return {
387
441
  ...session,
388
442
  decisionsByItemName: remainingDecisions,
389
443
  editedValuesByItemName: remainingEdits,
390
444
  attemptEvidenceIdsByItemName: remainingAttempts,
445
+ ...(session.selectedCandidateIdsByItemName ? { selectedCandidateIdsByItemName: remainingSelections } : {}),
391
446
  };
392
447
  }
393
448
  const decision = workbenchDecisionFromEvent(event);
@@ -419,6 +474,15 @@ export function replayReviewSessionEvents(startState, events) {
419
474
  else {
420
475
  delete attemptEvidenceIdsByItemName[itemName];
421
476
  }
477
+ // A choice between conflicting values is the event's candidateId: that
478
+ // id is the authority for select-proposed (validated replay checks it).
479
+ const selectedCandidateIdsByItemName = { ...session.selectedCandidateIdsByItemName };
480
+ if (decision === "select-proposed" && event.spec.candidateId) {
481
+ selectedCandidateIdsByItemName[itemName] = event.spec.candidateId;
482
+ }
483
+ else {
484
+ delete selectedCandidateIdsByItemName[itemName];
485
+ }
422
486
  return {
423
487
  ...session,
424
488
  decisionsByItemName: {
@@ -427,6 +491,7 @@ export function replayReviewSessionEvents(startState, events) {
427
491
  },
428
492
  editedValuesByItemName,
429
493
  attemptEvidenceIdsByItemName,
494
+ ...(session.selectedCandidateIdsByItemName || decision === "select-proposed" ? { selectedCandidateIdsByItemName } : {}),
430
495
  };
431
496
  }
432
497
  return session;
@@ -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" | "edited-value-not-editable" | "edited-value-type-mismatch";
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" | "invalid-conflict-selection" | "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;
@@ -37,6 +37,16 @@ export type ReviewSessionReplayWarning = {
37
37
  readonly refusedCode: "edited-value-not-editable" | "edited-value-type-mismatch";
38
38
  readonly editedValue: unknown;
39
39
  readonly message: string;
40
+ } | {
41
+ /**
42
+ * The session's items came from an extraction import, but the apply
43
+ * boundary was not given the import record to check them against (see
44
+ * `DeriveServerReviewSessionApplyResultOptions.extractionImport`). Emitted
45
+ * by the server apply boundary, never by replay itself.
46
+ */
47
+ readonly code: "unverified-extraction-queue";
48
+ readonly itemNames: readonly string[];
49
+ readonly message: string;
40
50
  };
41
51
  /**
42
52
  * Lists the legacy text edits replay converts to typed values, and refused
@@ -1,4 +1,4 @@
1
- import { candidateForDecision, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
1
+ import { candidateForDecision, conflictSelectionIssue, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
2
2
  import { checkEditedValueForItem } from "./edited-value.js";
3
3
  export function validateReviewSessionEventsForSnapshot(snapshot, events) {
4
4
  const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
@@ -53,6 +53,40 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
53
53
  message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not include a replayable workbench decision.`,
54
54
  });
55
55
  }
56
+ else if (itemName && itemsByName.has(itemName) && decision === "select-proposed") {
57
+ // The event's candidateId is the choice; it must name one proposed
58
+ // candidate of an item that holds a conflict.
59
+ const item = itemsByName.get(itemName);
60
+ const selectionIssue = conflictSelectionIssue(item, event.spec.candidateId);
61
+ if (selectionIssue) {
62
+ issues.push({
63
+ ...eventRef,
64
+ code: "invalid-conflict-selection",
65
+ reviewItemName: itemName,
66
+ candidateId: event.spec.candidateId,
67
+ message: `ReviewSessionEvent ${event.metadata.name}: ${selectionIssue}`,
68
+ });
69
+ }
70
+ const expectedStatus = workbenchDecisionDefinitions[decision].status;
71
+ if (event.spec.status !== expectedStatus) {
72
+ issues.push({
73
+ ...eventRef,
74
+ code: "decision-status-mismatch",
75
+ reviewItemName: itemName,
76
+ candidateId: event.spec.candidateId,
77
+ message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
78
+ });
79
+ }
80
+ if (event.spec.resolution !== undefined) {
81
+ issues.push({
82
+ ...eventRef,
83
+ code: "decision-resolution-mismatch",
84
+ reviewItemName: itemName,
85
+ candidateId: event.spec.candidateId,
86
+ message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects resolution none, but references ${event.spec.resolution}.`,
87
+ });
88
+ }
89
+ }
56
90
  else if (itemName && itemsByName.has(itemName)) {
57
91
  const item = itemsByName.get(itemName);
58
92
  const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
@@ -835,6 +835,100 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
835
835
  font-size: 12px;
836
836
  }
837
837
 
838
+ /* A queue that came from an extraction import but was not checked against
839
+ it (unverified), or that does not match it (diverges, shown instead of the
840
+ queue). */
841
+ .survey-workbench-embed .queue-attestation{
842
+ display: flex;
843
+ align-items: flex-start;
844
+ gap: var(--k-space-2);
845
+ margin: 0 0 var(--k-space-3);
846
+ padding: var(--k-space-2) var(--k-space-3);
847
+ border-radius: 9px;
848
+ font-size: 13px;
849
+ line-height: 1.45;
850
+ color: var(--k-caution);
851
+ background: var(--k-caution-wash);
852
+ }
853
+
854
+ .survey-workbench-embed .queue-attestation svg{
855
+ flex: none;
856
+ margin-top: 2px;
857
+ }
858
+
859
+ .survey-workbench-embed .queue-attestation.diverges{
860
+ color: var(--k-negative);
861
+ background: var(--k-negative-wash);
862
+ }
863
+
864
+ /* Conflicting proposed values: each value is listed with its own choice
865
+ control until the field is decided, then marked chosen or not chosen. */
866
+ .survey-workbench-embed .conflict-values ul{
867
+ list-style: none;
868
+ margin: var(--k-space-2) 0 0;
869
+ padding: 0;
870
+ display: grid;
871
+ gap: var(--k-space-2);
872
+ }
873
+
874
+ .survey-workbench-embed .conflict-values li{
875
+ display: flex;
876
+ align-items: center;
877
+ justify-content: space-between;
878
+ gap: var(--k-space-3);
879
+ flex-wrap: wrap;
880
+ padding: var(--k-space-2) var(--k-space-3);
881
+ border: 1px solid var(--k-line);
882
+ border-radius: 9px;
883
+ }
884
+
885
+ .survey-workbench-embed .conflict-values li[data-chosen="true"]{
886
+ border-color: var(--k-positive);
887
+ background: var(--k-positive-wash);
888
+ }
889
+
890
+ .survey-workbench-embed .conflict-values .conflict-text{
891
+ display: inline-flex;
892
+ align-items: baseline;
893
+ gap: var(--k-space-2);
894
+ flex-wrap: wrap;
895
+ min-width: 0;
896
+ }
897
+
898
+ .survey-workbench-embed .conflict-values .vtext{
899
+ font-weight: 600;
900
+ overflow-wrap: anywhere;
901
+ }
902
+
903
+ .survey-workbench-embed .conflict-values q{
904
+ color: var(--k-muted);
905
+ font-size: 12.5px;
906
+ overflow-wrap: anywhere;
907
+ }
908
+
909
+ .survey-workbench-embed .conflict-values .btn.choose{
910
+ min-height: 36px;
911
+ padding: 0 14px;
912
+ font-size: 13px;
913
+ }
914
+
915
+ .survey-workbench-embed .choice-tag{
916
+ font-size: 12px;
917
+ font-weight: 600;
918
+ padding: 2px 8px;
919
+ border-radius: 999px;
920
+ }
921
+
922
+ .survey-workbench-embed .choice-tag.chosen{
923
+ color: var(--k-positive);
924
+ background: var(--k-positive-wash);
925
+ }
926
+
927
+ .survey-workbench-embed .choice-tag.not-chosen{
928
+ color: var(--k-muted);
929
+ background: var(--k-sunken);
930
+ }
931
+
838
932
  .survey-workbench-embed .excerpt-verification,
839
933
  .survey-workbench-embed .support-verification{
840
934
  margin: 0;
@@ -34,6 +34,7 @@
34
34
  */
35
35
  import { type ReviewQueueSessionState, type ReviewWorkbenchState } from "./review-workbench.js";
36
36
  import type { ReviewPresentationAdapter } from "./review-presentation.js";
37
+ import type { ExtractionEnvelopeImport, ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
37
38
  import { type ExtractionInspectorInput } from "./extraction-inspector.js";
38
39
  export declare class SurveyReviewWorkbenchElement extends HTMLElement {
39
40
  #private;
@@ -45,6 +46,15 @@ export declare class SurveyReviewWorkbenchElement extends HTMLElement {
45
46
  /** Optional presentation adapter for custom labels, value summaries, and links. */
46
47
  get presentationAdapter(): ReviewPresentationAdapter | undefined;
47
48
  set presentationAdapter(value: ReviewPresentationAdapter | undefined);
49
+ /**
50
+ * The extraction import record the queue was built from, as stored beside
51
+ * it. The workbench checks the queue against it (see
52
+ * `MountReviewWorkbenchOptions.extractionImport`). When unset, the import of
53
+ * a single-import `extractionInspector` is used; with neither, a queue whose
54
+ * items came from an extraction import shows an "Unverified queue" notice.
55
+ */
56
+ get extractionImport(): ExtractionEnvelopeImport | ExtractionEnvelopeImportResult | null;
57
+ set extractionImport(value: ExtractionEnvelopeImport | ExtractionEnvelopeImportResult | null | undefined);
48
58
  /** Optional read-only source pane attached to this workbench's existing review lifecycle. */
49
59
  get extractionInspector(): ExtractionInspectorInput | null;
50
60
  set extractionInspector(value: ExtractionInspectorInput | null | undefined);
@@ -169,6 +169,7 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
169
169
  #session = null;
170
170
  #presentationAdapter = undefined;
171
171
  #extractionInspector = null;
172
+ #extractionImport = null;
172
173
  #unmountInspector;
173
174
  #root;
174
175
  #mountRoot;
@@ -340,6 +341,18 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
340
341
  this.#presentationAdapter = value;
341
342
  this.#remount();
342
343
  }
344
+ /**
345
+ * The extraction import record the queue was built from, as stored beside
346
+ * it. The workbench checks the queue against it (see
347
+ * `MountReviewWorkbenchOptions.extractionImport`). When unset, the import of
348
+ * a single-import `extractionInspector` is used; with neither, a queue whose
349
+ * items came from an extraction import shows an "Unverified queue" notice.
350
+ */
351
+ get extractionImport() { return this.#extractionImport; }
352
+ set extractionImport(value) {
353
+ this.#extractionImport = value ?? null;
354
+ this.#remount();
355
+ }
343
356
  /** Optional read-only source pane attached to this workbench's existing review lifecycle. */
344
357
  get extractionInspector() { return this.#extractionInspector; }
345
358
  set extractionInspector(value) {
@@ -423,9 +436,14 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
423
436
  this.#renderEmpty();
424
437
  return;
425
438
  }
426
- const options = this.#presentationAdapter
427
- ? { presentationAdapter: this.#presentationAdapter }
428
- : {};
439
+ const inspectorImport = this.#extractionInspector && !("imports" in this.#extractionInspector)
440
+ ? this.#extractionInspector.importResult
441
+ : undefined;
442
+ const extractionImport = this.#extractionImport ?? inspectorImport;
443
+ const options = {
444
+ ...(this.#presentationAdapter ? { presentationAdapter: this.#presentationAdapter } : {}),
445
+ ...(extractionImport ? { extractionImport } : {}),
446
+ };
429
447
  this.#applyThemeClasses();
430
448
  mountReviewWorkbench(this.#mountRoot, this.#session, options);
431
449
  this.#unmountInspector?.();