@kontourai/survey 2.2.4 → 2.4.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.
@@ -4,9 +4,12 @@ import { candidateForDecision, keepActionDecision, buildReviewSessionEvent, buil
4
4
  import { 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
- import { reviewResourceApiVersion, } from "../review-resource.js";
7
+ import { findSoleCandidateById, reviewResourceApiVersion, } from "../review-resource.js";
8
8
  import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
9
9
  import { humanizeIdentifier } from "./review-presentation.js";
10
+ import { createAuditFactTrace, } from "./audit-rows.js";
11
+ export { reviewAuditRowKeys } from "./audit-rows.js";
12
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
10
13
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
11
14
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
12
15
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
@@ -251,7 +254,7 @@ function buildReviewApplyActionContext(result, itemByName, issues) {
251
254
  };
252
255
  }
253
256
  function matchingSelectedCandidate(item, result) {
254
- const candidate = item.spec.candidates.find((entry) => entry.id === result.selectedCandidateId);
257
+ const candidate = findSoleCandidateById(item, result.selectedCandidateId);
255
258
  return candidate
256
259
  && candidate.role === result.selectedCandidateRole
257
260
  && structuralEqual(candidate.value, result.selectedValue)
@@ -660,6 +663,14 @@ function renderFieldCard(item, session, presentationAdapter) {
660
663
  </label>
661
664
  <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
662
665
  </div>
666
+ <!--
667
+ A control's precondition message belongs where the control is. The
668
+ reason "Could not confirm" needs lives in the collapsed audit
669
+ accordion, so writing the failure only into that textarea's validation
670
+ bubble put it somewhere the reviewer could not see at the moment they
671
+ clicked, and the button read as permanently dead (kontourai/survey#208).
672
+ -->
673
+ <span class="derr" data-testid="decision-error" role="alert" hidden></span>
663
674
  <div class="decided">
664
675
  <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue)}</span>
665
676
  <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
@@ -886,6 +897,10 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
886
897
  };
887
898
  const reviewDecisionPayload = buildReviewDecision(state);
888
899
  const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
900
+ // Seeded with what the card face already shows, so the audit surface never
901
+ // reprints it. Passed down through every section: the sections render in DOM
902
+ // order, so "first placement wins" is also "highest-context placement wins".
903
+ const trace = createAuditFactTrace(proposed ? [{ of: proposed.id, property: "locator.excerpt", value: proposed.locator?.excerpt }] : []);
889
904
  return `
890
905
  <details class="audit-details" data-testid="audit-details">
891
906
  <summary>Audit details</summary>
@@ -897,17 +912,17 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
897
912
  ${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
898
913
  ${renderProducerFeedbackTags(item)}
899
914
  <dl class="field-stack compact">
900
- ${current ? fieldItem("Current candidate ID", current.id) : ""}
901
- ${proposed ? fieldItem("Proposed candidate ID", proposed.id) : ""}
902
- ${proposed ? fieldItem("Claim ID", proposed.claimTarget.claimId ?? proposed.claimTarget.fieldOrBehavior) : ""}
903
- ${proposed ? fieldItem("Raw Source ID", proposed.source.sourceId ?? proposed.source.sourceRef) : ""}
904
- ${proposed?.locator ? fieldItem("Locator", proposed.locator.locator ?? proposed.locator.scheme) : ""}
905
- ${proposed?.extraction.model ? fieldItem("Model", proposed.extraction.model) : ""}
906
- ${proposed ? fieldItem("Extractor", proposed.extraction.extractor ?? "unknown") : ""}
907
- ${proposed ? fieldItem("Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
915
+ ${current ? fieldItem("current-candidate-id", "Current candidate ID", current.id) : ""}
916
+ ${proposed ? fieldItem("proposed-candidate-id", "Proposed candidate ID", proposed.id) : ""}
917
+ ${proposed ? fieldItem("claim-id", "Claim ID", proposed.claimTarget.claimId ?? proposed.claimTarget.fieldOrBehavior) : ""}
918
+ ${proposed ? placementItem(trace, { of: proposed.id, property: "source.sourceId" }, "raw-source-id", "Raw Source ID", proposed.source.sourceId ?? proposed.source.sourceRef) : ""}
919
+ ${proposed?.locator ? fieldItem("locator", "Locator", proposed.locator.locator ?? proposed.locator.scheme) : ""}
920
+ ${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
921
+ ${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
922
+ ${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
908
923
  </dl>
909
924
  ${preview
910
- ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
925
+ ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
911
926
  : "<p class=\"preview-disclaimer\">No decision recorded yet — pick an option above to preview the saved record.</p>"}
912
927
  ${reviewDecisionPayload
913
928
  ? `<details class="reference-details">
@@ -949,57 +964,83 @@ function renderFooterTally(session) {
949
964
  </footer>
950
965
  `;
951
966
  }
952
- function renderSurfacePreviewSections(preview) {
967
+ function renderSurfacePreviewSections(preview, trace) {
968
+ // Every row below describes the SELECTED candidate. When the reviewer kept the
969
+ // current value, that is a different record from the one the ID stack above
970
+ // describes, so none of these are repeat placements and all of them render —
971
+ // which is the point of keying on fact identity rather than on strings.
972
+ const selected = preview.canonicalClaim.candidateId;
953
973
  return [
954
974
  renderCandidateHistory(preview),
955
- renderSourceEvidence(preview),
975
+ renderSourceEvidence(preview, trace, selected),
956
976
  renderReviewEvent(preview),
957
- renderIntegrityPosture(preview),
977
+ renderIntegrityPosture(preview, trace, selected),
958
978
  renderAuthorityTrace(preview),
959
979
  ].join("");
960
980
  }
961
981
  function renderReviewEvent(preview) {
982
+ // A could-not-confirm resolution projects no review event; five rows reading
983
+ // "unknown / not recorded / pending / not provided" report an absence the
984
+ // decided chip on the card face already states (kontourai/survey#207).
985
+ if (!preview.reviewEvent) {
986
+ return "";
987
+ }
962
988
  return `
963
989
  <section class="preview-section" data-testid="surface-review-event">
964
990
  <h3>${escapeHtml("Review event")}</h3>
965
991
  <dl class="field-stack compact">
966
- ${fieldItem("Actor", preview.reviewEvent?.actor ?? "unknown")}
967
- ${fieldItem("Reviewed at", preview.reviewEvent?.reviewedAt ?? "not recorded")}
968
- ${fieldItem("Status", preview.reviewEvent?.status ?? "pending")}
969
- ${fieldItem("Rationale", preview.reviewEvent?.rationale ?? "No reviewer rationale provided.", "rationale-clamp")}
970
- ${fieldItem("Outcome", preview.reviewEvent?.reviewOutcomeId ?? "not provided")}
992
+ ${fieldItem("actor", "Actor", preview.reviewEvent.actor)}
993
+ ${fieldItem("reviewed-at", "Reviewed at", preview.reviewEvent.reviewedAt)}
994
+ ${fieldItem("status", "Status", preview.reviewEvent.status)}
995
+ ${fieldItem("rationale", "Rationale", preview.reviewEvent.rationale, "rationale-clamp")}
996
+ ${fieldItem("outcome", "Outcome", preview.reviewEvent.reviewOutcomeId)}
971
997
  </dl>
972
998
  </section>
973
999
  `;
974
1000
  }
975
- function renderIntegrityPosture(preview) {
1001
+ function renderIntegrityPosture(preview, trace, selected) {
976
1002
  return renderPreviewSection("Integrity posture", "surface-integrity-posture", [
977
- ["Checksum", preview.integrityPosture.checksum],
978
- ], undefined, [
979
- ["Candidate set ID", preview.integrityPosture.candidateSetId],
980
- ["Raw source ID", preview.integrityPosture.rawSourceId],
981
- ["Extraction ID", preview.integrityPosture.extractionId],
982
- ]);
1003
+ { key: "checksum", label: "Checksum", value: preview.integrityPosture.checksum },
1004
+ ], [
1005
+ { key: "candidate-set-id", label: "Candidate set ID", value: preview.integrityPosture.candidateSetId },
1006
+ { key: "raw-source-id", label: "Raw source ID", value: preview.integrityPosture.rawSourceId, fact: { of: selected, property: "source.sourceId" } },
1007
+ { key: "extraction-id", label: "Extraction ID", value: preview.integrityPosture.extractionId, fact: { of: selected, property: "extraction.extractionId" } },
1008
+ ], trace);
983
1009
  }
984
1010
  function renderAuthorityTrace(preview) {
1011
+ // An absent portable authority trace is the common case, and the two rows
1012
+ // that said so were the same sentence on every card of every queue. The
1013
+ // posture statement it repeated lives in the footer disclaimer; the data
1014
+ // stays on SurfaceProjectionPreview for hosts that project it.
1015
+ if (preview.authorityTrace.status === "empty") {
1016
+ return "";
1017
+ }
985
1018
  return renderPreviewSection("Authority trace", "surface-authority-trace", [
986
- ["Status", preview.authorityTrace.label],
987
- ["Detail", preview.authorityTrace.detail],
988
- ], preview.authorityTrace.status === "empty" ? " is-neutral" : "");
1019
+ { key: "authority-trace-status", label: "Status", value: preview.authorityTrace.label },
1020
+ { key: "authority-trace-detail", label: "Detail", value: preview.authorityTrace.detail },
1021
+ ]);
989
1022
  }
990
1023
  function renderCandidateHistory(preview) {
1024
+ // Nothing unselected is not history; the row that said so carried no fact.
991
1025
  if (preview.candidateHistory.length === 0) {
992
- return renderPreviewSection("Unselected candidate history", "surface-candidate-history", [["History", "No unselected candidates."]], undefined, []);
1026
+ return "";
993
1027
  }
994
1028
  const VISIBLE_COUNT = 3;
995
1029
  const total = preview.candidateHistory.length;
996
1030
  const visibleCandidates = preview.candidateHistory.slice(0, VISIBLE_COUNT);
997
1031
  const overflowCandidates = preview.candidateHistory.slice(VISIBLE_COUNT);
998
- const renderHistoryRows = (candidates) => candidates.flatMap((candidate) => [
999
- fieldItem("History", candidate.historyLabel),
1000
- fieldItem("Value", candidate.value),
1001
- ]).join("");
1002
- const referenceRows = preview.candidateHistory.map((candidate) => fieldItem("Candidate ID", candidate.candidateId)).join("");
1032
+ // `historyLabel` is the section's own heading, so a "History: Unselected
1033
+ // candidate history" row above every value restated the <h3> once per
1034
+ // candidate. The label stays on PreviewCandidateHistory for hosts that
1035
+ // project it without a heading of their own.
1036
+ const renderHistoryRows = (candidates) => candidates.map((candidate) => fieldItem("history-value", "Value", candidate.value)).join("");
1037
+ // Deliberately NOT deduplicated. With two candidates this repeats the ID
1038
+ // stack's "Current candidate ID", but it is the only row that names WHICH
1039
+ // unselected candidate a value belongs to, it stops being a repeat the moment
1040
+ // there are three, and suppressing it would make this section's disclosure
1041
+ // appear and disappear with the candidate count — structure a host would then
1042
+ // have to key on. Row-level suppression is what `data-audit-row` is for.
1043
+ const referenceRows = preview.candidateHistory.map((candidate) => fieldItem("candidate-id", "Candidate ID", candidate.candidateId)).join("");
1003
1044
  const overflowHtml = overflowCandidates.length > 0
1004
1045
  ? `<div class="history-overflow">${renderHistoryRows(overflowCandidates)}</div>`
1005
1046
  : "";
@@ -1019,53 +1060,75 @@ function renderCandidateHistory(preview) {
1019
1060
  </dl>
1020
1061
  ${expanderHtml}
1021
1062
  </div>
1022
- ${preview.candidateHistory.length > 0 ? `<details class="reference-details">
1063
+ <details class="reference-details">
1023
1064
  <summary>IDs and trace links</summary>
1024
1065
  <dl class="field-stack compact">${referenceRows}</dl>
1025
- </details>` : ""}
1066
+ </details>
1026
1067
  </section>
1027
1068
  `;
1028
1069
  }
1029
- function renderSourceEvidence(preview) {
1030
- const clampedExcerptHtml = fieldItemClamped("Excerpt", preview.sourceEvidence.excerpt, "excerpt");
1070
+ function renderSourceEvidence(preview, trace, selected) {
1071
+ // The excerpt is quoted on the card face for the PROPOSED candidate. A
1072
+ // decision that selected some other candidate has a different excerpt, and it
1073
+ // renders here.
1074
+ const clampedExcerptHtml = trace.isRepeatPlacement({ of: selected, property: "locator.excerpt" }, preview.sourceEvidence.excerpt)
1075
+ ? ""
1076
+ : fieldItemClamped("excerpt", "Excerpt", preview.sourceEvidence.excerpt, "excerpt");
1031
1077
  return `
1032
1078
  <section class="preview-section" data-testid="surface-source-evidence">
1033
1079
  <h3>${escapeHtml("Raw Source")}</h3>
1034
1080
  <dl class="field-stack compact">
1035
- ${fieldItem("Source Reference", preview.sourceEvidence.sourceRef)}
1081
+ ${fieldItem("source-reference", "Source Reference", preview.sourceEvidence.sourceRef)}
1036
1082
  ${clampedExcerptHtml}
1037
- ${fieldItem("Extractor", preview.sourceEvidence.extractor)}
1038
- ${fieldItem("Observed", preview.sourceEvidence.observedAt)}
1083
+ ${placementItem(trace, { of: selected, property: "extraction.extractor" }, "extractor", "Extractor", preview.sourceEvidence.extractor)}
1084
+ ${fieldItem("observed", "Observed", preview.sourceEvidence.observedAt)}
1039
1085
  ${preview.sourceEvidence.sourceAuthority ? [
1040
- fieldItem("Source authority class", preview.sourceEvidence.sourceAuthority.authorityClass),
1041
- fieldItem("Declared by", preview.sourceEvidence.sourceAuthority.declaredBy),
1042
- fieldItem("Authority scope", preview.sourceEvidence.sourceAuthority.scope),
1086
+ fieldItem("source-authority-class", "Source authority class", preview.sourceEvidence.sourceAuthority.authorityClass),
1087
+ fieldItem("declared-by", "Declared by", preview.sourceEvidence.sourceAuthority.declaredBy),
1088
+ fieldItem("authority-scope", "Authority scope", preview.sourceEvidence.sourceAuthority.scope),
1043
1089
  ].join("") : ""}
1044
1090
  </dl>
1045
1091
  ${renderReferenceDetails([
1046
- ["Raw Source ID", preview.sourceEvidence.sourceId],
1047
- ["Extraction ID", preview.sourceEvidence.extractionId],
1048
- ])}
1092
+ { key: "raw-source-id", label: "Raw Source ID", value: preview.sourceEvidence.sourceId, fact: { of: selected, property: "source.sourceId" } },
1093
+ { key: "extraction-id", label: "Extraction ID", value: preview.sourceEvidence.extractionId, fact: { of: selected, property: "extraction.extractionId" } },
1094
+ ], trace)}
1049
1095
  </section>
1050
1096
  `;
1051
1097
  }
1052
- function renderPreviewSection(title, testId, rows, extraClass = "", references = []) {
1098
+ function renderPreviewRows(rows, trace) {
1099
+ return rows
1100
+ .map((row) => (row.fact && trace
1101
+ ? placementItem(trace, row.fact, row.key, row.label, row.value)
1102
+ : fieldItem(row.key, row.label, row.value)))
1103
+ .join("");
1104
+ }
1105
+ function renderPreviewSection(title, testId, rows, references = [], trace) {
1053
1106
  return `
1054
- <section class="preview-section${extraClass}" data-testid="${testId}">
1107
+ <section class="preview-section" data-testid="${testId}">
1055
1108
  <h3>${escapeHtml(title)}</h3>
1056
1109
  <dl class="field-stack compact">
1057
- ${rows.map(([label, value]) => fieldItem(label, value)).join("")}
1110
+ ${renderPreviewRows(rows, trace)}
1058
1111
  </dl>
1059
- ${references.length > 0 ? renderReferenceDetails(references) : ""}
1112
+ ${references.length > 0 ? renderReferenceDetails(references, trace) : ""}
1060
1113
  </section>
1061
1114
  `;
1062
1115
  }
1063
- function renderReferenceDetails(references) {
1116
+ /**
1117
+ * The per-section "IDs and trace links" list.
1118
+ *
1119
+ * Every call site includes at least one reference that is a first placement and
1120
+ * so can never be suppressed — Raw Source keeps its extraction id, Integrity
1121
+ * posture its candidate set id, and the history section's candidate ids are
1122
+ * exempt outright. The disclosure therefore does not appear and disappear with
1123
+ * the data, which is what a host selecting on structure would otherwise be
1124
+ * exposed to. Keep that property when adding a reference list.
1125
+ */
1126
+ function renderReferenceDetails(references, trace) {
1064
1127
  return `
1065
1128
  <details class="reference-details">
1066
1129
  <summary>IDs and trace links</summary>
1067
1130
  <dl class="field-stack compact">
1068
- ${references.map(([label, value]) => fieldItem(label, value)).join("")}
1131
+ ${renderPreviewRows(references, trace)}
1069
1132
  </dl>
1070
1133
  </details>
1071
1134
  `;
@@ -1253,6 +1316,24 @@ function queueSessionFromStartState(startState) {
1253
1316
  actorId: startState.actorId,
1254
1317
  };
1255
1318
  }
1319
+ const COULD_NOT_CONFIRM_REASON_REQUIRED = "A reason is required when you could not confirm.";
1320
+ /**
1321
+ * Opens every collapsed region between `control` and the card before anything
1322
+ * tries to focus it or anchor a validation bubble on it.
1323
+ *
1324
+ * The browser will not show a validation message on, or move focus into, a
1325
+ * control inside a closed `<details>` — the call silently does nothing. Any
1326
+ * decision path that blocks on an input the reviewer cannot currently see must
1327
+ * go through here, or the control it guards reads as dead (kontourai/survey#208,
1328
+ * after #201 and #203 in the same family).
1329
+ */
1330
+ function revealControl(control) {
1331
+ let ancestor = control?.parentElement?.closest?.("details") ?? null;
1332
+ while (ancestor) {
1333
+ ancestor.open = true;
1334
+ ancestor = ancestor.parentElement?.closest?.("details") ?? null;
1335
+ }
1336
+ }
1256
1337
  function bindFieldCardInteractions(root, controller) {
1257
1338
  root.querySelectorAll("[data-testid='use-proposed']").forEach((button) => {
1258
1339
  button.addEventListener("click", () => {
@@ -1312,12 +1393,25 @@ function bindFieldCardInteractions(root, controller) {
1312
1393
  const note = controller.currentSession().notesByItemName[itemName]?.trim() ?? "";
1313
1394
  const field = button.closest("[data-testid='review-field']");
1314
1395
  const textarea = field?.querySelector("[data-testid='reviewer-note']");
1396
+ const errorEl = field?.querySelector("[data-testid='decision-error']");
1315
1397
  if (!note) {
1316
- textarea?.setCustomValidity?.("A reason is required when you could not confirm.");
1317
- textarea?.reportValidity?.();
1398
+ // Say it where the button is, then open the way to the input that
1399
+ // satisfies it. Focusing (and validating) a control inside a collapsed
1400
+ // <details> is a no-op the reviewer never sees (kontourai/survey#208).
1401
+ if (errorEl) {
1402
+ errorEl.textContent = COULD_NOT_CONFIRM_REASON_REQUIRED;
1403
+ errorEl.hidden = false;
1404
+ }
1405
+ textarea?.setCustomValidity?.(COULD_NOT_CONFIRM_REASON_REQUIRED);
1406
+ revealControl(textarea);
1318
1407
  textarea?.focus();
1408
+ textarea?.reportValidity?.();
1319
1409
  return;
1320
1410
  }
1411
+ if (errorEl) {
1412
+ errorEl.textContent = "";
1413
+ errorEl.hidden = true;
1414
+ }
1321
1415
  textarea?.setCustomValidity?.("");
1322
1416
  controller.setDecision(itemName, "could-not-confirm");
1323
1417
  controller.renderCurrentState();
@@ -1332,6 +1426,13 @@ function bindFieldCardInteractions(root, controller) {
1332
1426
  root.querySelectorAll("[data-testid='reviewer-note']").forEach((textarea) => {
1333
1427
  textarea.addEventListener("input", () => {
1334
1428
  textarea.setCustomValidity?.("");
1429
+ const decisionError = textarea
1430
+ .closest("[data-testid='review-field']")
1431
+ ?.querySelector("[data-testid='decision-error']");
1432
+ if (decisionError) {
1433
+ decisionError.textContent = "";
1434
+ decisionError.hidden = true;
1435
+ }
1335
1436
  const itemName = textarea.dataset.itemName ?? "";
1336
1437
  controller.updateReviewerNote(itemName, textarea.value);
1337
1438
  refreshAuditPayloadForItem(textarea, controller, itemName);
@@ -1403,9 +1504,9 @@ function bindHistoryExpanders(root) {
1403
1504
  });
1404
1505
  });
1405
1506
  }
1406
- function fieldItemClamped(label, value, extraClass = "") {
1507
+ function fieldItemClamped(key, label, value, extraClass = "") {
1407
1508
  return `
1408
- <div class="kv ${extraClass}">
1509
+ <div class="kv ${extraClass}" data-audit-row="${key}">
1409
1510
  <dt class="field-label">${escapeHtml(label)}</dt>
1410
1511
  <div class="excerpt-clamp" data-clamp>
1411
1512
  <dd class="field-value">${escapeHtml(value)}</dd>
@@ -1414,14 +1515,26 @@ function fieldItemClamped(label, value, extraClass = "") {
1414
1515
  </div>
1415
1516
  `;
1416
1517
  }
1417
- function fieldItem(label, value, extraClass = "") {
1518
+ function fieldItem(key, label, value, extraClass = "") {
1418
1519
  return `
1419
- <div class="kv ${extraClass}">
1520
+ <div class="kv ${extraClass}" data-audit-row="${key}">
1420
1521
  <dt class="field-label">${escapeHtml(label)}</dt>
1421
1522
  <dd class="field-value">${escapeHtml(value)}</dd>
1422
1523
  </div>
1423
1524
  `;
1424
1525
  }
1526
+ /**
1527
+ * A row for a fact the card renders in more than one place. Rendered only if
1528
+ * this card has not already printed the SAME property of the SAME record with
1529
+ * the same value; see {@link AuditFactTrace}.
1530
+ *
1531
+ * Only the four genuinely repeated placements go through here. Every other row
1532
+ * has exactly one home and is rendered with {@link fieldItem} directly, so no
1533
+ * row can ever be suppressed by a fact it has nothing to do with.
1534
+ */
1535
+ function placementItem(trace, fact, key, label, value, extraClass = "") {
1536
+ return trace.isRepeatPlacement(fact, value) ? "" : fieldItem(key, label, value, extraClass);
1537
+ }
1425
1538
  function escapeHtml(value) {
1426
1539
  return formatValue(value)
1427
1540
  .replaceAll("&", "&amp;")
@@ -48,11 +48,37 @@
48
48
  .inspector-pager button:disabled, .queue-pager button:disabled { opacity: .45; }
49
49
  .inspector-candidates { margin: 0; padding-left: 1.5rem; }
50
50
  .inspector-candidate { width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
51
- .inspector-source pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
51
+ .inspector-source pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; line-height: 2.2; }
52
52
  .inspector-source mark { background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
53
53
  .inspector-candidate:focus { outline: 3px solid var(--k-active); outline-offset: 2px; }
54
- .highlight-anchor { display: inline-block; width: 1px; height: 1em; }
55
- .highlight-anchor:focus { outline: 3px solid var(--k-active); }
54
+ /* An inert link target, one per candidate in the model, so a host's
55
+ `href="#<highlightElementId>"` always resolves. It is not a control and must
56
+ not behave like one: no size, no tab stop, nothing painted. */
57
+ .highlight-anchor {
58
+ display: inline;
59
+ width: 0;
60
+ height: 0;
61
+ overflow: hidden;
62
+ }
63
+
64
+ /* The highlight IS the return control — the only part of this surface a reader
65
+ can see, so it is the thing to aim at. Full phrase width, already visibly
66
+ marked, one tab stop per painted highlight. */
67
+ .source-highlight {
68
+ cursor: pointer;
69
+ border-radius: 2px;
70
+ /* Vertical padding on an inline box grows the hit area without moving the
71
+ line box, and the prepared text's line-height below leaves room for it, so
72
+ the target reaches ~24px tall without lines overlapping each other. The
73
+ phrase itself supplies the width. */
74
+ padding: 5px 3px;
75
+ margin: 0 -3px;
76
+ }
77
+
78
+ .source-highlight:focus-visible {
79
+ outline: 3px solid var(--k-active);
80
+ outline-offset: 1px;
81
+ }
56
82
  .source-unavailable { color: var(--k-negative); font-weight: 700; }
57
83
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
58
84
 
@@ -485,6 +511,22 @@ h1, h2, h3, p {
485
511
  display: none;
486
512
  }
487
513
 
514
+ /* Why a decision was refused, next to the button that refused it. The input
515
+ that satisfies the precondition can be inside the collapsed audit accordion,
516
+ so the message cannot live only with the input (kontourai/survey#208). */
517
+ .derr {
518
+ display: block;
519
+ margin-top: 6px;
520
+ text-align: right;
521
+ font-size: 12px;
522
+ font-weight: 600;
523
+ color: var(--k-negative);
524
+ }
525
+
526
+ .derr[hidden] {
527
+ display: none;
528
+ }
529
+
488
530
  /* confidence + provenance */
489
531
 
490
532
  .prov {
@@ -926,14 +968,6 @@ h1, h2, h3, p {
926
968
  text-transform: uppercase;
927
969
  }
928
970
 
929
- .preview-section.is-neutral {
930
- background: var(--k-raised);
931
- }
932
-
933
- .preview-section.is-neutral h3 {
934
- color: var(--k-faint);
935
- }
936
-
937
971
  .reference-details {
938
972
  margin-top: 8px;
939
973
  }
@@ -2,6 +2,7 @@ import { type DeriveReviewSessionApplyResultForSnapshotResult, type MapReviewWor
2
2
  import { type ReviewDecisionModeIssue } from "./producer-decision-mode.js";
3
3
  import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
4
4
  import type { ReviewDecision, ReviewSessionEvent } from "../review-resource.js";
5
+ import { type ReviewQueueBinding } from "./queue-binding.js";
5
6
  import { type ReviewQueueSessionState } from "./review-queue-session.js";
6
7
  export interface ServerReviewSessionRecord {
7
8
  readonly sessionName: string;
@@ -56,6 +57,19 @@ export interface DeriveServerReviewSessionApplyResultOptions {
56
57
  readonly currentSnapshot?: ReviewQueueSessionState;
57
58
  readonly currentEventCount?: number;
58
59
  readonly requiredResolvedItems?: ReviewSessionApplyResolutionRequirement;
60
+ /**
61
+ * The queue binding taken when this session opened (see
62
+ * ./queue-binding.ts). When present, the apply derivation refuses unless the
63
+ * record's snapshot — and the caller's current snapshot, when supplied —
64
+ * still matches the bound bytes and the bound item set, both directions.
65
+ *
66
+ * This is the check the record's own `snapshotHash` cannot provide: a record
67
+ * rebuilt from a mutated snapshot carries a hash of the mutated bytes and
68
+ * agrees with itself. The binding's authority is that it was written earlier,
69
+ * at queue construction, and carried unchanged — so it MUST come from the
70
+ * consumer's storage, not be recomputed at call time.
71
+ */
72
+ readonly binding?: ReviewQueueBinding;
59
73
  }
60
74
  export declare function createServerReviewSessionRecord(options: CreateServerReviewSessionRecordOptions): ServerReviewSessionRecord;
61
75
  export declare function hashReviewSessionSnapshot(snapshot: ReviewQueueSessionState): string;
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { deriveReviewSessionApplyResultForSnapshot, mapReviewWorkbenchResultsToApplyActions, ReviewApplyActionMappingError, } from "./review-workbench.js";
3
3
  import { validateReviewDecisionMode, } from "./producer-decision-mode.js";
4
4
  import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
5
+ import { assertReviewQueueBinding } from "./queue-binding.js";
5
6
  import { replayReviewSessionEvents } from "./review-queue-session.js";
6
7
  import { canonicalJson } from "./canonical.js";
7
8
  export class StaleServerReviewSessionError extends Error {
@@ -92,6 +93,12 @@ export function assertServerReviewSessionEvents(record, events) {
92
93
  }
93
94
  }
94
95
  export function deriveServerReviewSessionApplyResult(options) {
96
+ if (options.binding) {
97
+ assertReviewQueueBinding(options.binding, options.record.snapshot, { sessionName: options.record.sessionName });
98
+ if (options.currentSnapshot) {
99
+ assertReviewQueueBinding(options.binding, options.currentSnapshot, { sessionName: options.record.sessionName });
100
+ }
101
+ }
95
102
  assertServerReviewSessionFreshness(options.record, options.record.snapshot);
96
103
  if (options.currentSnapshot) {
97
104
  assertServerReviewSessionFreshness(options.record, options.currentSnapshot, options.currentEventCount);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "2.2.4",
3
+ "version": "2.4.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",
@@ -55,6 +55,7 @@
55
55
  "verify": "npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
56
56
  "check:content-boundary": "node --test tests/content-boundary-script.test.cjs && node scripts/check-content-boundary.cjs",
57
57
  "check:decisions": "node scripts/check-decisions.cjs check",
58
+ "check:guards": "node scripts/check-guards.mjs",
58
59
  "gen:decisions-index": "node scripts/check-decisions.cjs gen-index",
59
60
  "freeze:adrs": "node scripts/freeze-adrs.mjs",
60
61
  "sync:review-workbench-assets": "node scripts/sync-review-workbench-assets.cjs",