@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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Queue-binding attestation: a decision is only projectable against the exact
3
+ * queue bytes it was recorded against.
4
+ *
5
+ * The binding is a small, serializable record created ONCE, when a review
6
+ * round/session opens, and carried unchanged by every later write. Validation
7
+ * compares a presented queue against that stored record. The division of labor
8
+ * is deliberate and is the entire security property:
9
+ *
10
+ * - **The consumer persists the binding beside the queue and never recomputes
11
+ * it.** A digest a writer recomputes as it saves attests nothing — both sides
12
+ * of the comparison then regenerate from the same mutable bytes and agree by
13
+ * construction. That tautology shipped in a real consumer and let a
14
+ * post-decision edit export a substituted value (kontourai/fieldwork#60).
15
+ * - **Survey owns the rule and the refusal.** What a queue edit looks like, in
16
+ * both directions, and when a comparison is vacuous, is decided here so no
17
+ * consumer rediscovers it four adversarial review rounds at a time
18
+ * (kontourai/survey#213).
19
+ *
20
+ * The binding alone cannot survive a writer who edits the queue AND re-binds —
21
+ * hashing mutated bytes yields a self-consistent pair. For queues imported from
22
+ * an extraction envelope, {@link validateReviewQueueAgainstExtractionImport}
23
+ * narrows that hole to the record: it re-derives the canonical items from the
24
+ * presented import record and refuses a queue that diverges from it in either
25
+ * direction, so a queue edited independently of its record fails. What it
26
+ * attests is queue-to-record CONSISTENCY, not record integrity. The portable
27
+ * envelope carries the prepared artifact's digest and contentLength, never the
28
+ * prepared bytes, so a library handed only the record cannot verify proposal
29
+ * bytes against the digested artifact — a writer who edits the RECORD's
30
+ * proposals and re-derives the queue from the edited record presents a
31
+ * self-consistent pair this module blesses (pinned as a boundary test and by
32
+ * check:guards). Keeping the stored record equal to the record originally
33
+ * imported is the caller's storage obligation, and validating prepared bytes
34
+ * against `result.preparedArtifact.digest` does NOT discharge it: that digest
35
+ * covers the prepared artifact only, so it protects artifact integrity and
36
+ * stays green through a proposal rewrite. Preserving record integrity takes
37
+ * one of: a record digest/MAC anchored where the record's writer cannot
38
+ * reach, immutable or authenticated record storage, or independently
39
+ * re-deriving the proposals from trusted prepared bytes and comparing.
40
+ *
41
+ * Four bypasses from fieldwork#60's rounds, each a design input here:
42
+ * 1. Self-agreement (above) — the binding's origin is the open, not the save.
43
+ * 2. One-way set checks — validation walks BOTH directions, because checking
44
+ * only the items present can never notice one was removed.
45
+ * 3. Trusting a mutable label over the identity it claims — out of scope here
46
+ * by construction: nothing in this module dispatches on a label; recheck
47
+ * semantics stay with the consumer that owns them.
48
+ * 4. Vacuous success — an empty queue cannot be bound and never validates,
49
+ * because a receipt over nothing certifies nothing.
50
+ */
51
+ import { canonicalJson } from "./canonical.js";
52
+ import { sha256Hex } from "../sha256.js";
53
+ import { reviewResourceApiVersion } from "../review-resource.js";
54
+ import { buildReviewItemsFromExtractionEnvelopeImport, validateExtractionEnvelopeImport, } from "../extraction-envelope.js";
55
+ export class UnattestedReviewQueueError extends Error {
56
+ name = "UnattestedReviewQueueError";
57
+ issues;
58
+ constructor(issues) {
59
+ super(`Review queue is not attested by its binding: ${issues.map((issue) => issue.message).join(" ")}`);
60
+ this.issues = issues;
61
+ }
62
+ }
63
+ /**
64
+ * The digest a binding stores: sha256 over the canonical JSON of the whole
65
+ * open-time session state. Byte-identical to server-review-session's
66
+ * `hashReviewSessionSnapshot` (pinned by test), so a consumer already
67
+ * persisting that digest adopts the binding without invalidating stored state.
68
+ */
69
+ export function hashReviewQueueSnapshot(snapshot) {
70
+ return sha256Hex(canonicalJson(snapshot));
71
+ }
72
+ /**
73
+ * Take the binding for a queue, once, when the round/session opens.
74
+ *
75
+ * Call this at queue construction and persist the result beside the queue.
76
+ * Calling it again later, on bytes that may have changed, produces a binding
77
+ * that agrees with whatever it was given — which is the self-agreement bypass,
78
+ * not an attestation.
79
+ *
80
+ * Refuses an empty queue: a binding over nothing validates nothing, and every
81
+ * later check against it would be vacuously green. Refuses duplicate item
82
+ * names: the binding's set comparison is by name, so an ambiguous name would
83
+ * let two different items answer for one membership.
84
+ */
85
+ export function bindReviewQueue(snapshot, options) {
86
+ if (!options.sessionName) {
87
+ throw new Error("bindReviewQueue requires a non-empty sessionName.");
88
+ }
89
+ if (snapshot.items.length === 0) {
90
+ throw new Error("bindReviewQueue refuses an empty queue: a binding over nothing attests nothing.");
91
+ }
92
+ const names = snapshot.items.map((item) => item.metadata.name);
93
+ const unique = new Set(names);
94
+ if (unique.size !== names.length) {
95
+ const duplicate = names.find((name, index) => names.indexOf(name) !== index);
96
+ throw new Error(`bindReviewQueue refuses a queue with duplicate ReviewItem name ${duplicate}: set membership by name must be unambiguous.`);
97
+ }
98
+ return {
99
+ apiVersion: reviewResourceApiVersion,
100
+ kind: "ReviewQueueBinding",
101
+ spec: {
102
+ sessionName: options.sessionName,
103
+ snapshotHash: hashReviewQueueSnapshot(snapshot),
104
+ itemNames: [...unique].sort(),
105
+ boundAt: options.boundAt instanceof Date ? options.boundAt.toISOString() : options.boundAt ?? new Date().toISOString(),
106
+ },
107
+ };
108
+ }
109
+ /**
110
+ * Compare a presented queue against its stored binding.
111
+ *
112
+ * `binding` must be the record persisted when the round opened — passing one
113
+ * derived from `snapshot` here checks nothing (see module doc). `snapshot` is
114
+ * the base queue the binding was taken over, not the state after event replay:
115
+ * decisions live in the event log precisely so the bound bytes never move.
116
+ *
117
+ * A malformed binding fails closed with `binding-malformed` rather than
118
+ * skipping the checks it cannot perform.
119
+ */
120
+ export function validateReviewQueueBinding(binding, snapshot, options = {}) {
121
+ const structural = structuralBindingIssues(binding);
122
+ if (structural.length > 0) {
123
+ return structural;
124
+ }
125
+ const issues = [];
126
+ if (options.sessionName !== undefined && binding.spec.sessionName !== options.sessionName) {
127
+ issues.push({
128
+ code: "session-name-mismatch",
129
+ message: `Binding names session ${binding.spec.sessionName}, but this queue belongs to session ${options.sessionName}.`,
130
+ });
131
+ }
132
+ if (snapshot.items.length === 0) {
133
+ issues.push({
134
+ code: "empty-queue",
135
+ message: "The presented queue is empty. The binding covers items this queue no longer carries, and an empty queue attests nothing.",
136
+ });
137
+ }
138
+ const presentNames = snapshot.items.map((item) => item.metadata.name);
139
+ const present = new Set(presentNames);
140
+ if (present.size !== presentNames.length) {
141
+ const duplicate = presentNames.find((name, index) => presentNames.indexOf(name) !== index);
142
+ issues.push({
143
+ code: "ambiguous-item-identity",
144
+ message: `The presented queue carries duplicate ReviewItem name ${duplicate}; set membership by name is ambiguous.`,
145
+ itemName: duplicate,
146
+ });
147
+ }
148
+ // Set equality in BOTH directions. Walking only the items present can never
149
+ // notice an item was removed; walking only the bound names can never notice
150
+ // one was added. Each direction is a different edit.
151
+ const bound = new Set(binding.spec.itemNames);
152
+ for (const name of binding.spec.itemNames) {
153
+ if (!present.has(name)) {
154
+ issues.push({
155
+ code: "item-removed",
156
+ message: `ReviewItem ${name} is covered by the binding but missing from the presented queue.`,
157
+ itemName: name,
158
+ });
159
+ }
160
+ }
161
+ for (const name of present) {
162
+ if (!bound.has(name)) {
163
+ issues.push({
164
+ code: "item-added",
165
+ message: `ReviewItem ${name} is in the presented queue but not covered by the binding.`,
166
+ itemName: name,
167
+ });
168
+ }
169
+ }
170
+ const actualHash = hashReviewQueueSnapshot(snapshot);
171
+ if (binding.spec.snapshotHash !== actualHash) {
172
+ issues.push({
173
+ code: "snapshot-hash-mismatch",
174
+ message: `The presented queue's bytes do not match the binding (expected ${binding.spec.snapshotHash}, got ${actualHash}). The queue changed after the round opened.`,
175
+ });
176
+ }
177
+ return issues;
178
+ }
179
+ export function assertReviewQueueBinding(binding, snapshot, options = {}) {
180
+ const issues = validateReviewQueueBinding(binding, snapshot, options);
181
+ if (issues.length > 0) {
182
+ throw new UnattestedReviewQueueError(issues);
183
+ }
184
+ }
185
+ export class UnattestedExtractionQueueError extends Error {
186
+ name = "UnattestedExtractionQueueError";
187
+ issues;
188
+ constructor(issues) {
189
+ super(`Review queue is not attested by its extraction import: ${issues.map((issue) => issue.message).join(" ")}`);
190
+ this.issues = issues;
191
+ }
192
+ }
193
+ /**
194
+ * Check a stored queue for consistency with the extraction import record it
195
+ * was derived from.
196
+ *
197
+ * The queue binding alone cannot catch a writer who edits the queue and
198
+ * re-binds: hashing mutated bytes yields a self-consistent pair. This check
199
+ * closes the QUEUE half of that hole: it revalidates the presented record
200
+ * through the public import boundary (a forged `grounded` status throws; an
201
+ * ungrounded import is refused), re-derives the canonical ReviewItems from it,
202
+ * and requires the stored queue to be the SAME SET, byte-identically per item,
203
+ * in both directions. A queue edited independently of its record fails.
204
+ *
205
+ * What it does NOT attest: the record itself. The envelope carries the
206
+ * prepared artifact's digest and contentLength, never the prepared bytes, so
207
+ * a library handed only the record cannot verify a proposal's bytes against
208
+ * the digested artifact. A writer who edits the record's proposals and
209
+ * re-derives the queue from the edited record presents a pair this check
210
+ * blesses, while `result.preparedArtifact.digest` still names the honest
211
+ * bytes. Record integrity is therefore the caller's storage obligation, and
212
+ * checking prepared bytes against that digest does not meet it — the digest
213
+ * covers the artifact, not the proposals, so it stays green through the
214
+ * rewrite. Meeting it takes one of: a record digest/MAC anchored where the
215
+ * record's writer cannot reach, immutable or authenticated record storage,
216
+ * or independently re-deriving the proposals from trusted prepared bytes and
217
+ * comparing. This limit is pinned by a boundary test and by
218
+ * scripts/check-guards.mjs.
219
+ *
220
+ * This is the whole-extraction rule: it applies to a queue whose items all come
221
+ * from one import. A consumer whose rounds mix in items the extraction cannot
222
+ * attest (recheck rounds against a prior observation, for one) owns that
223
+ * dispatch and those semantics — deciding which attestation applies to which
224
+ * item from a mutable label is bypass 3, and it stays with the data that can
225
+ * cross-check the label.
226
+ */
227
+ export function validateReviewQueueAgainstExtractionImport(items, importResult) {
228
+ const record = validateExtractionEnvelopeImport(importResult.record);
229
+ if (record.status.state !== "grounded") {
230
+ return [{
231
+ code: "import-not-grounded",
232
+ message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded; it cannot attest a review queue.`,
233
+ }];
234
+ }
235
+ if (items.length === 0) {
236
+ return [{
237
+ code: "empty-queue",
238
+ message: "The stored queue is empty; there is nothing it can certify against this extraction.",
239
+ }];
240
+ }
241
+ // Re-derived through the public import boundary, not read from the caller's
242
+ // importResult.reviewItems: the record is validated above, and the canonical
243
+ // items are a pure function of it, so the comparison is against what the
244
+ // presented record actually says — not against a reviewItems array the
245
+ // caller could have edited separately from it. The record itself is
246
+ // caller-supplied and is NOT attested here; see the function doc.
247
+ const attesting = new Map(buildReviewItemsFromExtractionEnvelopeImport(record).map((item) => [item.metadata.name, item]));
248
+ const stored = new Map(items.map((item) => [item.metadata.name, item]));
249
+ const issues = [];
250
+ for (const [name, item] of attesting) {
251
+ const found = stored.get(name);
252
+ if (!found) {
253
+ issues.push({
254
+ code: "item-missing-from-queue",
255
+ message: `ReviewItem ${name} is in the extraction but missing from the stored queue.`,
256
+ itemName: name,
257
+ });
258
+ continue;
259
+ }
260
+ if (canonicalJson(found) !== canonicalJson(item)) {
261
+ issues.push({
262
+ code: "item-diverges-from-extraction",
263
+ message: `ReviewItem ${name} does not match the extraction it was imported from.`,
264
+ itemName: name,
265
+ });
266
+ }
267
+ }
268
+ for (const name of stored.keys()) {
269
+ if (!attesting.has(name)) {
270
+ issues.push({
271
+ code: "item-not-in-extraction",
272
+ message: `ReviewItem ${name} is in the stored queue but not in the extraction.`,
273
+ itemName: name,
274
+ });
275
+ }
276
+ }
277
+ return issues;
278
+ }
279
+ export function assertReviewQueueAgainstExtractionImport(items, importResult) {
280
+ const issues = validateReviewQueueAgainstExtractionImport(items, importResult);
281
+ if (issues.length > 0) {
282
+ throw new UnattestedExtractionQueueError(issues);
283
+ }
284
+ }
285
+ const SHA256_HEX = /^[a-f0-9]{64}$/;
286
+ function structuralBindingIssues(binding) {
287
+ const malformed = (message) => [{
288
+ code: "binding-malformed",
289
+ message: `Malformed ReviewQueueBinding: ${message}`,
290
+ }];
291
+ if (typeof binding !== "object" || binding === null || Array.isArray(binding)) {
292
+ return malformed("not an object.");
293
+ }
294
+ if (binding.apiVersion !== reviewResourceApiVersion || binding.kind !== "ReviewQueueBinding") {
295
+ return malformed(`unexpected identity ${String(binding.apiVersion)}/${String(binding.kind)}.`);
296
+ }
297
+ const spec = binding.spec;
298
+ if (typeof spec !== "object" || spec === null) {
299
+ return malformed("missing spec.");
300
+ }
301
+ if (typeof spec.sessionName !== "string" || spec.sessionName.length === 0) {
302
+ return malformed("sessionName must be a non-empty string.");
303
+ }
304
+ if (typeof spec.snapshotHash !== "string" || !SHA256_HEX.test(spec.snapshotHash)) {
305
+ return malformed("snapshotHash must be a 64-character lowercase hex sha256.");
306
+ }
307
+ if (typeof spec.boundAt !== "string" || spec.boundAt.length === 0) {
308
+ return malformed("boundAt must be a non-empty ISO timestamp string.");
309
+ }
310
+ if (!Array.isArray(spec.itemNames) || spec.itemNames.length === 0) {
311
+ return malformed("itemNames must be a non-empty array: a binding over nothing attests nothing.");
312
+ }
313
+ for (const name of spec.itemNames) {
314
+ if (typeof name !== "string" || name.length === 0) {
315
+ return malformed("itemNames must contain only non-empty strings.");
316
+ }
317
+ }
318
+ const sorted = [...spec.itemNames].sort();
319
+ if (new Set(spec.itemNames).size !== spec.itemNames.length
320
+ || spec.itemNames.some((name, index) => name !== sorted[index])) {
321
+ return malformed("itemNames must be unique and sorted.");
322
+ }
323
+ return [];
324
+ }
@@ -1,3 +1,4 @@
1
+ import { findSoleCandidateById } from "../review-resource.js";
1
2
  import { formatValue } from "./review-surface-preview.js";
2
3
  export function buildReviewItemPresentation(item, adapter = {}) {
3
4
  const context = { item };
@@ -34,7 +35,7 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
34
35
  const targetLabel = item && itemContext
35
36
  ? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
36
37
  : humanizeIdentifier(target);
37
- const selectedCandidate = item?.spec.candidates.find((candidate) => candidate.role === result.selectedCandidateRole || candidate.id === result.selectedCandidateId);
38
+ const selectedCandidate = item ? selectedCandidateForResult(item, result) : undefined;
38
39
  return {
39
40
  result,
40
41
  item,
@@ -53,6 +54,32 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
53
54
  : [{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" }],
54
55
  };
55
56
  }
57
+ /**
58
+ * The candidate a result selected, resolved by its complete identity.
59
+ *
60
+ * `find(role === … || id === …)` returned whichever candidate matched EITHER
61
+ * half, so on an item carrying a repeated candidate id it could return a
62
+ * different candidate than the result names — presenting one candidate's value
63
+ * against another's decision, which is exactly what it did.
64
+ *
65
+ * The id is the identity; {@link findSoleCandidateById} makes it fail closed
66
+ * rather than pick a winner when it is ambiguous, and the declared role has to
67
+ * agree when the result states one. Falling back to the role alone is kept for
68
+ * results that carry no id, and requires the role to be unambiguous too.
69
+ */
70
+ function selectedCandidateForResult(item, result) {
71
+ if (result.selectedCandidateId) {
72
+ const candidate = findSoleCandidateById(item, result.selectedCandidateId);
73
+ if (candidate && (result.selectedCandidateRole === undefined || candidate.role === result.selectedCandidateRole)) {
74
+ return candidate;
75
+ }
76
+ }
77
+ if (result.selectedCandidateRole === undefined) {
78
+ return undefined;
79
+ }
80
+ const byRole = item.spec.candidates.filter((candidate) => candidate.role === result.selectedCandidateRole);
81
+ return byRole.length === 1 ? byRole[0] : undefined;
82
+ }
56
83
  export function humanizeIdentifier(value) {
57
84
  return value
58
85
  .replace(/[_-]+/g, " ")
@@ -91,6 +91,17 @@ export declare function reviewSessionSummary(session: ReviewQueueSessionState):
91
91
  * would invent a prior value, with provenance, that the source never had.
92
92
  */
93
93
  export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boolean): ReviewWorkbenchDecision | undefined;
94
+ /**
95
+ * The candidate a workbench decision applies to.
96
+ *
97
+ * Selection is by role, but the id this returns is what every caller makes
98
+ * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
99
+ * replay expectation. So the id has to name exactly one candidate before it
100
+ * leaves here. Guarding the render path alone let the workbench emit an
101
+ * undecidable decision through the export path and then present a different
102
+ * candidate's value against it; this is the shared selector all of those go
103
+ * through, which is why the check belongs here rather than at each of them.
104
+ */
94
105
  export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
95
106
  /**
96
107
  * The value that should actually be applied for a decision: the reviewer's inline
@@ -1,6 +1,6 @@
1
1
  import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
- import { reviewResourceApiVersion, } from "../../src/review-resource.js";
3
+ import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
4
4
  export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
5
5
  export const defaultReviewSessionName = "review-workbench-session";
6
6
  export const workbenchDecisionDefinitions = {
@@ -148,12 +148,24 @@ export function keepActionDecision(item, flaggedWrong) {
148
148
  }
149
149
  return "keep-current";
150
150
  }
151
+ /**
152
+ * The candidate a workbench decision applies to.
153
+ *
154
+ * Selection is by role, but the id this returns is what every caller makes
155
+ * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
156
+ * replay expectation. So the id has to name exactly one candidate before it
157
+ * leaves here. Guarding the render path alone let the workbench emit an
158
+ * undecidable decision through the export path and then present a different
159
+ * candidate's value against it; this is the shared selector all of those go
160
+ * through, which is why the check belongs here rather than at each of them.
161
+ */
151
162
  export function candidateForDecision(item, decision) {
152
163
  const definition = workbenchDecisionDefinitions[decision];
153
164
  const candidate = item.spec.candidates.find((entry) => entry.role === definition.candidateRole);
154
165
  if (!candidate) {
155
166
  throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
156
167
  }
168
+ assertSoleCandidateId(item, candidate.id);
157
169
  return candidate;
158
170
  }
159
171
  /**
@@ -1,3 +1,4 @@
1
+ import { findSoleCandidateById } from "../review-resource.js";
1
2
  export function buildSurfaceProjectionPreview(item, decision, presentationAdapter = {}) {
2
3
  const selectedCandidate = selectedPreviewCandidate(item, decision);
3
4
  if (!selectedCandidate || !decision) {
@@ -38,7 +39,7 @@ function selectedPreviewCandidate(item, decision) {
38
39
  if (!decision?.spec.candidateId) {
39
40
  return undefined;
40
41
  }
41
- const candidate = item.spec.candidates.find((entry) => entry.id === decision.spec.candidateId);
42
+ const candidate = findSoleCandidateById(item, decision.spec.candidateId);
42
43
  if (!candidate) {
43
44
  throw new Error(`ReviewItem ${item.metadata.name} has no candidate ${decision.spec.candidateId}.`);
44
45
  }
@@ -202,11 +202,37 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
202
202
  .survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
203
203
  .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
204
204
  .survey-workbench-embed .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); }
205
- .survey-workbench-embed .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; }
205
+ .survey-workbench-embed .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; }
206
206
  .survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
207
207
  .survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
208
- .survey-workbench-embed .highlight-anchor{ display: inline-block; width: 1px; height: 1em; }
209
- .survey-workbench-embed .highlight-anchor:focus{ outline: 3px solid var(--k-active); }
208
+ /* An inert link target, one per candidate in the model, so a host's
209
+ \`href="#<highlightElementId>"\` always resolves. It is not a control and must
210
+ not behave like one: no size, no tab stop, nothing painted. */
211
+ .survey-workbench-embed .highlight-anchor{
212
+ display: inline;
213
+ width: 0;
214
+ height: 0;
215
+ overflow: hidden;
216
+ }
217
+
218
+ /* The highlight IS the return control — the only part of this surface a reader
219
+ can see, so it is the thing to aim at. Full phrase width, already visibly
220
+ marked, one tab stop per painted highlight. */
221
+ .survey-workbench-embed .source-highlight{
222
+ cursor: pointer;
223
+ border-radius: 2px;
224
+ /* Vertical padding on an inline box grows the hit area without moving the
225
+ line box, and the prepared text's line-height below leaves room for it, so
226
+ the target reaches ~24px tall without lines overlapping each other. The
227
+ phrase itself supplies the width. */
228
+ padding: 5px 3px;
229
+ margin: 0 -3px;
230
+ }
231
+
232
+ .survey-workbench-embed .source-highlight:focus-visible{
233
+ outline: 3px solid var(--k-active);
234
+ outline-offset: 1px;
235
+ }
210
236
  .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
211
237
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
212
238
 
@@ -639,6 +665,22 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
639
665
  display: none;
640
666
  }
641
667
 
668
+ /* Why a decision was refused, next to the button that refused it. The input
669
+ that satisfies the precondition can be inside the collapsed audit accordion,
670
+ so the message cannot live only with the input (kontourai/survey#208). */
671
+ .survey-workbench-embed .derr{
672
+ display: block;
673
+ margin-top: 6px;
674
+ text-align: right;
675
+ font-size: 12px;
676
+ font-weight: 600;
677
+ color: var(--k-negative);
678
+ }
679
+
680
+ .survey-workbench-embed .derr[hidden]{
681
+ display: none;
682
+ }
683
+
642
684
  /* confidence + provenance */
643
685
 
644
686
  .survey-workbench-embed .prov{
@@ -1080,14 +1122,6 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
1080
1122
  text-transform: uppercase;
1081
1123
  }
1082
1124
 
1083
- .survey-workbench-embed .preview-section.is-neutral{
1084
- background: var(--k-raised);
1085
- }
1086
-
1087
- .survey-workbench-embed .preview-section.is-neutral h3{
1088
- color: var(--k-faint);
1089
- }
1090
-
1091
1125
  .survey-workbench-embed .reference-details{
1092
1126
  margin-top: 8px;
1093
1127
  }
@@ -298,7 +298,30 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
298
298
  if (this.#session.activeItemName !== item.metadata.name)
299
299
  this.#session = { ...this.#session, activeItemName: item.metadata.name };
300
300
  this.#remount();
301
- queueMicrotask(() => this.#root.querySelector(`#highlight-${CSS.escape(detail.candidateId.replace(/[^a-zA-Z0-9_-]/g, "-"))}`)?.focus());
301
+ // Both lookups below are published contracts, used as published.
302
+ // Reconstructing the element id from candidateId — which this did — meant
303
+ // carrying a copy of Survey's private id sanitizer and ignoring the
304
+ // collision suffix that makes the id unique, which is exactly what the
305
+ // consumer guide tells embedders not to do.
306
+ const { highlightElementId } = detail;
307
+ if (!highlightElementId)
308
+ return;
309
+ queueMicrotask(() => {
310
+ // Routed on the highlight element id, which is unique by construction.
311
+ // candidateId is the candidate's own identity and a caller-authored
312
+ // model may repeat it, so a `[data-…="<candidateId>"]` lookup can select
313
+ // a different candidate's highlight — confidently wrong, on the surface
314
+ // whose job is showing which span a value came from. `~=` matches one
315
+ // whitespace-separated token, so a mark over a shared span is found too.
316
+ const highlight = this.#root.querySelector(`[data-highlight-return-to~="${CSS.escape(highlightElementId)}"]`);
317
+ if (highlight) {
318
+ highlight.focus();
319
+ return;
320
+ }
321
+ // Off the current page there is nothing painted; bring the link target
322
+ // into view instead of leaving the reader where they were.
323
+ this.#root.querySelector(`#${CSS.escape(highlightElementId)}`)?.scrollIntoView({ block: "center" });
324
+ });
302
325
  });
303
326
  }
304
327
  /** The review queue session to display. Setting this property re-mounts the workbench. */
@@ -199,11 +199,37 @@
199
199
  .survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
200
200
  .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
201
201
  .survey-workbench-embed .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); }
202
- .survey-workbench-embed .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; }
202
+ .survey-workbench-embed .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; }
203
203
  .survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
