@kontourai/survey 4.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.
@@ -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,6 +1,6 @@
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";
@@ -12,7 +12,7 @@ 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}`, {
@@ -126,7 +130,16 @@ function buildDecisionCardAuthorizing(state) {
126
130
  */
127
131
  function decisionCardRenderedPrompt(state, targetLabel) {
128
132
  const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
129
- const proposedCandidate = state.item.spec.candidates.find((c) => c.role === "proposed");
133
+ const proposedCandidates = state.item.spec.candidates.filter((c) => c.role === "proposed");
134
+ // A conflict card lists every proposed value and offers reject-all or could
135
+ // not confirm; the prompt states the same, naming no single value.
136
+ if (proposedCandidates.length > 1) {
137
+ const decisionLabel = state.decision === "reject-proposed"
138
+ ? "Reject all values"
139
+ : state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
140
+ return `For ${targetLabel}, ${proposedCandidates.length} different values were proposed: ${proposedCandidates.map((c) => formatValue(c.value)).join(", ")}. Selected decision: ${decisionLabel}.`;
141
+ }
142
+ const proposedCandidate = proposedCandidates[0];
130
143
  const currentValue = formatValue(currentCandidate?.value ?? "");
131
144
  const proposedValue = formatValue(proposedCandidate?.value ?? "");
132
145
  const decisionLabel = state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
@@ -206,7 +219,9 @@ function duplicateTargetIssues(items) {
206
219
  return issues;
207
220
  }
208
221
  function mapReviewApplyResultToActions(input) {
209
- if (input.result.decision === "could-not-confirm") {
222
+ // Could-not-confirm applies nothing, and neither does a decision that selects
223
+ // no candidate (rejecting every value of a conflict): there is no value to apply.
224
+ if (input.result.decision === "could-not-confirm" || input.result.selectedCandidateId === undefined) {
210
225
  return [];
211
226
  }
212
227
  const context = buildReviewApplyActionContext(input.result, input.itemByName, input.issues);
@@ -256,6 +271,8 @@ function buildReviewApplyActionContext(result, itemByName, issues) {
256
271
  };
257
272
  }
258
273
  function matchingSelectedCandidate(item, result) {
274
+ if (result.selectedCandidateId === undefined)
275
+ return undefined;
259
276
  const candidate = findSoleCandidateById(item, result.selectedCandidateId);
260
277
  return candidate
261
278
  && candidate.role === result.selectedCandidateRole
@@ -280,6 +297,16 @@ export function buildReviewWorkbenchResultsFromSession(session) {
280
297
  if (!reviewDecision) {
281
298
  return [];
282
299
  }
300
+ if (decisionSelectsNoCandidate(item, decision)) {
301
+ return [{
302
+ reviewItemName: item.metadata.name,
303
+ decision,
304
+ unselectedCandidates: [...item.spec.candidates],
305
+ reviewDecision,
306
+ status: reviewDecision.spec.status,
307
+ rationale: reviewDecision.spec.rationale,
308
+ }];
309
+ }
283
310
  const selectedCandidate = candidateForDecision(item, decision);
284
311
  const editedValue = decision === "accept-proposed"
285
312
  ? session.editedValuesByItemName?.[item.metadata.name]
@@ -491,13 +518,13 @@ function fieldCardState(item, decision) {
491
518
  * {@link keepActionDecision}). Saying "Kept — flagged wrong" there would claim a
492
519
  * distinction the record does not hold, so the chip states only what happened.
493
520
  */
494
- function chipLabel(state, hasCurrentValue) {
521
+ function chipLabel(state, hasCurrentValue, conflictingValues) {
495
522
  switch (state) {
496
523
  case "accepted": return "Accepted";
497
- case "kept": return hasCurrentValue ? "Kept current" : "Left unset";
498
- case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : "Left unset";
524
+ case "kept": return hasCurrentValue ? "Kept current" : conflictingValues ? "All values rejected" : "Left unset";
525
+ case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : conflictingValues ? "All values rejected" : "Left unset";
499
526
  case "could-not-confirm": return "Could not confirm";
500
- default: return "Needs review";
527
+ default: return conflictingValues ? `Conflict: ${conflictingValues} values` : "Needs review";
501
528
  }
502
529
  }
503
530
  const DEFAULT_REVIEW_QUEUE_PAGE_SIZE = 50;
@@ -618,11 +645,16 @@ function renderFieldCard(item, session, presentationAdapter) {
618
645
  const state = fieldCardState(item, decision);
619
646
  const decided = decision !== undefined;
620
647
  const current = item.spec.candidates.find((candidate) => candidate.role === "current");
621
- const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
648
+ const proposedCandidates = item.spec.candidates.filter((candidate) => candidate.role === "proposed");
649
+ // Several proposed values for one claim are a conflict. A control names a
650
+ // role, not a value, so none may make one of them the trusted value; the
651
+ // reviewer can still reject them all or end the round as could-not-confirm.
652
+ const conflict = proposedCandidates.length > 1;
653
+ const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
622
654
  const presentation = buildReviewItemPresentation(item, presentationAdapter);
623
655
  const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
624
656
  const kind = hasCurrentValue ? "Update" : "New";
625
- const keepLabel = hasCurrentValue ? "Keep current" : "Leave unset";
657
+ const keepLabel = hasCurrentValue ? "Keep current" : conflict ? "Reject all values" : "Leave unset";
626
658
  // Only render controls whose decision this item can actually record. Every
627
659
  // decision resolves to a candidate role, so a control routed at a role the
628
660
  // item does not carry cannot emit anything — it used to throw mid-click and
@@ -653,11 +685,13 @@ function renderFieldCard(item, session, presentationAdapter) {
653
685
  <div class="frow1">
654
686
  <span class="fname">${escapeHtml(presentation.targetLabel)}</span>
655
687
  <span class="fkind">${kind}</span>
656
- <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state, hasCurrentValue)}</span>
688
+ <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
657
689
  </div>
658
690
  ${proposed
659
691
  ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
660
- : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
692
+ : proposedCandidates.length > 1
693
+ ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel)
694
+ : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
661
695
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
662
696
  <div class="decide">
663
697
  ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
@@ -667,6 +701,7 @@ function renderFieldCard(item, session, presentationAdapter) {
667
701
  Suggestion was wrong
668
702
  </label>
669
703
  <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
704
+ ${conflict ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
670
705
  </div>
671
706
  <!--
672
707
  A control's precondition message belongs where the control is. The
@@ -677,7 +712,7 @@ function renderFieldCard(item, session, presentationAdapter) {
677
712
  -->
678
713
  <span class="derr" data-testid="decision-error" role="alert" hidden></span>
679
714
  <div class="decided">
680
- <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue)}</span>
715
+ <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
681
716
  <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
682
717
  </div>
683
718
  ${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
@@ -685,6 +720,24 @@ function renderFieldCard(item, session, presentationAdapter) {
685
720
  </section>
686
721
  `;
