@kontourai/survey 4.0.0 → 6.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.
@@ -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
@@ -143,32 +143,65 @@ export function reviewSessionSummary(session) {
143
143
  * would invent a prior value, with provenance, that the source never had.
144
144
  */
145
145
  export function keepActionDecision(item, flaggedWrong) {
146
- const hasRole = (role) => item.spec.candidates.some((candidate) => candidate.role === role);
147
- if (flaggedWrong || !hasRole("current")) {
148
- 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;
149
152
  }
150
- return "keep-current";
153
+ return count("current") === 1 ? "keep-current" : undefined;
151
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"]);
152
161
  /**
153
162
  * The candidate a workbench decision applies to.
154
163
  *
155
- * Selection is by role, but the id this returns is what every caller makes
156
- * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
157
- * replay expectation. So the id has to name exactly one candidate before it
158
- * leaves here. Guarding the render path alone let the workbench emit an
159
- * 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
160
167
  * candidate's value against it; this is the shared selector all of those go
161
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.
162
178
  */
163
179
  export function candidateForDecision(item, decision) {
164
180
  const definition = workbenchDecisionDefinitions[decision];
165
- 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];
166
183
  if (!candidate) {
167
184
  throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
168
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
+ }
169
189
  assertSoleCandidateId(item, candidate.id);
170
190
  return candidate;
171
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
+ }
172
205
  /**
173
206
  * The value that should actually be applied for a decision: the reviewer's inline
174
207
  * edit when one was made for an accept-proposed decision, otherwise the selected
@@ -254,7 +287,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
254
287
  if (decision === "could-not-confirm" && !note?.trim()) {
255
288
  throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
256
289
  }
257
- const candidate = candidateForDecision(item, decision);
290
+ const candidateId = decisionCandidateId(item, decision);
258
291
  const definition = workbenchDecisionDefinitions[decision];
259
292
  const reviewDecisionName = `${item.metadata.name}-${decision}`;
260
293
  // Carry the reviewer's inline edit in the event itself (accept-proposed
@@ -284,7 +317,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
284
317
  occurredAt: session.reviewedAt,
285
318
  reviewItemName: item.metadata.name,
286
319
  reviewDecisionName,
287
- candidateId: candidate.id,
320
+ ...(candidateId !== undefined ? { candidateId } : {}),
288
321
  status: definition.status,
289
322
  ...(decision === "could-not-confirm"
290
323
  ? {
@@ -302,7 +335,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
302
335
  occurredAt: session.reviewedAt,
303
336
  reviewItemName: item.metadata.name,
304
337
  reviewDecisionName,
305
- candidateId: candidate.id,
338
+ ...(candidateId !== undefined ? { candidateId } : {}),
306
339
  status: definition.status,
307
340
  rationale: note,
308
341
  ...(decision === "could-not-confirm"
@@ -1,4 +1,4 @@
1
- import { candidateForDecision, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
1
+ import { candidateForDecision, 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]));
@@ -56,17 +56,19 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
56
56
  else if (itemName && itemsByName.has(itemName)) {
57
57
  const item = itemsByName.get(itemName);
58
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;
59
61
  const expectedStatus = workbenchDecisionDefinitions[decision].status;
60
62
  const referencedCandidateExists = event.spec.candidateId
61
63
  ? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
62
64
  : false;
63
- if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
65
+ if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidateId) {
64
66
  issues.push({
65
67
  ...eventRef,
66
68
  code: "decision-candidate-mismatch",
67
69
  reviewItemName: itemName,
68
70
  candidateId: event.spec.candidateId,
69
- 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"}.`,
70
72
  });
71
73
  }
72
74
  if (event.spec.status !== expectedStatus) {
@@ -246,7 +246,10 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
246
246
  .survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
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
- .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; }
249
+ /* Caution first: an artifact failure on the same source must win the tie and stay negative. */
250
+ .survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
251
+ .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch, .survey-workbench-embed .inspector-posture.proposals-excluded{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
252
+ .survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
250
253
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
251
254
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
252
255
  .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; }
@@ -832,6 +835,12 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
832
835
  font-size: 12px;
833
836
  }
834
837
 