204
204
  .survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
205
- .survey-workbench-embed .highlight-anchor{ display: inline-block; width: 1px; height: 1em; }
206
- .survey-workbench-embed .highlight-anchor:focus{ outline: 3px solid var(--k-active); }
205
+ /* An inert link target, one per candidate in the model, so a host's
206
+ `href="#<highlightElementId>"` always resolves. It is not a control and must
207
+ not behave like one: no size, no tab stop, nothing painted. */
208
+ .survey-workbench-embed .highlight-anchor{
209
+ display: inline;
210
+ width: 0;
211
+ height: 0;
212
+ overflow: hidden;
213
+ }
214
+
215
+ /* The highlight IS the return control — the only part of this surface a reader
216
+ can see, so it is the thing to aim at. Full phrase width, already visibly
217
+ marked, one tab stop per painted highlight. */
218
+ .survey-workbench-embed .source-highlight{
219
+ cursor: pointer;
220
+ border-radius: 2px;
221
+ /* Vertical padding on an inline box grows the hit area without moving the
222
+ line box, and the prepared text's line-height below leaves room for it, so
223
+ the target reaches ~24px tall without lines overlapping each other. The
224
+ phrase itself supplies the width. */
225
+ padding: 5px 3px;
226
+ margin: 0 -3px;
227
+ }
228
+
229
+ .survey-workbench-embed .source-highlight:focus-visible{
230
+ outline: 3px solid var(--k-active);
231
+ outline-offset: 1px;
232
+ }
207
233
  .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
