@kontourai/survey 1.15.0 → 1.17.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.
@@ -1,4 +1,5 @@
1
1
  import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
2
+ import { assertReviewResolutionConsistency } from "../producer-discipline.js";
2
3
  import { reviewResourceApiVersion, } from "../../src/review-resource.js";
3
4
  export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
4
5
  export const defaultReviewSessionName = "review-workbench-session";
@@ -21,6 +22,12 @@ export const workbenchDecisionDefinitions = {
21
22
  candidateRole: "proposed",
22
23
  status: "rejected",
23
24
  },
25
+ "could-not-confirm": {
26
+ label: "Could not confirm",
27
+ effect: "The review round ends without changing or escalating the proposed claim.",
28
+ candidateRole: "proposed",
29
+ status: "proposed",
30
+ },
24
31
  };
25
32
  export function initialReviewWorkbenchState(item = publicDirectoryReviewItemExample) {
26
33
  return {
@@ -49,6 +56,7 @@ export function currentReviewWorkbenchState(session) {
49
56
  note: session.notesByItemName[item.metadata.name] ?? "",
50
57
  decision: session.decisionsByItemName[item.metadata.name],
51
58
  editedValue: session.editedValuesByItemName?.[item.metadata.name],
59
+ attemptEvidenceIds: session.attemptEvidenceIdsByItemName?.[item.metadata.name],
52
60
  reviewedAt: session.reviewedAt,
53
61
  actorId: session.actorId,
54
62
  };
@@ -65,6 +73,9 @@ export function deriveQueueRowStatus(item, session) {
65
73
  if (decision === "reject-proposed") {
66
74
  return "rejected";
67
75
  }
76
+ if (decision === "could-not-confirm") {
77
+ return "could-not-confirm";
78
+ }
68
79
  if (decision === "accept-proposed" || decision === "keep-current" || item.spec.candidateSetStatus === "resolved") {
69
80
  return "resolved";
70
81
  }
@@ -96,6 +107,9 @@ export function reviewSessionSummary(session) {
96
107
  if (decision === "reject-proposed") {
97
108
  return { ...summary, rejected: summary.rejected + 1 };
98
109
  }
110
+ if (decision === "could-not-confirm") {
111
+ return { ...summary, couldNotConfirm: (summary.couldNotConfirm ?? 0) + 1 };
112
+ }
99
113
  if (item.spec.candidateSetStatus === "escalated") {
100
114
  return { ...summary, escalated: summary.escalated + 1 };
101
115
  }
@@ -194,6 +208,9 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
194
208
  if (!decision) {
195
209
  continue;
196
210
  }
211
+ if (decision === "could-not-confirm" && !note?.trim()) {
212
+ throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
213
+ }
197
214
  const candidate = candidateForDecision(item, decision);
198
215
  const definition = workbenchDecisionDefinitions[decision];
199
216
  const reviewDecisionName = `${item.metadata.name}-${decision}`;
@@ -203,9 +220,20 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
203
220
  // the server apply boundary derives effectiveValue from it. Without this
204
221
  // the edit lives only in browser state and never survives replay.
205
222
  const editedValue = decision === "accept-proposed" ? session.editedValuesByItemName?.[item.metadata.name] : undefined;
223
+ const attemptEvidenceIds = decision === "could-not-confirm"
224
+ ? session.attemptEvidenceIdsByItemName?.[item.metadata.name]
225
+ : undefined;
226
+ assertReviewResolutionConsistency(`ReviewItem ${item.metadata.name}`, {
227
+ status: definition.status,
228
+ resolution: decision === "could-not-confirm" ? "could_not_confirm" : undefined,
229
+ resolutionReason: decision === "could-not-confirm" ? note : undefined,
230
+ attemptEvidenceIds,
231
+ actor: session.actorId,
232
+ reviewedAt: session.reviewedAt,
233
+ });
206
234
  const data = editedValue !== undefined
207
235
  ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
208
- : { workbenchDecision: decision };
236
+ : { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) };
209
237
  events.push(buildReviewSessionEvent(session, {
210
238
  sessionName,
211
239
  sequence: events.length + 1,
@@ -215,6 +243,13 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
215
243
  reviewDecisionName,
216
244
  candidateId: candidate.id,
217
245
  status: definition.status,
246
+ ...(decision === "could-not-confirm"
247
+ ? {
248
+ resolution: "could_not_confirm",
249
+ resolutionReason: note,
250
+ ...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
251
+ }
252
+ : {}),
218
253
  data,
219
254
  }));
220
255
  events.push(buildReviewSessionEvent(session, {
@@ -227,6 +262,13 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
227
262
  candidateId: candidate.id,
228
263
  status: definition.status,
229
264
  rationale: note,
265
+ ...(decision === "could-not-confirm"
266
+ ? {
267
+ resolution: "could_not_confirm",
268
+ resolutionReason: note,
269
+ ...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
270
+ }
271
+ : {}),
230
272
  data,
231
273
  }));
232
274
  }
@@ -264,7 +306,13 @@ export function replayReviewSessionEvents(startState, events) {
264
306
  if (isClearedWorkbenchDecisionEvent(event)) {
265
307
  const { [itemName]: _removedDecision, ...remainingDecisions } = session.decisionsByItemName;
266
308
  const { [itemName]: _removedEdit, ...remainingEdits } = session.editedValuesByItemName ?? {};
267
- return { ...session, decisionsByItemName: remainingDecisions, editedValuesByItemName: remainingEdits };
309
+ const { [itemName]: _removedAttempts, ...remainingAttempts } = session.attemptEvidenceIdsByItemName ?? {};
310
+ return {
311
+ ...session,
312
+ decisionsByItemName: remainingDecisions,
313
+ editedValuesByItemName: remainingEdits,
314
+ attemptEvidenceIdsByItemName: remainingAttempts,
315
+ };
268
316
  }
269
317
  const decision = workbenchDecisionFromEvent(event);
270
318
  if (!decision) {
@@ -276,12 +324,19 @@ export function replayReviewSessionEvents(startState, events) {
276
324
  // reviewer moved away from.
277
325
  const editedValue = workbenchEditedValueFromEvent(event);
278
326
  const editedValuesByItemName = { ...session.editedValuesByItemName };
327
+ const attemptEvidenceIdsByItemName = { ...session.attemptEvidenceIdsByItemName };
279
328
  if (decision === "accept-proposed" && editedValue !== undefined) {
280
329
  editedValuesByItemName[itemName] = editedValue;
281
330
  }
282
331
  else {
283
332
  delete editedValuesByItemName[itemName];
284
333
  }
334
+ if (decision === "could-not-confirm" && event.spec.attemptEvidenceIds?.length) {
335
+ attemptEvidenceIdsByItemName[itemName] = [...event.spec.attemptEvidenceIds];
336
+ }
337
+ else {
338
+ delete attemptEvidenceIdsByItemName[itemName];
339
+ }
285
340
  return {
286
341
  ...session,
287
342
  decisionsByItemName: {
@@ -289,6 +344,7 @@ export function replayReviewSessionEvents(startState, events) {
289
344
  [itemName]: decision,
290
345
  },
291
346
  editedValuesByItemName,
347
+ attemptEvidenceIdsByItemName,
292
348
  };
293
349
  }
294
350
  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";
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";
4
4
  export interface ReviewSessionReplayIssue {
5
5
  readonly code: ReviewSessionReplayIssueCode;
6
6
  readonly eventName: string;
@@ -76,6 +76,25 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
76
76
  message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
77
77
  });
78
78
  }
79
+ const expectedResolution = decision === "could-not-confirm" ? "could_not_confirm" : undefined;
80
+ if (event.spec.resolution !== expectedResolution) {
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 ${expectedResolution ?? "none"}, but references ${event.spec.resolution ?? "no resolution"}.`,
87
+ });
88
+ }
89
+ if (decision === "could-not-confirm" && !event.spec.resolutionReason?.trim()) {
90
+ issues.push({
91
+ ...eventRef,
92
+ code: "missing-resolution-reason",
93
+ reviewItemName: itemName,
94
+ candidateId: event.spec.candidateId,
95
+ message: `ReviewSessionEvent ${event.metadata.name} decision could-not-confirm requires a non-empty resolutionReason.`,
96
+ });
97
+ }
79
98
  }
80
99
  }
81
100
  if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
@@ -8,7 +8,7 @@ export function buildSurfaceProjectionPreview(item, decision, presentationAdapte
8
8
  canonicalClaim: buildPreviewClaim(item, selectedCandidate, decision, projection, presentationAdapter),
9
9
  candidateHistory: buildCandidateHistory(item, selectedCandidate, presentationAdapter),
10
10
  sourceEvidence: buildSourceEvidence(selectedCandidate),
11
- reviewEvent: buildReviewEvent(decision, projection),
11
+ reviewEvent: decision.spec.resolution === "could_not_confirm" ? undefined : buildReviewEvent(decision, projection),
12
12
  integrityPosture: buildIntegrityPosture(item, selectedCandidate, projection),
13
13
  authorityTrace: portableAuthorityTrace(selectedCandidate.producer?.authorityTrace),
14
14
  postureDisclaimer: postureDisclaimer(),
@@ -437,6 +437,7 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
437
437
  .survey-workbench-embed .field[data-state="accepted"] .stripe{ background: var(--k-positive); }
438
438
  .survey-workbench-embed .field[data-state="kept"] .stripe{ background: var(--k-faint); }
439
439
  .survey-workbench-embed .field[data-state="rejected"] .stripe{ background: var(--k-negative); }
440
+ .survey-workbench-embed .field[data-state="could-not-confirm"] .stripe{ background: var(--k-caution); }
440
441
  .survey-workbench-embed .field[data-state="review"] .stripe{ background: var(--k-brand); }
441
442
 
442
443
  .survey-workbench-embed .fbody{
@@ -491,6 +492,7 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
491
492
  .survey-workbench-embed .chip.accepted{ color: var(--k-positive); background: var(--k-positive-wash); }
492
493
  .survey-workbench-embed .chip.kept{ color: var(--k-muted); background: var(--k-sunken); }
493
494
  .survey-workbench-embed .chip.rejected{ color: var(--k-negative); background: var(--k-negative-wash); }
495
+ .survey-workbench-embed .chip.could-not-confirm{ color: var(--k-caution); background: var(--k-caution-wash); }
494
496
 
495
497
  .survey-workbench-embed .frow1 .push{
496
498
  margin-left: auto;
@@ -755,6 +757,12 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
755
757
  background: var(--k-panel-raised, var(--k-raised));
756
758
  }
757
759
 
760
+ .survey-workbench-embed .btn.unconfirmed{
761
+ border-color: var(--k-caution);
762
+ color: var(--k-caution);
763
+ background: var(--k-caution-wash);
764
+ }
765
+
758
766
  /* "Suggestion was wrong" — optional quiet calibration toggle on Keep current.
759
767
  Ticking it turns a keep-current decision into the reject signal. Not a third action. */
760
768
  .survey-workbench-embed .wrong{
@@ -434,6 +434,7 @@
434
434
  .survey-workbench-embed .field[data-state="accepted"] .stripe{ background: var(--k-positive); }
435
435
  .survey-workbench-embed .field[data-state="kept"] .stripe{ background: var(--k-faint); }
436
436
  .survey-workbench-embed .field[data-state="rejected"] .stripe{ background: var(--k-negative); }
437
+ .survey-workbench-embed .field[data-state="could-not-confirm"] .stripe{ background: var(--k-caution); }
437
438
  .survey-workbench-embed .field[data-state="review"] .stripe{ background: var(--k-brand); }
438
439
 
439
440
  .survey-workbench-embed .fbody{
@@ -488,6 +489,7 @@
488
489
  .survey-workbench-embed .chip.accepted{ color: var(--k-positive); background: var(--k-positive-wash); }
489
490
  .survey-workbench-embed .chip.kept{ color: var(--k-muted); background: var(--k-sunken); }
490
491
  .survey-workbench-embed .chip.rejected{ color: var(--k-negative); background: var(--k-negative-wash); }
492
+ .survey-workbench-embed .chip.could-not-confirm{ color: var(--k-caution); background: var(--k-caution-wash); }
491
493
 
492
494
  .survey-workbench-embed .frow1 .push{
493
495
  margin-left: auto;
@@ -752,6 +754,12 @@
752
754
  background: var(--k-panel-raised, var(--k-raised));
753
755
  }
754
756
 
757
+ .survey-workbench-embed .btn.unconfirmed{
758
+ border-color: var(--k-caution);
759
+ color: var(--k-caution);
760
+ background: var(--k-caution-wash);
761
+ }
762
+
755
763
  /* "Suggestion was wrong" — optional quiet calibration toggle on Keep current.
756
764
  Ticking it turns a keep-current decision into the reject signal. Not a third action. */
757
765
  .survey-workbench-embed .wrong{
@@ -1,4 +1,5 @@
1
1
  import { canonicalJson } from "./canonical.js";
2
+ import { assertReviewResolutionConsistency } from "../producer-discipline.js";
2
3
  import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
3
4
  import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
4
5
  import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
@@ -16,13 +17,16 @@ export function buildReviewDecision(state) {
16
17
  return undefined;
17
18
  }
18
19
  const definition = workbenchDecisionDefinitions[state.decision];
20
+ if (state.decision === "could-not-confirm" && !state.note.trim()) {
21
+ throw new Error("Could not confirm requires a non-empty reason.");
22
+ }
19
23
  const candidate = candidateForDecision(state.item, state.decision);
20
24
  const projection = {
21
25
  ...candidate.projection,
22
26
  reviewOutcomeId: candidate.projection?.reviewOutcomeId
23
27
  ?? `${state.item.metadata.name}:${state.decision}:review-outcome`,
24
28
  };
25
- return {
29
+ const reviewDecision = {
26
30
  apiVersion: reviewResourceApiVersion,
27
31
  kind: "ReviewDecision",
28
32
  metadata: {
@@ -34,6 +38,13 @@ export function buildReviewDecision(state) {
34
38
  reviewItemName: state.item.metadata.name,
35
39
  candidateId: candidate.id,
36
40
  status: definition.status,
41
+ ...(state.decision === "could-not-confirm"
42
+ ? {
43
+ resolution: "could_not_confirm",
44
+ resolutionReason: state.note.trim(),
45
+ ...(state.attemptEvidenceIds?.length ? { attemptEvidenceIds: [...state.attemptEvidenceIds] } : {}),
46
+ }
47
+ : {}),
37
48
  actor: {
38
49
  id: state.actorId,
39
50
  },
@@ -47,6 +58,15 @@ export function buildReviewDecision(state) {
47
58
  appliedToClaimIds: candidate.projection?.claimId ? [candidate.projection.claimId] : undefined,
48
59
  },
49
60
  };
61
+ assertReviewResolutionConsistency(`ReviewDecision ${reviewDecision.metadata.name}`, {
62
+ status: reviewDecision.spec.status,
63
+ resolution: reviewDecision.spec.resolution,
64
+ resolutionReason: reviewDecision.spec.resolutionReason,
65
+ attemptEvidenceIds: reviewDecision.spec.attemptEvidenceIds,
66
+ actor: reviewDecision.spec.actor?.id,
67
+ reviewedAt: reviewDecision.spec.reviewedAt,
68
+ });
69
+ return reviewDecision;
50
70
  }
51
71
  /**
52
72
  * Stable versioned prompt identifier for the workbench decision card control.
@@ -177,6 +197,9 @@ function duplicateTargetIssues(items) {
177
197
  return issues;
178
198
  }
179
199
  function mapReviewApplyResultToActions(input) {
200
+ if (input.result.decision === "could-not-confirm") {
201
+ return [];
202
+ }
180
203
  const context = buildReviewApplyActionContext(input.result, input.itemByName, input.issues);
181
204
  if (!context || input.options.skip?.(context)) {
182
205
  return [];
@@ -442,6 +465,8 @@ function fieldCardState(item, decision) {
442
465
  return "kept";
443
466
  if (decision === "reject-proposed")
444
467
  return "rejected";
468
+ if (decision === "could-not-confirm")
469
+ return "could-not-confirm";
445
470
  if (item.spec.candidateSetStatus === "resolved")
446
471
  return "kept";
447
472
  return "review";
@@ -451,6 +476,7 @@ function chipLabel(state) {
451
476
  case "accepted": return "Accepted";
452
477
  case "kept": return "Kept current";
453
478
  case "rejected": return "Kept — flagged wrong";
479
+ case "could-not-confirm": return "Could not confirm";
454
480
  default: return "Needs review";
455
481
  }
456
482
  }
@@ -540,6 +566,7 @@ function renderFieldCard(item, session, presentationAdapter) {
540
566
  <input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
541
567
  Suggestion was wrong
542
568
  </label>
569
+ <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>
543
570
  </div>
544
571
  <div class="decided">
545
572
  <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state)}</span>
@@ -813,7 +840,7 @@ function renderProducerFeedbackTags(item) {
813
840
  `;
814
841
  }
815
842
  function renderFooterTally(session) {
816
- const counts = { review: 0, accepted: 0, kept: 0, rejected: 0 };
843
+ const counts = { review: 0, accepted: 0, kept: 0, rejected: 0, "could-not-confirm": 0 };
817
844
  for (const item of session.items) {
818
845
  const state = fieldCardState(item, session.decisionsByItemName[item.metadata.name]);
819
846
  counts[state] += 1;
@@ -824,6 +851,7 @@ function renderFooterTally(session) {
824
851
  <span>Accepted <b data-testid="tally-accepted">${counts.accepted}</b></span>
825
852
  <span>Kept <b data-testid="tally-kept">${counts.kept}</b></span>
826
853
  <span>Flagged <b data-testid="tally-rejected">${counts.rejected}</b></span>
854
+ <span>Unconfirmed <b data-testid="tally-could-not-confirm">${counts["could-not-confirm"]}</b></span>
827
855
  <span>Remaining <b data-testid="tally-review">${counts.review}</b></span>
828
856
  </div>
829
857
  </footer>
@@ -973,10 +1001,13 @@ function createReviewWorkbenchController(root, startState, options) {
973
1001
  // effectiveValue from snapshot + events alone. setDecision updates the
974
1002
  // session before this runs, so the edit is already in state.
975
1003
  const editedValue = itemName && decision === "accept-proposed" ? session.editedValuesByItemName?.[itemName] : undefined;
1004
+ const attemptEvidenceIds = itemName && decision === "could-not-confirm"
1005
+ ? session.attemptEvidenceIdsByItemName?.[itemName]
1006
+ : undefined;
976
1007
  const defaultData = decision
977
1008
  ? (editedValue !== undefined
978
1009
  ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
979
- : { workbenchDecision: decision })
1010
+ : { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) })
980
1011
  : undefined;
981
1012
  events = [
982
1013
  ...events,
@@ -989,6 +1020,13 @@ function createReviewWorkbenchController(root, startState, options) {
989
1020
  reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
990
1021
  candidateId: candidate?.id,
991
1022
  status: definition?.status,
1023
+ ...(decision === "could-not-confirm"
1024
+ ? {
1025
+ resolution: "could_not_confirm",
1026
+ resolutionReason: note?.trim(),
1027
+ ...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
1028
+ }
1029
+ : {}),
992
1030
  rationale: note,
993
1031
  data: dataOverride ?? defaultData,
994
1032
  }),
@@ -998,6 +1036,9 @@ function createReviewWorkbenchController(root, startState, options) {
998
1036
  session = update(session);
999
1037
  };
1000
1038
  const setDecision = (itemName, decision, rawEditedValue) => {
1039
+ if (decision === "could-not-confirm" && !session.notesByItemName[itemName]?.trim()) {
1040
+ throw new Error("Could not confirm requires a non-empty reason.");
1041
+ }
1001
1042
  applySessionUpdate((current) => {
1002
1043
  const item = current.items.find((entry) => entry.metadata.name === itemName);
1003
1044
  const proposed = item?.spec.candidates.find((candidate) => candidate.role === "proposed");
@@ -1025,11 +1066,14 @@ function createReviewWorkbenchController(root, startState, options) {
1025
1066
  delete remainingDecisions[itemName];
1026
1067
  const remainingEdits = { ...current.editedValuesByItemName };
1027
1068
  delete remainingEdits[itemName];
1069
+ const remainingAttemptEvidenceIds = { ...current.attemptEvidenceIdsByItemName };
1070
+ delete remainingAttemptEvidenceIds[itemName];
1028
1071
  return {
1029
1072
  ...current,
1030
1073
  activeItemName: itemName,
1031
1074
  decisionsByItemName: remainingDecisions,
1032
1075
  editedValuesByItemName: remainingEdits,
1076
+ attemptEvidenceIdsByItemName: remainingAttemptEvidenceIds,
1033
1077
  };
1034
1078
  });
1035
1079
  appendEvent("decision-changed", itemName, { workbenchDecision: null });
@@ -1082,6 +1126,9 @@ function queueSessionFromStartState(startState) {
1082
1126
  editedValuesByItemName: startState.editedValue !== undefined
1083
1127
  ? { [startState.item.metadata.name]: startState.editedValue }
1084
1128
  : {},
1129
+ ...(startState.attemptEvidenceIds?.length
1130
+ ? { attemptEvidenceIdsByItemName: { [startState.item.metadata.name]: [...startState.attemptEvidenceIds] } }
1131
+ : {}),
1085
1132
  reviewedAt: startState.reviewedAt,
1086
1133
  actorId: startState.actorId,
1087
1134
  };
@@ -1125,6 +1172,23 @@ function bindFieldCardInteractions(root, controller) {
1125
1172
  controller.renderCurrentState();
1126
1173
  });
1127
1174
  });
1175
+ root.querySelectorAll("[data-testid='could-not-confirm']").forEach((button) => {
1176
+ button.addEventListener("click", () => {
1177
+ const itemName = button.dataset.itemName ?? "";
1178
+ const note = controller.currentSession().notesByItemName[itemName]?.trim() ?? "";
1179
+ const field = button.closest("[data-testid='review-field']");
1180
+ const textarea = field?.querySelector("[data-testid='reviewer-note']");
1181
+ if (!note) {
1182
+ textarea?.setCustomValidity?.("A reason is required when you could not confirm.");
1183
+ textarea?.reportValidity?.();
1184
+ textarea?.focus();
1185
+ return;
1186
+ }
1187
+ textarea?.setCustomValidity?.("");
1188
+ controller.setDecision(itemName, "could-not-confirm");
1189
+ controller.renderCurrentState();
1190
+ });
1191
+ });
1128
1192
  root.querySelectorAll("[data-testid='undo-decision']").forEach((button) => {
1129
1193
  button.addEventListener("click", () => {
1130
1194
  controller.clearDecision(button.dataset.itemName ?? "");
@@ -1133,6 +1197,7 @@ function bindFieldCardInteractions(root, controller) {
1133
1197
  });
1134
1198
  root.querySelectorAll("[data-testid='reviewer-note']").forEach((textarea) => {
1135
1199
  textarea.addEventListener("input", () => {
1200
+ textarea.setCustomValidity?.("");
1136
1201
  const itemName = textarea.dataset.itemName ?? "";
1137
1202
  controller.updateReviewerNote(itemName, textarea.value);
1138
1203
  refreshAuditPayloadForItem(textarea, controller, itemName);
@@ -283,6 +283,7 @@ h1, h2, h3, p {
283
283
  .field[data-state="accepted"] .stripe { background: var(--k-positive); }
284
284
  .field[data-state="kept"] .stripe { background: var(--k-faint); }
285
285
  .field[data-state="rejected"] .stripe { background: var(--k-negative); }
286
+ .field[data-state="could-not-confirm"] .stripe { background: var(--k-caution); }
286
287
  .field[data-state="review"] .stripe { background: var(--k-brand); }
287
288
 
288
289
  .fbody {
@@ -337,6 +338,7 @@ h1, h2, h3, p {
337
338
  .chip.accepted { color: var(--k-positive); background: var(--k-positive-wash); }
338
339
  .chip.kept { color: var(--k-muted); background: var(--k-sunken); }
339
340
  .chip.rejected { color: var(--k-negative); background: var(--k-negative-wash); }
341
+ .chip.could-not-confirm { color: var(--k-caution); background: var(--k-caution-wash); }
340
342
 
341
343
  .frow1 .push {
342
344
  margin-left: auto;
@@ -601,6 +603,12 @@ h1, h2, h3, p {
601
603
  background: var(--k-panel-raised, var(--k-raised));
602
604
  }
603
605
 
606
+ .btn.unconfirmed {
607
+ border-color: var(--k-caution);
608
+ color: var(--k-caution);
609
+ background: var(--k-caution-wash);
610
+ }
611
+
604
612
  /* "Suggestion was wrong" — optional quiet calibration toggle on Keep current.
605
613
  Ticking it turns a keep-current decision into the reject signal. Not a third action. */
606
614
  .wrong {
@@ -27,11 +27,16 @@ export function buildSurveyTrustBundle(input, options = {}) {
27
27
  const extraction = requireMapValue(extractions, candidate.extractionId, "extraction");
28
28
  const rawSource = requireMapValue(rawSources, extraction.sourceId, "raw source");
29
29
  const review = selectReview(reviewsByCandidateSet.get(candidateSet.id) ?? [], candidate.id);
30
- const status = projection.status ?? statusFor({ candidateSet, candidate, review });
31
- assertProducerDiscipline({ status, review, extraction, rawSource, projection });
30
+ const projectionReview = review?.resolution === "could_not_confirm" ? undefined : review;
31
+ const unreviewedStatus = statusFor({ candidateSet, candidate });
32
+ const status = projection.status
33
+ ?? (review?.resolution === "could_not_confirm"
34
+ ? (unreviewedStatus === "disputed" ? unreviewedStatus : review.status)
35
+ : statusFor({ candidateSet, candidate, review }));
36
+ assertProducerDiscipline({ status, review, candidateSet, extraction, rawSource, projection });
32
37
  const claimValue = projection.value ?? candidate.value;
33
38
  const createdAt = projection.createdAt ?? extraction.extractedAt;
34
- const updatedAt = projection.updatedAt ?? review?.reviewedAt ?? input.generatedAt;
39
+ const updatedAt = projection.updatedAt ?? projectionReview?.reviewedAt ?? input.generatedAt;
35
40
  const evidenceId = `${projection.id}.evidence.source`;
36
41
  const claim = {
37
42
  id: projection.id,
@@ -57,7 +62,15 @@ export function buildSurveyTrustBundle(input, options = {}) {
57
62
  },
58
63
  metadata: {
59
64
  ...projection.metadata,
60
- survey: buildSurveyMetadata({ projection, rawSource, extraction, candidateSet, candidate, review }),
65
+ survey: buildSurveyMetadata({
66
+ projection,
67
+ rawSource,
68
+ extraction,
69
+ candidateSet,
70
+ candidate,
71
+ review: projectionReview,
72
+ comfortZoneReview: review,
73
+ }),
61
74
  },
62
75
  };
63
76
  // Promote the review's comfort-zone signal — and, when calibration is
@@ -82,7 +95,7 @@ export function buildSurveyTrustBundle(input, options = {}) {
82
95
  }
83
96
  : undefined;
84
97
  const affirmedConclusion = status === "verified" || status === "assumed";
85
- const calibrated = calibrationMetrics && review && affirmedConclusion
98
+ const calibrated = calibrationMetrics && projectionReview && affirmedConclusion
86
99
  ? lookupCalibratedValue(calibrationMetrics, extraction.extractor, extraction.target, calibrationMinSamples)
87
100
  : undefined;
88
101
  if (comfortZone || calibrated) {
@@ -91,13 +104,16 @@ export function buildSurveyTrustBundle(input, options = {}) {
91
104
  ...(comfortZone ? { comfortZone } : {}),
92
105
  };
93
106
  }
94
- if (options.reviewProofs && review) {
107
+ // Could-not-confirm stays byte-quiet at the Surface boundary. In particular,
108
+ // do not attach an anchor whose public observedAt would disclose review time;
109
+ // callers can persist/recompute Survey's canonical v3 proof separately.
110
+ if (options.reviewProofs && projectionReview) {
95
111
  claim.currentIntegrityAnchor = buildReviewProofAnchor({
96
112
  rawSource,
97
113
  extraction,
98
114
  candidate,
99
115
  candidateSet,
100
- reviewOutcome: review,
116
+ reviewOutcome: projectionReview,
101
117
  claim: {
102
118
  ...projection,
103
119
  value: claimValue,
@@ -129,16 +145,16 @@ export function buildSurveyTrustBundle(input, options = {}) {
129
145
  confidence: candidate.confidence ?? extraction.confidence,
130
146
  },
131
147
  });
132
- const rationale = review?.rationale ?? candidateSet.rationale;
148
+ const rationale = projectionReview?.rationale ?? candidateSet.rationale;
133
149
  events.push({
134
150
  id: `${projection.id}.event.${status}`,
135
151
  claimId: projection.id,
136
152
  status,
137
- actor: projection.actor ?? review?.actor ?? projection.collectedBy,
153
+ actor: projection.actor ?? projectionReview?.actor ?? projection.collectedBy,
138
154
  method: projection.eventMethod ?? eventMethodFor(status, candidateSet),
139
- evidenceIds: review?.evidenceIds?.length ? review.evidenceIds : [evidenceId],
140
- createdAt: review?.reviewedAt ?? input.generatedAt,
141
- verifiedAt: status === "verified" || status === "assumed" ? review?.reviewedAt ?? input.generatedAt : undefined,
155
+ evidenceIds: projectionReview?.evidenceIds?.length ? projectionReview.evidenceIds : [evidenceId],
156
+ createdAt: projectionReview?.reviewedAt ?? input.generatedAt,
157
+ verifiedAt: status === "verified" || status === "assumed" ? projectionReview?.reviewedAt ?? input.generatedAt : undefined,
142
158
  notes: rationale,
143
159
  });
144
160
  }
@@ -357,11 +373,11 @@ function buildSurveyMetadata(input) {
357
373
  },
358
374
  }
359
375
  : {}),
360
- ...(input.review?.withinComfortZone === false
376
+ ...(input.comfortZoneReview?.withinComfortZone === false
361
377
  ? {
362
378
  comfortZone: {
363
379
  withinComfortZone: false,
364
- ...(input.review.comfortZoneNote ? { note: input.review.comfortZoneNote } : {}),
380
+ ...(input.comfortZoneReview.comfortZoneNote ? { note: input.comfortZoneReview.comfortZoneNote } : {}),
365
381
  },
366
382
  }
367
383
  : {}),
@@ -384,6 +400,7 @@ function assertProducerDiscipline(input) {
384
400
  subject: `Claim ${input.projection.id}`,
385
401
  status: input.status,
386
402
  review: input.review,
403
+ candidateSetStatus: input.candidateSet.status,
387
404
  });
388
405
  if (input.rawSource.kind !== "manual-entry" && !input.extraction.locator) {
389
406
  throw new Error(`Claim ${input.projection.id} needs a source locator for ${input.rawSource.kind}`);
@@ -49,6 +49,7 @@ export interface CandidateSet {
49
49
  metadata?: Record<string, unknown>;
50
50
  }
51
51
  export type ReviewStatus = Extract<TrustStatus, "verified" | "assumed" | "rejected" | "proposed">;
52
+ export type ReviewResolution = "accepted" | "rejected" | "held" | "could_not_confirm";
52
53
  export type ReviewAuthorizingKind = "explicit-statement" | "exchange" | "authorized-action";
53
54
  export interface ReviewAuthorizingExplicitStatement {
54
55
  kind: "explicit-statement";
@@ -74,6 +75,13 @@ export interface ReviewOutcome {
74
75
  candidateSetId: string;
75
76
  candidateId?: string;
76
77
  status: ReviewStatus;
78
+ /** Explicit terminal resolution for this review round. Absent outcomes retain
79
+ * the legacy status-inference behavior. */
80
+ resolution?: ReviewResolution;
81
+ /** Required, non-empty explanation when resolution is `could_not_confirm`. */
82
+ resolutionReason?: string;
83
+ /** Evidence of attempts made before the reviewer concluded they could not confirm. */
84
+ attemptEvidenceIds?: string[];
77
85
  actor?: string;
78
86
  reviewedAt?: string;
79
87
  rationale?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "1.15.0",
3
+ "version": "1.17.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",
@@ -87,6 +87,7 @@
87
87
  },
88
88
  "devDependencies": {
89
89
  "@anthropic-ai/sdk": "^0.54.0",
90
+ "@kontourai/traverse": "^0.19.0",
90
91
  "@kontourai/ui": "^1.1.0",
91
92
  "@playwright/test": "^1.60.0",
92
93
  "@types/node": "^25.6.0",