@kontourai/survey 2.3.0 → 2.4.1
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.
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/review-workbench/queue-binding.d.ts +127 -0
- package/dist/src/review-workbench/queue-binding.js +324 -0
- package/dist/src/review-workbench/review-workbench.d.ts +1 -0
- package/dist/src/review-workbench/review-workbench.js +1 -0
- package/dist/src/review-workbench/server-review-session.d.ts +14 -0
- package/dist/src/review-workbench/server-review-session.js +7 -0
- package/package.json +2 -1
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
+
"version": "2.4.1",
|
|
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",
|