@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.
@@ -41,7 +41,77 @@ export function buildExtractionInspectorModel(input) {
41
41
  candidates[index].alignment = "excerpt-mismatch";
42
42
  }
43
43
  }
44
- return { sources, candidates };
44
+ return { sources, candidates: bindHighlightElementIds(candidates) };
45
+ }
46
+ const HIGHLIGHT_ID_PREFIX = "highlight-";
47
+ /**
48
+ * Assigns every candidate the element id its source highlight will carry.
49
+ *
50
+ * Done here, over the whole model, rather than by a pure per-id derivation:
51
+ * sanitizing a candidate id to an HTML identifier is lossy (`a:b.c` and `a:b-c`
52
+ * both reduce to `a-b-c`), and two anchors sharing an id silently resolve a
53
+ * host's link to the wrong sentence. Today's candidate-id scheme happens not to
54
+ * produce that collision — the entry index always separates two sources — so
55
+ * the disambiguating suffix is defensive rather than a fix for a reachable bug.
56
+ * It is here because uniqueness is now a published promise
57
+ * ({@link ExtractionInspectorCandidate.highlightElementId}) that must survive a
58
+ * later change to the key scheme rather than depend on one.
59
+ */
60
+ function bindHighlightElementIds(candidates) {
61
+ const used = new Set();
62
+ return candidates.map((candidate) => ({
63
+ ...candidate,
64
+ highlightElementId: uniqueHighlightElementId(candidate.id, used),
65
+ }));
66
+ }
67
+ /**
68
+ * Derives an element id not already taken.
69
+ *
70
+ * `used` holds finished element ids — prefix included — and so does the value
71
+ * returned, because the two have to be the same form to be comparable. They were
72
+ * not: uniqueness was checked on the bare token while the set held prefixed ids,
73
+ * so a published `highlight-alpha` did not stop a candidate with id `alpha` from
74
+ * deriving `highlight-alpha` and mounting a second element under it. A host's
75
+ * `href` then resolved to whichever came first.
76
+ */
77
+ function uniqueHighlightElementId(candidateId, used) {
78
+ const base = `${HIGHLIGHT_ID_PREFIX}${safeId(candidateId)}`;
79
+ let elementId = base;
80
+ for (let suffix = 2; used.has(elementId); suffix += 1)
81
+ elementId = `${base}-${suffix}`;
82
+ used.add(elementId);
83
+ return elementId;
84
+ }
85
+ /** The inspector's own candidate-list id, paired 1:1 with a highlight anchor. Not public. */
86
+ function candidateElementId(highlightElementId) {
87
+ return `candidate-${highlightElementId.slice(HIGHLIGHT_ID_PREFIX.length)}`;
88
+ }
89
+ /**
90
+ * The element id each candidate's anchor will carry, for one mount.
91
+ *
92
+ * A model from {@link buildExtractionInspectorModel} already carries one per
93
+ * candidate and it is used verbatim — rewriting a published id is precisely the
94
+ * drift this contract exists to prevent. A hand-authored model may omit some or
95
+ * all of them; those get an id derived here, seeded with the published ones so a
96
+ * derived id can never collide with one a host is already linking to.
97
+ *
98
+ * Keyed on the candidate OBJECT, not on `candidate.id`. The builder's ids are
99
+ * structurally unique, but this function exists for models a caller assembled,
100
+ * where nothing enforces that — and an id-keyed map silently collapsed two such
101
+ * candidates into one entry, handing the second's derived id to the first and
102
+ * discarding the very `highlightElementId` a host was already linking to. Object
103
+ * identity cannot collide, whatever the data says.
104
+ */
105
+ function resolveHighlightElementIds(model) {
106
+ const used = new Set();
107
+ for (const candidate of model.candidates)
108
+ if (candidate.highlightElementId)
109
+ used.add(candidate.highlightElementId);
110
+ const resolved = new Map();
111
+ for (const candidate of model.candidates) {
112
+ resolved.set(candidate, candidate.highlightElementId ?? uniqueHighlightElementId(candidate.id, used));
113
+ }
114
+ return resolved;
45
115
  }
