@kontourai/survey 2.3.0 → 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.
@@ -3,6 +3,8 @@ export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
3
3
  export { reviewResourceApiVersion } from "./review-resource.js";
4
4
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
5
5
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
6
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
7
+ export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssue, ReviewQueueBindingIssueCode, ReviewQueueExtractionIssue, ReviewQueueExtractionIssueCode, ValidateReviewQueueBindingOptions, } from "./review-workbench/queue-binding.js";
6
8
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
7
9
  export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
8
10
  export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
package/dist/src/index.js CHANGED
@@ -2,6 +2,7 @@ export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
2
2
  export { reviewResourceApiVersion } from "./review-resource.js";
3
3
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
4
4
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
5
6
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
6
7
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
7
8
  export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
@@ -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;
@@ -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
+ }
@@ -3,6 +3,7 @@ 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
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";
6
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";
7
8
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
8
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";
@@ -9,6 +9,7 @@ import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review
9
9
  import { humanizeIdentifier } from "./review-presentation.js";
10
10
  import { createAuditFactTrace, } from "./audit-rows.js";
11
11
  export { reviewAuditRowKeys } from "./audit-rows.js";
12
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
12
13
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
13
14
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
14
15
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
@@ -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.3.0",
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",