687
722
  }
723
+ /**
724
+ * The card body for an item whose candidate set holds several proposed values.
725
+ * Lists every value with its excerpt; the workbench records a decision against
726
+ * a role, so it cannot pick one of them, and says what the reviewer can do.
727
+ */
728
+ function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel) {
729
+ const values = candidates.map((candidate) => {
730
+ const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
731
+ const excerpt = candidate.locator?.excerpt;
732
+ return `<li data-testid="conflicting-value"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}</li>`;
733
+ }).join("");
734
+ return `
735
+ <div class="field-value" data-testid="conflicting-proposals">
736
+ <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>
737
+ <ul>${values}</ul>
738
+ </div>
739
+ `;
740
+ }
688
741
  /**
689
742
  * Renders the Current → Proposed diff row. The proposed side is editable
690
743
  * (via {@link renderProposedValueEditor}) until the field has a decision.
@@ -1165,7 +1218,7 @@ function createReviewWorkbenchController(root, startState, options) {
1165
1218
  const session = sessionForEvent;
1166
1219
  const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
1167
1220
  const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
1168
- const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
1221
+ const candidateId = item && decision ? decisionCandidateId(item, decision) : undefined;
1169
1222
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
1170
1223
  const note = itemName ? session.notesByItemName[itemName] : undefined;
1171
1224
  // Carry the reviewer's inline edit in the event (accept-proposed only), so
@@ -1188,7 +1241,7 @@ function createReviewWorkbenchController(root, startState, options) {
1188
1241
  occurredAt: session.reviewedAt,
1189
1242
  reviewItemName: itemName,
1190
1243
  reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
1191
- candidateId: candidate?.id,
1244
+ candidateId,
1192
1245
  status: definition?.status,
1193
1246
  ...(decision === "could-not-confirm"
1194
1247
  ? {
@@ -39,6 +39,8 @@
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
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; }
42
+ .inspector-posture.extraction-failed { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
43
+ .inspector-posture.extraction-incomplete { background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
42
44
  .inspector-filters { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
43
45
  .inspector-filters label { display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
44
46
  .inspector-filters select, .inspector-filters input { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
@@ -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) {
@@ -44,6 +44,10 @@ export function buildSurveyTrustBundle(input, options = {}) {
44
44
  const events = [];
45
45
  for (const projection of input.claims) {
46
46
  const candidateSet = requireMapValue(candidateSets, projection.candidateSetId, "candidate set");
47
+ if (projection.candidateId === undefined && candidateSet.selectedCandidateId === undefined && candidateSet.candidates.length > 1) {
48
+ projectUnselectedSetClaim({ input, projection, candidateSet, rawSources, extractions, reviewsByCandidateSet, projectionContextId, claims, evidence, events });
49
+ continue;
50
+ }
47
51
  const candidate = selectCandidate(candidateSet, projection.candidateId);
48
52
  const extraction = requireMapValue(extractions, candidate.extractionId, "extraction");
49
53
  const rawSource = requireMapValue(rawSources, extraction.sourceId, "raw source");
@@ -220,6 +224,116 @@ export function buildSurveyTrustBundle(input, options = {}) {
220
224
  events,
221
225
  };
222
226
  }
227
+ /**
228
+ * A claim over a candidate set that selects none of its candidates (a review
229
+ * rejected every conflicting value, or could not confirm any). No single value
230
+ * is presented: the claim value is the projection's own (null from the
231
+ * canonical path), every candidate is listed in `metadata.survey.candidates`
232
+ * and backed by its own evidence record, and the status can never be trusted.
233
+ * A review that names one candidate cannot apply to such a claim and is
234
+ * refused rather than dropped. With `reviewProofs`, no integrity anchor is
235
+ * attached: the anchor commits one reviewed candidate, and this claim has none
236
+ * (it is never `verified` or `assumed`).
237
+ */
238
+ function projectUnselectedSetClaim(context) {
239
+ const { input, projection, candidateSet, projectionContextId } = context;
240
+ const setReviews = context.reviewsByCandidateSet.get(candidateSet.id) ?? [];
241
+ const candidateReviews = setReviews.filter((review) => review.candidateId);
242
+ if (candidateReviews.length) {
243
+ throw new Error(`Claim ${projection.id} names no candidate of set ${candidateSet.id}, but review ${candidateReviews.map((review) => review.id).join(", ")} is about candidate ${candidateReviews.map((review) => review.candidateId).join(", ")}: a candidate-level review needs a selectedCandidateId on the set or a candidateId on the claim.`);
244
+ }
245
+ const reviews = setReviews;
246
+ const review = latestReview(reviews, projection.id);
247
+ const setStatus = statusFor({ candidateSet, candidate: { id: "", extractionId: "", value: null } });
248
+ const status = projection.status
249
+ ?? (review?.resolution === "could_not_confirm"
250
+ ? (setStatus === "disputed" ? setStatus : review.status)
251
+ : review?.status ?? setStatus);
252
+ if (status === "verified" || status === "assumed") {
253
+ throw new ReviewAgreementError("status-mismatch", `Claim ${projection.id} selects no candidate of set ${candidateSet.id}, so it cannot be ${status}`);
254
+ }
255
+ assertReviewOutcomeDiscipline({ subject: `Claim ${projection.id}`, status, review, candidateSetStatus: candidateSet.status });
256
+ const sources = candidateSet.candidates.map((candidate) => {
257
+ const extraction = requireMapValue(context.extractions, candidate.extractionId, "extraction");
258
+ const rawSource = requireMapValue(context.rawSources, extraction.sourceId, "raw source");
259
+ return { candidate, extraction, rawSource };
260
+ });
261
+ const evidenceIds = sources.map(({ candidate, extraction, rawSource }) => {
262
+ const id = projectionRecordId(projection.id, projectionContextId, "claim-evidence", `evidence.source.${candidate.id}`);
263
+ const policyStandard = policyStandardFields(rawSource);
264
+ context.evidence.push({
265
+ id,
266
+ claimId: projection.id,
267
+ evidenceType: projection.evidenceType ?? (rawSource.resolution ? evidenceTypeForResolution(rawSource, rawSource.resolution) : evidenceTypeFor(rawSource)),
268
+ method: projection.evidenceMethod ?? "extraction",
269
+ sourceRef: rawSource.sourceRef,
270
+ sourceLocator: extraction.locator,
271
+ excerptOrSummary: evidenceExcerptOrSummary({ rawSource, extraction, projection, policyStandard }),
272
+ observedAt: rawSource.observedAt,
273
+ collectedBy: projection.collectedBy,
274
+ integrityRef: rawSource.checksum,
275
+ metadata: {
276
+ ...rawSource.metadata,
277
+ ...extraction.metadata,
278
+ ...candidate.metadata,
279
+ ...(policyStandard ? { policyStandard } : {}),
280
+ rawSourceKind: rawSource.kind,
281
+ locatorScheme: rawSource.locatorScheme,
282
+ candidateId: candidate.id,
283
+ ...(candidate.confidence ?? extraction.confidence) !== undefined ? { confidence: candidate.confidence ?? extraction.confidence } : {},
284
+ },
285
+ });
286
+ return id;
287
+ });
288
+ const producerSurvey = isRecord(projection.metadata?.survey) ? projection.metadata.survey : {};
289
+ context.claims.push({
290
+ id: projection.id,
291
+ subjectType: projection.subjectType,
292
+ subjectId: projection.subjectId,
293
+ facet: projection.facet,
294
+ claimType: projection.claimType,
295
+ fieldOrBehavior: projection.fieldOrBehavior,
296
+ value: "value" in projection ? projection.value : null,
297
+ status,
298
+ createdAt: projection.createdAt ?? sources.map(({ extraction }) => extraction.extractedAt).sort()[0],
299
+ updatedAt: projection.updatedAt ?? review?.reviewedAt ?? input.generatedAt,
300
+ impactLevel: projection.impactLevel,
301
+ derivedFrom: projection.derivedFrom,
302
+ derivationEdges: projection.derivationEdges,
303
+ confidenceBasis: {
304
+ sourceQuality: "moderate",
305
+ reviewerAuthority: "none",
306
+ evidenceStrength: "weak",
307
+ impactLevel: projection.impactLevel,
308
+ ...projection.confidenceBasis,
309
+ },
310
+ metadata: {
311
+ ...projection.metadata,
312
+ survey: {
313
+ ...producerSurvey,
314
+ candidateSetId: candidateSet.id,
315
+ candidateSetStatus: candidateSet.status,
316
+ // No candidate is selected; every value the set held, in set order.
317
+ candidates: candidateSet.candidates.map((candidate) => ({
318
+ candidateId: candidate.id,
319
+ value: candidate.value,
320
+ ...(candidate.rejectionReason !== undefined ? { rejectionReason: candidate.rejectionReason } : {}),
321
+ })),
322
+ reviewOutcomeId: review?.id,
323
+ },
324
+ },
325
+ });
326
+ context.events.push({
327
+ id: projectionRecordId(projection.id, projectionContextId, "claim-event", `event.${status}`),
328
+ claimId: projection.id,
329
+ status,
330
+ actor: projection.actor ?? (review?.resolution === "could_not_confirm" ? undefined : review?.actor) ?? projection.collectedBy,
331
+ method: projection.eventMethod ?? eventMethodFor(status, candidateSet),
332
+ evidenceIds,
333
+ createdAt: review?.reviewedAt ?? input.generatedAt,
334
+ notes: review?.rationale ?? candidateSet.rationale,
335
+ });
336
+ }
223
337
  function validateProjectionContextId(contextId) {
224
338
  if (contextId === undefined)
225
339
  return undefined;
@@ -504,6 +618,8 @@ function statusFor(input) {
504
618
  return "disputed";
505
619
  if (input.candidateSet.status === "escalated")
506
620
  return "disputed";
621
+ if (input.candidateSet.status === "rejected")
622
+ return "rejected";
507
623
  return "proposed";
508
624
  }
509
625
  function assertProducerDiscipline(input) {
@@ -28,7 +28,11 @@ export interface Extraction {
28
28
  extractedAt: string;
29
29
  metadata?: Record<string, unknown>;
30
30
  }
31
- export type CandidateSetStatus = "resolved" | "needs-review" | "conflict" | "escalated";
31
+ /**
32
+ * `rejected`: a review rejected every candidate in the set and selected none
33
+ * (for example rejecting all conflicting values for one claim).
34
+ */
35
+ export type CandidateSetStatus = "resolved" | "needs-review" | "conflict" | "escalated" | "rejected";
32
36
  export type EscalationDimension = "framing" | "completeness" | "conclusion" | "citation";
33
37
  export interface Candidate {
34
38
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",