46
116
  function assertResolvedArtifact(artifact) {
47
117
  if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
@@ -164,7 +234,9 @@ export function exportExtractionInspector(model, options = {}) {
164
234
  return canonicalJson({ apiVersion: "survey.kontourai.io/v1alpha1", kind: "ExtractionInspectorExport", spec: {
165
235
  redaction: { preparedTextIncluded: options.includePreparedText === true, excerptsIncluded: options.includeExcerpts === true },
166
236
  sources: model.sources.map(({ artifactText, message: _message, ...source }) => ({ ...source, preparedText: options.includePreparedText ? artifactText ?? null : "[redacted]" })),
167
- candidates: model.candidates.map((candidate) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
237
+ // highlightElementId is a DOM binding for a live inspector, not extraction
238
+ // evidence — it stays out of the canonical export (and out of its digest).
239
+ candidates: model.candidates.map(({ highlightElementId: _highlightElementId, ...candidate }) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
168
240
  } });
169
241
  }
170
242
  export function mountExtractionInspector(container, model, options = {}) {
@@ -172,6 +244,8 @@ export function mountExtractionInspector(container, model, options = {}) {
172
244
  ? Math.min(Number(options.pageSize), 500)
173
245
  : 100;
174
246
  let page = 0;
247
+ const highlightIds = resolveHighlightElementIds(model);
248
+ const highlightIdFor = (candidate) => highlightIds.get(candidate);
175
249
  const root = document.createElement("section");
176
250
  root.className = "extraction-inspector";
177
251
  root.setAttribute("aria-label", "Source-linked extraction inspector");
@@ -188,7 +262,7 @@ export function mountExtractionInspector(container, model, options = {}) {
188
262
  page = Math.min(page, pageCount - 1);
189
263
  const start = page * pageSize;
190
264
  const visible = matching.slice(start, start + pageSize);
191
- list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="candidate-${safeId(c.id)}" data-candidate-id="${escapeHtml(c.id)}" aria-controls="highlight-${safeId(c.id)}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span>${formatContext(c)}</button></li>`).join("") || "<li>No candidates match these filters.</li>";
265
+ list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="${escapeHtml(candidateElementId(highlightIdFor(c)))}" data-candidate-id="${escapeHtml(c.id)}" data-highlight-element-id="${escapeHtml(highlightIdFor(c))}" aria-controls="${escapeHtml(highlightIdFor(c))}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span>${formatContext(c)}</button></li>`).join("") || "<li>No candidates match these filters.</li>";
192
266
  resultCount.textContent = matching.length === 0 ? "No matching candidates" : `${start + 1}–${Math.min(start + pageSize, matching.length)} of ${matching.length}`;
193
267
  pageLabel.textContent = `Page ${page + 1} of ${pageCount}`;
194
268
  pageLabel.hidden = pageCount === 1;
@@ -197,7 +271,7 @@ export function mountExtractionInspector(container, model, options = {}) {
197
271
  previous.disabled = page === 0;
198
272
  next.disabled = page >= pageCount - 1;
199
273
  postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
200
- sourcesRoot.innerHTML = model.sources.map(s => { const candidates = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, candidates)}</pre></div>`; }).join("");
274
+ sourcesRoot.innerHTML = model.sources.map(s => { const anchored = model.candidates.filter(c => c.sourceKey === s.key); const marked = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `${anchored.map(c => anchorHtml(c, highlightIdFor(c))).join("")}<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, anchored, marked, highlightIdFor)}</pre></div>`; }).join("");
201
275
  };
202
276
  root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
203
277
  filters[key] = select.value;
@@ -209,50 +283,187 @@ export function mountExtractionInspector(container, model, options = {}) {
209
283
  delete filters.query; page = 0; render(); });
210
284
  previous.addEventListener("click", () => { page = Math.max(0, page - 1); render(); list.querySelector("button")?.focus(); });
211
285
  next.addEventListener("click", () => { page += 1; render(); list.querySelector("button")?.focus(); });
212
- const activateCandidate = (id) => { const candidate = model.candidates.find(c => c.id === id); if (!candidate)
213
- return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: id, reviewItemName: candidate.reviewItemName } })); };
214
- root.addEventListener("click", event => { const candidate = event.target.closest("button[data-candidate-id]"); if (candidate) {
286
+ // The event carries the resolved binding as well as the identity: a listener
287
+ // that wants to focus the highlight must never reconstruct the id, and the
288
+ // first-party custom element is the reference implementation of that rule.
289
+ const candidateFor = (highlightElementId) => model.candidates.find(c => highlightIdFor(c) === highlightElementId);
290
+ const activateCandidate = (highlightElementId) => { const candidate = candidateFor(highlightElementId); if (!candidate)
291
+ return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: candidate.id, reviewItemName: candidate.reviewItemName, highlightElementId } })); };
292
+ const clearFilters = () => {
293
+ for (const key of Object.keys(filters))
294
+ delete filters[key];
295
+ root.querySelectorAll("select[data-filter]").forEach(select => { select.value = ""; });
296
+ const search = root.querySelector('input[data-filter="query"]');
297
+ if (search)
298
+ search.value = "";
299
+ };
300
+ /**
301
+ * Brings a candidate's row onto the mounted page so it can be focused.
302
+ *
303
+ * Anchors exist for every candidate, so a highlight can be activated while its
304
+ * candidate row is on another page or filtered out entirely. Returning focus
305
+ * to a row that is not mounted would silently do nothing, so page to it —
306
+ * clearing the filters first if they are what is hiding it.
307
+ */
308
+ const revealCandidateRow = (highlightElementId) => {
309
+ const candidate = candidateFor(highlightElementId);
310
+ if (!candidate)
311
+ return undefined;
312
+ let index = filterExtractionInspectorCandidates(model, filters).indexOf(candidate);
313
+ if (index < 0) {
314
+ clearFilters();
315
+ index = model.candidates.indexOf(candidate);
316
+ }
317
+ if (index < 0)
318
+ return undefined;
319
+ page = Math.floor(index / pageSize);
320
+ render();
321
+ return candidate;
322
+ };
323
+ const returnToCandidate = (highlightElementId) => {
324
+ if (revealCandidateRow(highlightElementId))
325
+ root.querySelector(`#${CSS.escape(candidateElementId(highlightElementId))}`)?.focus();
326
+ };
327
+ /**
328
+ * Follows a host's `href="#<highlightElementId>"`.
329
+ *
330
+ * The anchor that id names always exists, but a candidate off the current page
331
+ * or excluded by a filter has no painted highlight, so the link would land the
332
+ * reader on an invisible marker in the middle of the source text. Page to the
333
+ * candidate instead, so the highlight it points at is actually painted, and put
334
+ * focus on it.
335
+ */
336
+ const followHighlightFragment = () => {
337
+ const fragment = typeof location === "undefined" ? "" : location.hash.slice(1);
338
+ if (!fragment)
339
+ return;
340
+ if (!candidateFor(fragment))
341
+ return;
342
+ revealCandidateRow(fragment);
343
+ // `~=` matches one whitespace-separated token: a mark painted over a span
344
+ // shared by several candidates lists all of them, and this is the one the
345
+ // reader asked for. Record it, so activating that mark returns to the
346
+ // candidate they arrived by rather than to whichever is listed first.
347
+ const mark = root.querySelector(`[data-highlight-return-to~="${CSS.escape(fragment)}"]`);
348
+ if (!mark)
349
+ return;
350
+ mark.dataset.highlightArrivedBy = fragment;
351
+ mark.focus();
352
+ mark.scrollIntoView({ block: "center" });
353
+ };
354
+ const onHashChange = () => followHighlightFragment();
355
+ if (typeof window !== "undefined")
356
+ window.addEventListener("hashchange", onHashChange);
357
+ /** The candidate a shared mark returns to: the one the reader arrived by, else the first it covers. */
358
+ const returnTargetOf = (mark) => mark.dataset.highlightArrivedBy ?? mark.dataset.highlightReturnTo.split(" ")[0];
359
+ root.addEventListener("click", event => { const candidate = event.target.closest("button[data-highlight-element-id]"); if (candidate) {
215
360
  event.preventDefault();
216
361
  event.stopPropagation();
217
- activateCandidate(candidate.dataset.candidateId);
362
+ activateCandidate(candidate.dataset.highlightElementId);
218
363
  return;
219
- } const highlight = event.target.closest("button[data-highlight-candidate-id]"); if (highlight) {
364
+ } const highlight = event.target.closest("[data-highlight-return-to]"); if (highlight) {
220
365
  event.preventDefault();
221
366
  event.stopPropagation();
222
- root.querySelector(`#candidate-${CSS.escape(safeId(highlight.dataset.highlightCandidateId))}`)?.focus();
367
+ returnToCandidate(returnTargetOf(highlight));
223
368
  } });
224
- root.addEventListener("keydown", event => { const button = event.target.closest("button[data-candidate-id]"); if (!button)
369
+ root.addEventListener("keydown", event => { const highlight = event.target.closest("[data-highlight-return-to]"); if (highlight) {
370
+ if (event.key !== "Enter" && event.key !== " ")
371
+ return;
372
+ event.preventDefault();
373
+ event.stopPropagation();
374
+ returnToCandidate(returnTargetOf(highlight));
375
+ return;
376
+ } const button = event.target.closest("button[data-highlight-element-id]"); if (!button)
225
377
  return; if (event.key === "Enter" || event.key === " ") {
226
378
  event.preventDefault();
227
379
  event.stopPropagation();
228
- activateCandidate(button.dataset.candidateId);
380
+ activateCandidate(button.dataset.highlightElementId);
229
381
  return;
230
382
  } if (event.key === "ArrowDown" || event.key === "ArrowUp") {
231
383
  event.preventDefault();
232
- const buttons = [...list.querySelectorAll("button[data-candidate-id]")];
384
+ const buttons = [...list.querySelectorAll("button[data-highlight-element-id]")];
233
385
  const index = buttons.indexOf(button);
234
386
  buttons[event.key === "ArrowDown" ? Math.min(index + 1, buttons.length - 1) : Math.max(index - 1, 0)]?.focus();
235
387
  } });
236
388
  render();
237
- return () => root.remove();
389
+ followHighlightFragment();
390
+ return () => { if (typeof window !== "undefined")
391
+ window.removeEventListener("hashchange", onHashChange); root.remove(); };
238
392
  }
239
- function renderSource(text, candidates) {
393
+ /**
394
+ * Renders the prepared text with a return anchor for every candidate and a
395
+ * `<mark>` for the ones on the current page.
396
+ *
397
+ * The two sets are deliberately different. `marked` follows the candidate
398
+ * list's paging and filtering, so the painting tracks what the reviewer is
399
+ * looking at. `anchored` is every candidate the source has, so that the element
400
+ * id published on the model resolves whatever page the reviewer is on and
401
+ * whatever they have typed into the filter box — the anchors carry the contract,
402
+ * the marks carry the view.
403
+ */
404
+ function renderSource(text, anchored, marked, highlightIdFor) {
240
405
  const boundaries = new Set([0, text.length]);
241
- candidates.forEach(c => { boundaries.add(c.start); boundaries.add(c.end); });
242
- const points = [...boundaries].sort((a, b) => a - b);
406
+ anchored.forEach(c => boundaries.add(c.start));
407
+ marked.forEach(c => { boundaries.add(c.start); boundaries.add(c.end); });
408
+ const points = [...boundaries].filter(point => point >= 0 && point <= text.length).sort((a, b) => a - b);
243
409
  let html = "";
244
410
  const starts = new Map();
245
- candidates.forEach(c => starts.set(c.start, [...(starts.get(c.start) ?? []), c]));
411
+ anchored.forEach(c => starts.set(c.start, [...(starts.get(c.start) ?? []), c]));
412
+ const emitted = new Set();
246
413
  for (let i = 0; i < points.length - 1; i++) {
247
414
  const start = points[i], end = points[i + 1];
248
- for (const c of starts.get(start) ?? [])
249
- html += `<button type="button" class="highlight-anchor" id="highlight-${safeId(c.id)}" data-highlight-candidate-id="${escapeHtml(c.id)}" aria-label="Source highlight for ${escapeHtml(c.field)}; activate to return to candidate"></button>`;
415
+ for (const c of starts.get(start) ?? []) {
416
+ emitted.add(c);
417
+ html += anchorHtml(c, highlightIdFor(c));
418
+ }
250
419
  const segment = escapeHtml(text.slice(start, end));
251
- const active = candidates.filter(c => c.start < end && c.end > start);
252
- html += active.length ? `<mark aria-label="Highlighted for ${active.map(c => escapeHtml(c.field)).join(", ")}">${segment}</mark>` : segment;
420
+ const active = marked.filter(c => c.start < end && c.end > start);
421
+ html += active.length ? markHtml(segment, active, highlightIdFor) : segment;
253
422
  }
423
+ // A span starting at or past the end of the prepared text has no segment to
424
+ // lead; its anchor still has to exist, or its published id resolves nowhere.
425
+ for (const c of anchored)
426
+ if (!emitted.has(c))
427
+ html += anchorHtml(c, highlightIdFor(c));
254
428
  return html;
255
429
  }
430
+ /**
431
+ * The link target a host's `href` resolves to: an empty, inert span at the start
432
+ * of the candidate's span.
433
+ *
434
+ * Deliberately NOT a control. It exists for every candidate in the model, which
435
+ * is what makes the published id resolve unconditionally — but a thing that
436
+ * exists per candidate must cost nothing to a keyboard or screen-reader user, and
437
+ * as a `<button>` it did: 600 candidates put 600 invisible tab stops in sequence,
438
+ * several of them stacked together in the non-grounded postures. It was also a
439
+ * one-pixel pointer target once its accidental user-agent chrome was removed —
440
+ * an affordance in name only, at any size, because nothing about it was visible.
441
+ *
442
+ * Returning to the candidate is now the job of the thing a reader can actually
443
+ * see and aim at: the highlight itself. See {@link markHtml}.
444
+ */
445
+ function anchorHtml(candidate, highlightElementId) {
446
+ return `<span class="highlight-anchor" id="${escapeHtml(highlightElementId)}" data-highlight-candidate-id="${escapeHtml(candidate.id)}"></span>`;
447
+ }
448
+ /**
449
+ * The painted highlight, and the control that returns to its candidate.
450
+ *
451
+ * The highlight is the only part of this surface a reader can see, so it is the
452
+ * target: full phrase width rather than a sliver, discoverable because it is
453
+ * already visibly marked, and one tab stop per painted highlight rather than one
454
+ * per candidate in the model. `data-highlight-return-to` is separate from the
455
+ * anchor's `data-highlight-candidate-id` so the documented reverse lookup keeps
456
+ * resolving to exactly one element.
457
+ */
458
+ function markHtml(segment, active, highlightIdFor) {
459
+ const fields = active.map(c => escapeHtml(c.field)).join(", ");
460
+ // Every candidate the mark covers, not just the first. Two claims over one
461
+ // span is ordinary in extraction, and naming both in the label while binding
462
+ // only one left the others with a highlight that could not be navigated to or
463
+ // returned from — the label said two, the binding said one.
464
+ const bindings = active.map(c => escapeHtml(highlightIdFor(c))).join(" ");
465
+ return `<mark class="source-highlight" role="button" tabindex="0" data-highlight-return-to="${bindings}" aria-label="Highlighted for ${fields}; activate to return to candidate">${segment}</mark>`;
466
+ }
256
467
  function formatContext(candidate) {
257
468
  const context = [];
258
469
  if (candidate.pdfRegion) {
@@ -0,0 +1,127 @@
1
+ import type { ReviewQueueSessionState } from "./review-queue-session.js";
2
+ import { reviewResourceApiVersion, type ReviewItem } from "../review-resource.js";
3
+ import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
4
+ /**
5
+ * The durable attestation record. Serializable; a consumer stores it beside the
6
+ * queue when the round opens and presents it, unchanged, at every later
7
+ * validation.
8
+ *
9
+ * `itemNames` is deliberately redundant with the hash: it is what makes a
10
+ * refusal diagnosable (which item was removed or added, by name) and it keeps
11
+ * the set comparison independent of the hash derivation.
12
+ */
13
+ export interface ReviewQueueBinding {
14
+ readonly apiVersion: typeof reviewResourceApiVersion;
15
+ readonly kind: "ReviewQueueBinding";
16
+ readonly spec: {
17
+ readonly sessionName: string;
18
+ /** sha256 of the canonical open-time snapshot; see {@link hashReviewQueueSnapshot}. */
19
+ readonly snapshotHash: string;
20
+ /** Sorted, unique names of every ReviewItem the binding covers. Never empty. */
21
+ readonly itemNames: readonly string[];
22
+ /** ISO timestamp of when the binding was taken. Informational, not trusted. */
23
+ readonly boundAt: string;
24
+ };
25
+ }
26
+ export type ReviewQueueBindingIssueCode = "binding-malformed" | "session-name-mismatch" | "empty-queue" | "ambiguous-item-identity" | "snapshot-hash-mismatch" | "item-removed" | "item-added";
27
+ export interface ReviewQueueBindingIssue {
28
+ readonly code: ReviewQueueBindingIssueCode;
29
+ readonly message: string;
30
+ /** The ReviewItem name a set-membership issue is about, when there is one. */
31
+ readonly itemName?: string;
32
+ }
33
+ export declare class UnattestedReviewQueueError extends Error {
34
+ readonly name = "UnattestedReviewQueueError";
35
+ readonly issues: readonly ReviewQueueBindingIssue[];
36
+ constructor(issues: readonly ReviewQueueBindingIssue[]);
37
+ }
38
+ export interface BindReviewQueueOptions {
39
+ readonly sessionName: string;
40
+ /** Defaults to now. Informational only; nothing validates against it. */
41
+ readonly boundAt?: Date | string;
42
+ }
43
+ export interface ValidateReviewQueueBindingOptions {
44
+ /** When set, the binding must name this session. */
45
+ readonly sessionName?: string;
46
+ }
47
+ /**
48
+ * The digest a binding stores: sha256 over the canonical JSON of the whole
49
+ * open-time session state. Byte-identical to server-review-session's
50
+ * `hashReviewSessionSnapshot` (pinned by test), so a consumer already
51
+ * persisting that digest adopts the binding without invalidating stored state.
52
+ */
53
+ export declare function hashReviewQueueSnapshot(snapshot: ReviewQueueSessionState): string;
54
+ /**
55
+ * Take the binding for a queue, once, when the round/session opens.
56
+ *
57
+ * Call this at queue construction and persist the result beside the queue.
58
+ * Calling it again later, on bytes that may have changed, produces a binding
59
+ * that agrees with whatever it was given — which is the self-agreement bypass,
60
+ * not an attestation.
61
+ *
62
+ * Refuses an empty queue: a binding over nothing validates nothing, and every
63
+ * later check against it would be vacuously green. Refuses duplicate item
64
+ * names: the binding's set comparison is by name, so an ambiguous name would
65
+ * let two different items answer for one membership.
66
+ */
67
+ export declare function bindReviewQueue(snapshot: ReviewQueueSessionState, options: BindReviewQueueOptions): ReviewQueueBinding;
68
+ /**
69
+ * Compare a presented queue against its stored binding.
70
+ *
71
+ * `binding` must be the record persisted when the round opened — passing one
72
+ * derived from `snapshot` here checks nothing (see module doc). `snapshot` is
73
+ * the base queue the binding was taken over, not the state after event replay:
74
+ * decisions live in the event log precisely so the bound bytes never move.
75
+ *
76
+ * A malformed binding fails closed with `binding-malformed` rather than
77
+ * skipping the checks it cannot perform.
78
+ */
79
+ export declare function validateReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): ReviewQueueBindingIssue[];
80
+ export declare function assertReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): void;
81
+ export type ReviewQueueExtractionIssueCode = "import-not-grounded" | "empty-queue" | "item-missing-from-queue" | "item-not-in-extraction" | "item-diverges-from-extraction";
82
+ export interface ReviewQueueExtractionIssue {
83
+ readonly code: ReviewQueueExtractionIssueCode;
84
+ readonly message: string;
85
+ readonly itemName?: string;
86
+ }
87
+ export declare class UnattestedExtractionQueueError extends Error {
88
+ readonly name = "UnattestedExtractionQueueError";
89
+ readonly issues: readonly ReviewQueueExtractionIssue[];
90
+ constructor(issues: readonly ReviewQueueExtractionIssue[]);
91
+ }
92
+ /**
93
+ * Check a stored queue for consistency with the extraction import record it
94
+ * was derived from.
95
+ *
96
+ * The queue binding alone cannot catch a writer who edits the queue and
97
+ * re-binds: hashing mutated bytes yields a self-consistent pair. This check
98
+ * closes the QUEUE half of that hole: it revalidates the presented record
99
+ * through the public import boundary (a forged `grounded` status throws; an
100
+ * ungrounded import is refused), re-derives the canonical ReviewItems from it,
101
+ * and requires the stored queue to be the SAME SET, byte-identically per item,
102
+ * in both directions. A queue edited independently of its record fails.
103
+ *
104
+ * What it does NOT attest: the record itself. The envelope carries the
105
+ * prepared artifact's digest and contentLength, never the prepared bytes, so
106
+ * a library handed only the record cannot verify a proposal's bytes against
107
+ * the digested artifact. A writer who edits the record's proposals and
108
+ * re-derives the queue from the edited record presents a pair this check
109
+ * blesses, while `result.preparedArtifact.digest` still names the honest
110
+ * bytes. Record integrity is therefore the caller's storage obligation, and
111
+ * checking prepared bytes against that digest does not meet it — the digest
112
+ * covers the artifact, not the proposals, so it stays green through the
113
+ * rewrite. Meeting it takes one of: a record digest/MAC anchored where the
114
+ * record's writer cannot reach, immutable or authenticated record storage,
115
+ * or independently re-deriving the proposals from trusted prepared bytes and
116
+ * comparing. This limit is pinned by a boundary test and by
117
+ * scripts/check-guards.mjs.
118
+ *
119
+ * This is the whole-extraction rule: it applies to a queue whose items all come
120
+ * from one import. A consumer whose rounds mix in items the extraction cannot
121
+ * attest (recheck rounds against a prior observation, for one) owns that
122
+ * dispatch and those semantics — deciding which attestation applies to which
123
+ * item from a mutable label is bypass 3, and it stays with the data that can
124
+ * cross-check the label.
125
+ */
126
+ export declare function validateReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): ReviewQueueExtractionIssue[];
127
+ export declare function assertReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): void;