838
+ .survey-workbench-embed .excerpt-verification{
839
+ margin: 0;
840
+ font-size: 12px;
841
+ color: var(--k-muted);
842
+ }
843
+
835
844
  /* decisions */
836
845
 
837
846
  /* The decision row is the point of the card, so it sits on its own line,
@@ -243,7 +243,10 @@
243
243
  .survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
244
244
  .survey-workbench-embed .inspector-heading h2{ margin: 0; }
245
245
  .survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
246
- .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; }
246
+ /* Caution first: an artifact failure on the same source must win the tie and stay negative. */
247
+ .survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
248
+ .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch, .survey-workbench-embed .inspector-posture.proposals-excluded{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
249
+ .survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
247
250
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
248
251
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
249
252
  .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; }
@@ -829,6 +832,12 @@
829
832
  font-size: 12px;
830
833
  }
831
834
 
835
+ .survey-workbench-embed .excerpt-verification{
836
+ margin: 0;
837
+ font-size: 12px;
838
+ color: var(--k-muted);
839
+ }
840
+
832
841
  /* decisions */
833
842
 
834
843
  /* The decision row is the point of the card, so it sits on its own line,
@@ -5,7 +5,7 @@ import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type Review
5
5
  export { reviewAuditRowKeys, type ReviewAuditRowKey } from "./audit-rows.js";
6
6
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, type BindReviewQueueOptions, type ReviewQueueBinding, type ReviewQueueBindingIssue, type ReviewQueueBindingIssueCode, type ReviewQueueExtractionIssue, type ReviewQueueExtractionIssueCode, type ValidateReviewQueueBindingOptions, } from "./queue-binding.js";
7
7
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type BuiltExtractionInspectorCandidate, type BuiltExtractionInspectorModel, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
8
- export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
8
+ export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
9
9
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
10
10
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
11
11
  export { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, type ReviewSessionReplayIssue, type ReviewSessionReplayIssueCode, type ReviewSessionReplayWarning, } from "./review-session-replay.js";
@@ -110,24 +110,31 @@ export interface ReviewQueueWindow {
110
110
  export interface BrowserReviewWorkbenchConfig {
111
111
  readonly startState?: ReviewQueueSessionState | ReviewWorkbenchState;
112
112
  }
113
+ /**
114
+ * The in-process outcome of one workbench decision, derived from the session.
115
+ * For a decision that selects no candidate ({@link decisionSelectsNoCandidate}:
116
+ * reject-all or could-not-confirm on a conflict), every `selected*` and
117
+ * `effective*` field is absent, `unselectedCandidates` holds every candidate,
118
+ * and `reviewDecision.spec.candidateId` is absent.
119
+ */
113
120
  export interface ReviewWorkbenchResult {
114
121
  readonly reviewItemName: string;
115
122
  readonly decision: ReviewWorkbenchDecision;
116
- readonly selectedCandidate: ReviewCandidate;
117
- readonly selectedCandidateId: string;
123
+ readonly selectedCandidate?: ReviewCandidate;
124
+ readonly selectedCandidateId?: string;
118
125
  readonly selectedCandidateRole?: ReviewCandidate["role"];
119
126
  /** The selected candidate's original value, unaffected by any reviewer edit. Used
120
127
  * for candidate-identity matching (see `matchingSelectedCandidate`); consumers who
121
128
  * want the reviewer's edited value should read `effectiveValue` instead. */
122
- readonly selectedValue: unknown;
123
- readonly selectedDisplayValue: string;
129
+ readonly selectedValue?: unknown;
130
+ readonly selectedDisplayValue?: string;
124
131
  /** Reviewer-edited override captured for an accept-proposed decision, if the
125
132
  * reviewer changed the proposed value before applying it. Additive/optional. */
126
133
  readonly editedValue?: unknown;
127
134
  /** The value that should actually be applied: `editedValue` when present (and the
128
135
  * decision selects the proposed candidate), otherwise `selectedValue`. */
129
- readonly effectiveValue: unknown;
130
- readonly effectiveDisplayValue: string;
136
+ readonly effectiveValue?: unknown;
137
+ readonly effectiveDisplayValue?: string;
131
138
  readonly unselectedCandidates: readonly ReviewCandidate[];
132
139
  readonly reviewDecision: ReviewDecision;
133
140
  readonly status: ReviewDecision["spec"]["status"];
@@ -1,18 +1,18 @@
1
1
  import { canonicalJson } from "./canonical.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
- import { candidateForDecision, keepActionDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
3
+ import { candidateForDecision, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
4
4
  import { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
5
5
  import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
6
6
  import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
7
7
  import { findSoleCandidateById, reviewResourceApiVersion, } from "../review-resource.js";
8
8
  import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
9
- import { humanizeIdentifier } from "./review-presentation.js";
9
+ import { excludedProposalsSentence, humanizeIdentifier } from "./review-presentation.js";
10
10
  import { editedValueFromEditorText, isIsoCalendarDate, parsePlainDecimal } from "./edited-value.js";
11
11
  import { createAuditFactTrace, } from "./audit-rows.js";
12
12
  export { reviewAuditRowKeys } from "./audit-rows.js";
13
13
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
14
14
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
15
- export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
15
+ export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
16
16
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
17
17
  export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
18
18
  export { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
@@ -27,11 +27,15 @@ export function buildReviewDecision(state) {
27
27
  throw new Error("Could not confirm requires a non-empty reason.");
28
28
  }
29
29
  const candidate = candidateForDecision(state.item, state.decision);
30
+ // A decision that selects no candidate (a value-neutral decision on a
31
+ // conflict) records no candidate id and no candidate's projection hints.
32
+ const selectsNone = decisionSelectsNoCandidate(state.item, state.decision);
30
33
  const projection = {
31
- ...candidate.projection,
32
- reviewOutcomeId: candidate.projection?.reviewOutcomeId
34
+ ...(selectsNone ? state.item.spec.projection : candidate.projection),
35
+ reviewOutcomeId: (selectsNone ? state.item.spec.projection : candidate.projection)?.reviewOutcomeId
33
36
  ?? `${state.item.metadata.name}:${state.decision}:review-outcome`,
34
37
  };
38
+ const candidateProjection = selectsNone ? undefined : candidate.projection;
35
39
  const authorizing = buildDecisionCardAuthorizing(state);
36
40
  const reviewDecision = {
37
41
  apiVersion: reviewResourceApiVersion,
@@ -43,7 +47,7 @@ export function buildReviewDecision(state) {
43
47
  },
44
48
  spec: {
45
49
  reviewItemName: state.item.metadata.name,
46
- candidateId: candidate.id,
50
+ ...(selectsNone ? {} : { candidateId: candidate.id }),
47
51
  status: definition.status,
48
52
  ...(state.decision === "could-not-confirm"
49
53
  ? {
@@ -64,7 +68,7 @@ export function buildReviewDecision(state) {
64
68
  : {}),
65
69
  },
66
70
  status: {
67
- ...(candidate.projection?.claimId ? { appliedToClaimIds: [candidate.projection.claimId] } : {}),
71
+ ...(candidateProjection?.claimId ? { appliedToClaimIds: [candidateProjection.claimId] } : {}),
68
72
  },
69
73
  };
70
74
  assertReviewResolutionConsistency(`ReviewDecision ${reviewDecision.metadata.name}`, {
@@ -125,8 +129,24 @@ function buildDecisionCardAuthorizing(state) {
125
129
  * followed by the selected decision label so the block is self-contained.
126
130
  */
127
131
  function decisionCardRenderedPrompt(state, targetLabel) {
132
+ // The card shows proposals excluded at import; the recorded prompt must say
133
+ // the reviewer was told about them.
134
+ const excluded = excludedProposalsSentence(buildReviewItemPresentation(state.item).excludedProposals);
135
+ const base = decisionCardBasePrompt(state, targetLabel);
136
+ return excluded ? `${base} ${excluded}` : base;
137
+ }
138
+ function decisionCardBasePrompt(state, targetLabel) {
128
139
  const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
129
- const proposedCandidate = state.item.spec.candidates.find((c) => c.role === "proposed");
140
+ const proposedCandidates = state.item.spec.candidates.filter((c) => c.role === "proposed");
141
+ // A conflict card lists every proposed value and offers reject-all or could
142
+ // not confirm; the prompt states the same, naming no single value.
143
+ if (proposedCandidates.length > 1) {
144
+ const decisionLabel = state.decision === "reject-proposed"
145
+ ? "Reject all values"
146
+ : state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
147
+ return `For ${targetLabel}, ${proposedCandidates.length} different values were proposed: ${proposedCandidates.map((c) => formatValue(c.value)).join(", ")}. Selected decision: ${decisionLabel}.`;
148
+ }
149
+ const proposedCandidate = proposedCandidates[0];
130
150
  const currentValue = formatValue(currentCandidate?.value ?? "");
131
151
  const proposedValue = formatValue(proposedCandidate?.value ?? "");
132
152
  const decisionLabel = state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
@@ -206,7 +226,9 @@ function duplicateTargetIssues(items) {
206
226
  return issues;
207
227
  }
208
228
  function mapReviewApplyResultToActions(input) {
209
- if (input.result.decision === "could-not-confirm") {
229
+ // Could-not-confirm applies nothing, and neither does a decision that selects
230
+ // no candidate (rejecting every value of a conflict): there is no value to apply.
231
+ if (input.result.decision === "could-not-confirm" || input.result.selectedCandidateId === undefined) {
210
232
  return [];
211
233
  }
212
234
  const context = buildReviewApplyActionContext(input.result, input.itemByName, input.issues);
@@ -256,6 +278,8 @@ function buildReviewApplyActionContext(result, itemByName, issues) {
256
278
  };
257
279
  }
258
280
  function matchingSelectedCandidate(item, result) {
281
+ if (result.selectedCandidateId === undefined)
282
+ return undefined;
259
283
  const candidate = findSoleCandidateById(item, result.selectedCandidateId);
260
284
  return candidate
261
285
  && candidate.role === result.selectedCandidateRole
@@ -280,6 +304,16 @@ export function buildReviewWorkbenchResultsFromSession(session) {
280
304
  if (!reviewDecision) {
281
305
  return [];
282
306
  }
307
+ if (decisionSelectsNoCandidate(item, decision)) {
308
+ return [{
309
+ reviewItemName: item.metadata.name,
310
+ decision,
311
+ unselectedCandidates: [...item.spec.candidates],
312
+ reviewDecision,
313
+ status: reviewDecision.spec.status,
314
+ rationale: reviewDecision.spec.rationale,
315
+ }];
316
+ }
283
317
  const selectedCandidate = candidateForDecision(item, decision);
284
318
  const editedValue = decision === "accept-proposed"
285
319
  ? session.editedValuesByItemName?.[item.metadata.name]
@@ -491,13 +525,13 @@ function fieldCardState(item, decision) {
491
525
  * {@link keepActionDecision}). Saying "Kept — flagged wrong" there would claim a
492
526
  * distinction the record does not hold, so the chip states only what happened.
493
527
  */
494
- function chipLabel(state, hasCurrentValue) {
528
+ function chipLabel(state, hasCurrentValue, conflictingValues) {
495
529
  switch (state) {
496
530
  case "accepted": return "Accepted";
497
- case "kept": return hasCurrentValue ? "Kept current" : "Left unset";
498
- case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : "Left unset";
531
+ case "kept": return hasCurrentValue ? "Kept current" : conflictingValues ? "All values rejected" : "Left unset";
532
+ case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : conflictingValues ? "All values rejected" : "Left unset";
499
533
  case "could-not-confirm": return "Could not confirm";
500
- default: return "Needs review";
534
+ default: return conflictingValues ? `Conflict: ${conflictingValues} values` : "Needs review";
501
535
  }
502
536
  }
503
537
  const DEFAULT_REVIEW_QUEUE_PAGE_SIZE = 50;
@@ -618,11 +652,16 @@ function renderFieldCard(item, session, presentationAdapter) {
618
652
  const state = fieldCardState(item, decision);
619
653
  const decided = decision !== undefined;
620
654
  const current = item.spec.candidates.find((candidate) => candidate.role === "current");
621
- const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
655
+ const proposedCandidates = item.spec.candidates.filter((candidate) => candidate.role === "proposed");
656
+ // Several proposed values for one claim are a conflict. A control names a
657
+ // role, not a value, so none may make one of them the trusted value; the
658
+ // reviewer can still reject them all or end the round as could-not-confirm.
659
+ const conflict = proposedCandidates.length > 1;
660
+ const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
622
661
  const presentation = buildReviewItemPresentation(item, presentationAdapter);
623
662
  const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
624
663
  const kind = hasCurrentValue ? "Update" : "New";
625
- const keepLabel = hasCurrentValue ? "Keep current" : "Leave unset";
664
+ const keepLabel = hasCurrentValue ? "Keep current" : conflict ? "Reject all values" : "Leave unset";
626
665
  // Only render controls whose decision this item can actually record. Every
627
666
  // decision resolves to a candidate role, so a control routed at a role the
628
667
  // item does not carry cannot emit anything — it used to throw mid-click and
@@ -653,12 +692,15 @@ function renderFieldCard(item, session, presentationAdapter) {
653
692
  <div class="frow1">
654
693
  <span class="fname">${escapeHtml(presentation.targetLabel)}</span>
655
694
  <span class="fkind">${kind}</span>
656
- <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state, hasCurrentValue)}</span>
695
+ <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
657
696
  </div>
658
697
  ${proposed
659
698
  ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
660
- : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
699
+ : proposedCandidates.length > 1
700
+ ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel)
701
+ : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
661
702
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
703
+ ${renderExtractionImportNotes(presentation)}
662
704
  <div class="decide">
663
705
  ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
664
706
  ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
@@ -667,6 +709,7 @@ function renderFieldCard(item, session, presentationAdapter) {
667
709
  Suggestion was wrong
668
710
  </label>
669
711
  <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
712
+ ${conflict ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
670
713
  </div>
671
714
  <!--
672
715
  A control's precondition message belongs where the control is. The
@@ -677,7 +720,7 @@ function renderFieldCard(item, session, presentationAdapter) {
677
720
  -->
678
721
  <span class="derr" data-testid="decision-error" role="alert" hidden></span>
679
722
  <div class="decided">
680
- <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue)}</span>
723
+ <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
681
724
  <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
682
725
  </div>
683
726
  ${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
@@ -685,6 +728,24 @@ function renderFieldCard(item, session, presentationAdapter) {
685
728
  </section>
686
729
  `;
687
730
  }
731
+ /**
732
+ * The card body for an item whose candidate set holds several proposed values.
733
+ * Lists every value with its excerpt; the workbench records a decision against
734
+ * a role, so it cannot pick one of them, and says what the reviewer can do.
735
+ */
736
+ function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel) {
737
+ const values = candidates.map((candidate) => {
738
+ const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
739
+ const excerpt = candidate.locator?.excerpt;
740
+ return `<li data-testid="conflicting-value"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}</li>`;
741
+ }).join("");
742
+ return `
743
+ <div class="field-value" data-testid="conflicting-proposals">
744
+ <p>${candidates.length} different values were proposed for this field. This queue cannot choose one of them yet: reject them all, or mark the field Could not confirm with a reason.</p>
745
+ <ul>${values}</ul>
746
+ </div>
747
+ `;
748
+ }
688
749
  /**
689
750
  * Renders the Current → Proposed diff row. The proposed side is editable
690
751
  * (via {@link renderProposedValueEditor}) until the field has a decision.
@@ -871,6 +932,35 @@ function renderProvenanceRow(item, proposed, presentationAdapter) {
871
932
  </div>
872
933
  `;
873
934
  }
935
+ /**
936
+ * What an envelope import says about this field's evidence: whether its
937
+ * excerpts were checked against the prepared source text, and any rival
938
+ * proposals the import left out because their excerpt was not at their span.
939
+ * Such a proposal is unverifiable, not disproven, so the reviewer sees it
940
+ * before accepting the value that remained. Empty for items from other
941
+ * producers.
942
+ */
943
+ function renderExtractionImportNotes(presentation) {
944
+ const { excerptVerification: verification, excludedProposals: excluded } = presentation;
945
+ if (verification === undefined && excluded.length === 0)
946
+ return "";
947
+ const excludedHtml = excluded.length === 0 ? "" : `
948
+ <div class="noprov" data-testid="excluded-proposals" role="note">
949
+ ${WARNING_SVG}
950
+ <span class="tag">${excluded.length} excluded</span>
951
+ <span>${excluded.length === 1 ? "Another proposal for this field was" : `${excluded.length} other proposals for this field were`} left out because the source text at the cited span is not the excerpt:
952
+ ${excluded.map((entry) => `<span data-testid="excluded-proposal" data-proposal-index="${escapeHtml(String(entry.proposalIndex))}"><q>${escapeHtml(entry.valueText)}</q> (proposal ${escapeHtml(String(entry.proposalIndex))}, ${escapeHtml(entry.locator)})</span>`).join(", ")}.
953
+ Unverifiable is not disproven: check the source before deciding.</span>
954
+ </div>`;
955
+ const verificationHtml = verification === undefined ? "" : `
956
+ <p class="excerpt-verification" data-testid="excerpt-verification" data-verified="${verification === "verified" ? "true" : "false"}">${verification === "verified"
957
+ ? "Excerpts checked against the prepared source text at import."
958
+ : "Excerpts not checked against the prepared source text at import."}</p>`;
959
+ return `
960
+ <div class="prov import-notes">${excludedHtml}${verificationHtml}
961
+ </div>
962
+ `;
963
+ }
874
964
  function renderConfidenceMeter(confidence) {
875
965
  const clamped = Math.max(0, Math.min(1, confidence));
876
966
  const pct = Math.round(clamped * 100);
@@ -927,6 +1017,7 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
927
1017
  ${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
928
1018
  ${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
929
1019
  ${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
1020
+ ${buildReviewItemPresentation(item, presentationAdapter).excludedProposals.map((entry) => fieldItem("excluded-proposal", `Excluded proposal ${entry.proposalIndex}`, `${entry.valueText} at ${entry.locator}: excerpt not at the cited span`)).join("")}
930
1021
  </dl>
931
1022
  ${preview
932
1023
  ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
@@ -1165,7 +1256,7 @@ function createReviewWorkbenchController(root, startState, options) {
1165
1256
  const session = sessionForEvent;
1166
1257
  const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
1167
1258
  const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
1168
- const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
1259
+ const candidateId = item && decision ? decisionCandidateId(item, decision) : undefined;
1169
1260
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
1170
1261
  const note = itemName ? session.notesByItemName[itemName] : undefined;
1171
1262
  // Carry the reviewer's inline edit in the event (accept-proposed only), so
@@ -1188,7 +1279,7 @@ function createReviewWorkbenchController(root, startState, options) {
1188
1279
  occurredAt: session.reviewedAt,
1189
1280
  reviewItemName: itemName,
1190
1281
  reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
1191
- candidateId: candidate?.id,
1282
+ candidateId,
1192
1283
  status: definition?.status,
1193
1284
  ...(decision === "could-not-confirm"
1194
1285
  ? {
@@ -38,7 +38,10 @@
38
38
  .inspector-heading, .inspector-layout { display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
39
39
  .inspector-heading h2 { margin: 0; }
40
40
  .inspector-posture { display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
41
- .inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
41
+ /* Caution first: an artifact failure on the same source must win the tie and stay negative. */
42
+ .inspector-posture.extraction-incomplete { background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
43
+ .inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch, .inspector-posture.proposals-excluded { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
44
+ .inspector-posture.extraction-failed { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
42
45
  .inspector-filters { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
43
46
  .inspector-filters label { display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
44
47
  .inspector-filters select, .inspector-filters input { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
@@ -624,6 +627,12 @@ h1, h2, h3, p {
624
627
  font-size: 12px;
625
628
  }
626
629
 
630
+ .excerpt-verification {
631
+ margin: 0;
632
+ font-size: 12px;
633
+ color: var(--k-muted);
634
+ }
635
+
627
636
  /* decisions */
628
637
 
629
638
  /* The decision row is the point of the card, so it sits on its own line,
@@ -1,4 +1,8 @@
1
1
  export function toSurfaceReviewedExtractionImport(record) {
2
+ const unreported = record.spec.envelope.result.proposals.findIndex((proposal) => proposal.confidence === undefined);
3
+ if (unreported !== -1) {
4
+ throw new Error(`Surface's reviewed-extraction profile requires a proposer confidence on every proposal; proposal ${unreported} of ${record.metadata.name} reports none.`);
5
+ }
2
6
  return record;
3
7
  }
4
8
  export function toSurfaceReviewedExtractionItem(item) {