208
234
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
209
235
 
@@ -636,6 +662,22 @@
636
662
  display: none;
637
663
  }
638
664
 
665
+ /* Why a decision was refused, next to the button that refused it. The input
666
+ that satisfies the precondition can be inside the collapsed audit accordion,
667
+ so the message cannot live only with the input (kontourai/survey#208). */
668
+ .survey-workbench-embed .derr{
669
+ display: block;
670
+ margin-top: 6px;
671
+ text-align: right;
672
+ font-size: 12px;
673
+ font-weight: 600;
674
+ color: var(--k-negative);
675
+ }
676
+
677
+ .survey-workbench-embed .derr[hidden]{
678
+ display: none;
679
+ }
680
+
639
681
  /* confidence + provenance */
640
682
 
641
683
  .survey-workbench-embed .prov{
@@ -1077,14 +1119,6 @@
1077
1119
  text-transform: uppercase;
1078
1120
  }
1079
1121
 
1080
- .survey-workbench-embed .preview-section.is-neutral{
1081
- background: var(--k-raised);
1082
- }
1083
-
1084
- .survey-workbench-embed .preview-section.is-neutral h3{
1085
- color: var(--k-faint);
1086
- }
1087
-
1088
1122
  .survey-workbench-embed .reference-details{
1089
1123
  margin-top: 8px;
1090
1124
  }
@@ -2,7 +2,9 @@ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type Review
2
2
  import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
3
3
  import { type ReviewPresentationAdapter } from "./review-presentation.js";
4
4
  import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
5
- export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
5
+ export { reviewAuditRowKeys, type ReviewAuditRowKey } from "./audit-rows.js";
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
+ 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";
6
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";
7
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";
8
10
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";