@kontourai/survey 0.4.19 → 0.4.20

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/README.md CHANGED
@@ -101,15 +101,24 @@ For the full consumer path from `ReviewItem` construction through persisted
101
101
  review events, exported results, and optional Surface projection, see
102
102
  [`docs/consumer-integration-guide.md`](docs/consumer-integration-guide.md).
103
103
  That guide also covers the server-side apply boundary: producers should derive
104
- write results from reviewed snapshots plus persisted events, not from
104
+ write results from pre-decision review snapshots plus persisted events, not from
105
105
  browser-computed decisions or exported result payloads.
106
106
  Use `persistReviewSessionEvents` when server code needs to save review events,
107
107
  then pass the persisted event set to `deriveReviewSessionApplyResultForSnapshot`
108
108
  before applying product policy. Survey derives selected review results and
109
109
  structured replay/completion issues; the producer still owns current-record
110
110
  validation and writes.
111
- For a generic, test-covered example of the consumer adapter contract, see
112
- [`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts).
111
+ For browser-backed queues, server code can import
112
+ `@kontourai/survey/review-workbench/server-review-session` and use
113
+ `createServerReviewSessionRecord`, `hashReviewSessionSnapshot`,
114
+ `assertServerReviewSessionFreshness`, and `assertServerReviewSessionEvents` to
115
+ keep the review snapshot server-owned while accepting browser-submitted
116
+ `ReviewSessionEvent` resources.
117
+ For generic, test-covered consumer examples, see
118
+ [`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts)
119
+ for presentation and event persistence, and
120
+ [`examples/review-workbench/server-apply-consumer.ts`](examples/review-workbench/server-apply-consumer.ts)
121
+ for a compact server-side apply boundary.
113
122
  For the current decision on why Survey is not adding a generic review adapter
114
123
  builder yet, see
115
124
  [`docs/consumer-adapter-abstraction-assessment.md`](docs/consumer-adapter-abstraction-assessment.md).
@@ -6,6 +6,7 @@ export declare function buildFacilityCredentialConsumerExample(): Promise<Facili
6
6
  export declare const facilityCredentialConsumerExample: FacilityCredentialConsumerExample;
7
7
  export interface FacilityCredentialConsumerExample {
8
8
  readonly reviewItem: typeof facilityCredentialReviewItemFixture;
9
+ readonly reviewSessionSnapshot: ReturnType<typeof initialReviewQueueSessionState>;
9
10
  readonly reviewedSnapshot: ReturnType<typeof initialReviewQueueSessionState>;
10
11
  readonly eventsToPersist: readonly ReviewSessionEvent[];
11
12
  readonly persistedEvents: readonly ReviewSessionEvent[];
@@ -45,10 +45,13 @@ export const facilityCredentialPresentationAdapter = {
45
45
  },
46
46
  };
47
47
  export async function buildFacilityCredentialConsumerExample() {
48
- const reviewedSnapshot = {
48
+ const reviewSessionSnapshot = {
49
49
  ...initialReviewQueueSessionState([facilityCredentialReviewItemFixture]),
50
50
  actorId: "review-operator@example.test",
51
51
  reviewedAt: "2026-01-17T16:15:00.000Z",
52
+ };
53
+ const reviewedSnapshot = {
54
+ ...reviewSessionSnapshot,
52
55
  decisionsByItemName: {
53
56
  [facilityCredentialReviewItemFixture.metadata.name]: "accept-proposed",
54
57
  },
@@ -71,7 +74,7 @@ export async function buildFacilityCredentialConsumerExample() {
71
74
  },
72
75
  });
73
76
  const applyResult = deriveReviewSessionApplyResultForSnapshot({
74
- snapshot: reviewedSnapshot,
77
+ snapshot: reviewSessionSnapshot,
75
78
  events: persisted.events,
76
79
  requiredResolvedItems: "all",
77
80
  });
@@ -90,6 +93,7 @@ export async function buildFacilityCredentialConsumerExample() {
90
93
  }
91
94
  return {
92
95
  reviewItem: facilityCredentialReviewItemFixture,
96
+ reviewSessionSnapshot,
93
97
  reviewedSnapshot,
94
98
  eventsToPersist,
95
99
  persistedEvents: persisted.events,
@@ -0,0 +1,30 @@
1
+ import { type ReviewQueueSessionState, type ReviewWorkbenchResult } from "../../src/review-workbench/review-workbench.js";
2
+ import type { ReviewSessionEvent } from "../../src/review-resource.js";
3
+ export interface FacilityCredentialRecord {
4
+ readonly id: string;
5
+ readonly credential: unknown;
6
+ readonly appliedReviewItemNames: readonly string[];
7
+ }
8
+ export type FacilityCredentialApplyPreparation = {
9
+ readonly ok: true;
10
+ readonly mutation: {
11
+ readonly recordId: string;
12
+ readonly credential: unknown;
13
+ readonly reviewItemName: string;
14
+ readonly selectedCandidateId: string;
15
+ readonly actorId: string;
16
+ readonly appliedAt: string;
17
+ };
18
+ readonly result: ReviewWorkbenchResult;
19
+ } | {
20
+ readonly ok: false;
21
+ readonly message: string;
22
+ };
23
+ export declare function prepareFacilityCredentialServerApply(input: {
24
+ readonly currentRecord: FacilityCredentialRecord;
25
+ readonly reviewSessionSnapshot: ReviewQueueSessionState;
26
+ readonly events: readonly ReviewSessionEvent[];
27
+ readonly actorId: string;
28
+ readonly appliedAt: string;
29
+ }): FacilityCredentialApplyPreparation;
30
+ export declare const facilityCredentialCurrentRecordFixture: FacilityCredentialRecord;
@@ -0,0 +1,47 @@
1
+ import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
2
+ import { deriveReviewSessionApplyResultForSnapshot, } from "../../src/review-workbench/review-workbench.js";
3
+ export function prepareFacilityCredentialServerApply(input) {
4
+ const applyResult = deriveReviewSessionApplyResultForSnapshot({
5
+ snapshot: input.reviewSessionSnapshot,
6
+ events: input.events,
7
+ requiredResolvedItems: "all",
8
+ });
9
+ if (!applyResult.ok) {
10
+ return {
11
+ ok: false,
12
+ message: applyResult.issues.map((issue) => issue.message).join(" "),
13
+ };
14
+ }
15
+ const [result] = applyResult.results;
16
+ if (!result || applyResult.results.length !== 1) {
17
+ return { ok: false, message: "Expected exactly one reviewed credential result." };
18
+ }
19
+ const item = input.reviewSessionSnapshot.items.find((candidate) => candidate.metadata.name === result.reviewItemName);
20
+ if (!item || item.spec.target !== "operatingLicenseCredential") {
21
+ return { ok: false, message: "Review result does not target the credential field." };
22
+ }
23
+ const currentCandidate = item.spec.candidates.find((candidate) => candidate.role === "current");
24
+ if (!currentCandidate || JSON.stringify(currentCandidate.value) !== JSON.stringify(input.currentRecord.credential)) {
25
+ return { ok: false, message: "Current credential no longer matches the review session snapshot." };
26
+ }
27
+ if (input.currentRecord.appliedReviewItemNames.includes(result.reviewItemName)) {
28
+ return { ok: false, message: "Review result was already applied." };
29
+ }
30
+ return {
31
+ ok: true,
32
+ result,
33
+ mutation: {
34
+ recordId: input.currentRecord.id,
35
+ credential: result.selectedValue,
36
+ reviewItemName: result.reviewItemName,
37
+ selectedCandidateId: result.selectedCandidateId,
38
+ actorId: input.actorId,
39
+ appliedAt: input.appliedAt,
40
+ },
41
+ };
42
+ }
43
+ export const facilityCredentialCurrentRecordFixture = {
44
+ id: "facility-credential-record-1",
45
+ credential: facilityCredentialReviewItemFixture.spec.candidates.find((candidate) => candidate.role === "current")?.value,
46
+ appliedReviewItemNames: [],
47
+ };
@@ -0,0 +1,12 @@
1
+ import type { ReviewSessionEvent } from "../review-resource.js";
2
+ import { type ReviewQueueSessionState } from "./review-queue-session.js";
3
+ export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch";
4
+ export interface ReviewSessionReplayIssue {
5
+ readonly code: ReviewSessionReplayIssueCode;
6
+ readonly eventName: string;
7
+ readonly sequence: number;
8
+ readonly reviewItemName?: string;
9
+ readonly candidateId?: string;
10
+ readonly message: string;
11
+ }
12
+ export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
@@ -0,0 +1,136 @@
1
+ import { candidateForDecision, workbenchDecisionDefinitions, } from "./review-queue-session.js";
2
+ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
3
+ const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
4
+ const sequenceIssues = validateEventSequence(events);
5
+ const replayIssues = events.flatMap((event) => {
6
+ const issues = [];
7
+ const activeItemName = event.spec.activeItemName;
8
+ const reviewItemName = event.spec.reviewItemName;
9
+ const itemName = reviewItemName ?? activeItemName;
10
+ const eventRef = {
11
+ eventName: event.metadata.name,
12
+ sequence: event.spec.sequence,
13
+ };
14
+ if (activeItemName && !itemsByName.has(activeItemName)) {
15
+ issues.push({
16
+ ...eventRef,
17
+ code: "unknown-active-item",
18
+ reviewItemName: activeItemName,
19
+ message: `ReviewSessionEvent ${event.metadata.name} references active item ${activeItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
20
+ });
21
+ }
22
+ if (reviewItemName && !itemsByName.has(reviewItemName)) {
23
+ issues.push({
24
+ ...eventRef,
25
+ code: "unknown-review-item",
26
+ reviewItemName,
27
+ message: `ReviewSessionEvent ${event.metadata.name} references review item ${reviewItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
28
+ });
29
+ }
30
+ if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
31
+ && !reviewItemName) {
32
+ issues.push({
33
+ ...eventRef,
34
+ code: "missing-review-item",
35
+ message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not reference a ReviewItem.`,
36
+ });
37
+ }
38
+ if (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted") {
39
+ const decision = replayableWorkbenchDecision(event.spec.data?.workbenchDecision);
40
+ if (!decision) {
41
+ issues.push({
42
+ ...eventRef,
43
+ code: "invalid-workbench-decision",
44
+ reviewItemName,
45
+ candidateId: event.spec.candidateId,
46
+ message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not include a replayable workbench decision.`,
47
+ });
48
+ }
49
+ else if (itemName && itemsByName.has(itemName)) {
50
+ const item = itemsByName.get(itemName);
51
+ const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
52
+ const expectedStatus = workbenchDecisionDefinitions[decision].status;
53
+ const referencedCandidateExists = event.spec.candidateId
54
+ ? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
55
+ : false;
56
+ if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
57
+ issues.push({
58
+ ...eventRef,
59
+ code: "decision-candidate-mismatch",
60
+ reviewItemName: itemName,
61
+ candidateId: event.spec.candidateId,
62
+ message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${expectedCandidate.id}, but references ${event.spec.candidateId ?? "no candidate"}.`,
63
+ });
64
+ }
65
+ if (event.spec.status !== expectedStatus) {
66
+ issues.push({
67
+ ...eventRef,
68
+ code: "decision-status-mismatch",
69
+ reviewItemName: itemName,
70
+ candidateId: event.spec.candidateId,
71
+ message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
72
+ });
73
+ }
74
+ }
75
+ }
76
+ if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
77
+ const item = itemsByName.get(itemName);
78
+ const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
79
+ if (!hasCandidate) {
80
+ issues.push({
81
+ ...eventRef,
82
+ code: "unknown-candidate",
83
+ reviewItemName: itemName,
84
+ candidateId: event.spec.candidateId,
85
+ message: `ReviewSessionEvent ${event.metadata.name} references candidate ${event.spec.candidateId}, but ReviewItem ${itemName} in the supplied session snapshot does not contain that candidate.`,
86
+ });
87
+ }
88
+ }
89
+ return issues;
90
+ });
91
+ return [...sequenceIssues, ...replayIssues];
92
+ }
93
+ function replayableWorkbenchDecision(value) {
94
+ return typeof value === "string" && value in workbenchDecisionDefinitions
95
+ ? value
96
+ : undefined;
97
+ }
98
+ function validateEventSequence(events) {
99
+ const issues = [];
100
+ const seen = new Map();
101
+ events.forEach((event, index) => {
102
+ const sequence = event.spec.sequence;
103
+ const eventRef = {
104
+ eventName: event.metadata.name,
105
+ sequence,
106
+ };
107
+ if (!Number.isSafeInteger(sequence) || sequence < 1) {
108
+ issues.push({
109
+ ...eventRef,
110
+ code: "invalid-sequence",
111
+ message: `ReviewSessionEvent ${event.metadata.name} has invalid sequence ${String(sequence)}. Sequences must be positive safe integers.`,
112
+ });
113
+ return;
114
+ }
115
+ const duplicateOf = seen.get(sequence);
116
+ if (duplicateOf) {
117
+ issues.push({
118
+ ...eventRef,
119
+ code: "duplicate-sequence",
120
+ message: `ReviewSessionEvent ${event.metadata.name} reuses sequence ${sequence} from ${duplicateOf}.`,
121
+ });
122
+ }
123
+ else {
124
+ seen.set(sequence, event.metadata.name);
125
+ }
126
+ const expected = index + 1;
127
+ if (sequence !== expected) {
128
+ issues.push({
129
+ ...eventRef,
130
+ code: "non-contiguous-sequence",
131
+ message: `ReviewSessionEvent ${event.metadata.name} has sequence ${sequence}, but canonical event streams must be ordered and contiguous from 1; expected ${expected}.`,
132
+ });
133
+ }
134
+ });
135
+ return issues;
136
+ }
@@ -1,9 +1,11 @@
1
1
  import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type ReviewWorkbenchState } from "./review-queue-session.js";
2
+ import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
2
3
  import { type ReviewPresentationAdapter } from "./review-presentation.js";
3
4
  import { type ReviewCandidate, type ReviewDecision, type ReviewSession, type ReviewSessionEvent } from "../review-resource.js";
4
5
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, 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";
5
6
  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";
6
7
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
8
+ export { validateReviewSessionEventsForSnapshot, type ReviewSessionReplayIssue, type ReviewSessionReplayIssueCode, } from "./review-session-replay.js";
7
9
  export declare function buildReviewDecision(state: ReviewWorkbenchState): ReviewDecision | undefined;
8
10
  export interface ReviewWorkbenchSessionExport {
9
11
  readonly session: ReviewSession;
@@ -11,15 +13,6 @@ export interface ReviewWorkbenchSessionExport {
11
13
  readonly decisions: readonly ReviewDecision[];
12
14
  readonly results: readonly ReviewWorkbenchResult[];
13
15
  }
14
- export type ReviewSessionReplayIssueCode = "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch";
15
- export interface ReviewSessionReplayIssue {
16
- readonly code: ReviewSessionReplayIssueCode;
17
- readonly eventName: string;
18
- readonly sequence: number;
19
- readonly reviewItemName?: string;
20
- readonly candidateId?: string;
21
- readonly message: string;
22
- }
23
16
  export type ReviewSessionApplyResolutionRequirement = "all" | "any" | "none";
24
17
  export type ReviewSessionApplyIssue = ReviewSessionReplayIssue | {
25
18
  readonly code: "unresolved-review-item";
@@ -108,7 +101,6 @@ export interface ReviewWorkbenchResult {
108
101
  export declare function buildReviewDecisionsFromSession(session: ReviewQueueSessionState): ReviewDecision[];
109
102
  export declare function buildReviewWorkbenchResultsFromSession(session: ReviewQueueSessionState): ReviewWorkbenchResult[];
110
103
  export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSessionState, events?: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
111
- export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
112
104
  export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
113
105
  export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
114
106
  export declare function deriveReviewSessionApplyResultForSnapshot(options: DeriveReviewSessionApplyResultForSnapshotOptions): DeriveReviewSessionApplyResultForSnapshotResult;
@@ -1,10 +1,12 @@
1
1
  import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
2
+ import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
2
3
  import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
3
4
  import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
4
5
  import { reviewResourceApiVersion, } from "../review-resource.js";
5
6
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
6
7
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
7
8
  export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
9
+ export { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
8
10
  export function buildReviewDecision(state) {
9
11
  if (!state.decision) {
10
12
  return undefined;
@@ -91,100 +93,6 @@ export function buildReviewWorkbenchSessionExport(session, events = buildReviewS
91
93
  results: buildReviewWorkbenchResultsFromSession(session),
92
94
  };
93
95
  }
94
- export function validateReviewSessionEventsForSnapshot(snapshot, events) {
95
- const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
96
- return events.flatMap((event) => {
97
- const issues = [];
98
- const activeItemName = event.spec.activeItemName;
99
- const reviewItemName = event.spec.reviewItemName;
100
- const itemName = reviewItemName ?? activeItemName;
101
- const eventRef = {
102
- eventName: event.metadata.name,
103
- sequence: event.spec.sequence,
104
- };
105
- if (activeItemName && !itemsByName.has(activeItemName)) {
106
- issues.push({
107
- ...eventRef,
108
- code: "unknown-active-item",
109
- reviewItemName: activeItemName,
110
- message: `ReviewSessionEvent ${event.metadata.name} references active item ${activeItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
111
- });
112
- }
113
- if (reviewItemName && !itemsByName.has(reviewItemName)) {
114
- issues.push({
115
- ...eventRef,
116
- code: "unknown-review-item",
117
- reviewItemName,
118
- message: `ReviewSessionEvent ${event.metadata.name} references review item ${reviewItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
119
- });
120
- }
121
- if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
122
- && !reviewItemName) {
123
- issues.push({
124
- ...eventRef,
125
- code: "missing-review-item",
126
- message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not reference a ReviewItem.`,
127
- });
128
- }
129
- if (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted") {
130
- const decision = replayableWorkbenchDecision(event.spec.data?.workbenchDecision);
131
- if (!decision) {
132
- issues.push({
133
- ...eventRef,
134
- code: "invalid-workbench-decision",
135
- reviewItemName,
136
- candidateId: event.spec.candidateId,
137
- message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not include a replayable workbench decision.`,
138
- });
139
- }
140
- else if (itemName && itemsByName.has(itemName)) {
141
- const item = itemsByName.get(itemName);
142
- const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
143
- const expectedStatus = workbenchDecisionDefinitions[decision].status;
144
- const referencedCandidateExists = event.spec.candidateId
145
- ? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
146
- : false;
147
- if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
148
- issues.push({
149
- ...eventRef,
150
- code: "decision-candidate-mismatch",
151
- reviewItemName: itemName,
152
- candidateId: event.spec.candidateId,
153
- message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${expectedCandidate.id}, but references ${event.spec.candidateId ?? "no candidate"}.`,
154
- });
155
- }
156
- if (event.spec.status !== expectedStatus) {
157
- issues.push({
158
- ...eventRef,
159
- code: "decision-status-mismatch",
160
- reviewItemName: itemName,
161
- candidateId: event.spec.candidateId,
162
- message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
163
- });
164
- }
165
- }
166
- }
167
- if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
168
- const item = itemsByName.get(itemName);
169
- const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
170
- if (!hasCandidate) {
171
- issues.push({
172
- ...eventRef,
173
- code: "unknown-candidate",
174
- reviewItemName: itemName,
175
- candidateId: event.spec.candidateId,
176
- message: `ReviewSessionEvent ${event.metadata.name} references candidate ${event.spec.candidateId}, but ReviewItem ${itemName} in the supplied session snapshot does not contain that candidate.`,
177
- });
178
- }
179
- }
180
- return issues;
181
- });
182
- }
183
- function replayableWorkbenchDecision(value) {
184
- return typeof value === "string" && value in workbenchDecisionDefinitions
185
- ? value
186
- : undefined;
187
- }
188
96
  export function replayReviewSessionEventsForSnapshot(snapshot, events) {
189
97
  const issues = validateReviewSessionEventsForSnapshot(snapshot, events);
190
98
  if (issues.length > 0) {
@@ -0,0 +1,56 @@
1
+ import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
2
+ import type { ReviewSessionEvent } from "../review-resource.js";
3
+ import type { ReviewQueueSessionState } from "./review-queue-session.js";
4
+ export interface ServerReviewSessionRecord {
5
+ readonly sessionName: string;
6
+ readonly snapshot: ReviewQueueSessionState;
7
+ readonly snapshotHash: string;
8
+ readonly eventCount?: number;
9
+ readonly updatedAt: string;
10
+ }
11
+ export type ServerReviewSessionFreshnessStatus = "current" | "stale";
12
+ export interface ServerReviewSessionFreshnessComparison {
13
+ readonly status: ServerReviewSessionFreshnessStatus;
14
+ readonly expectedSnapshotHash: string;
15
+ readonly actualSnapshotHash: string;
16
+ readonly expectedEventCount?: number;
17
+ readonly actualEventCount?: number;
18
+ }
19
+ export type ServerReviewSessionStaleIssueCode = "snapshot-hash-mismatch" | "event-count-mismatch";
20
+ export interface ServerReviewSessionStaleIssue {
21
+ readonly code: ServerReviewSessionStaleIssueCode;
22
+ readonly message: string;
23
+ readonly expected: string | number;
24
+ readonly actual: string | number;
25
+ }
26
+ export declare class StaleServerReviewSessionError extends Error {
27
+ readonly name = "StaleServerReviewSessionError";
28
+ readonly issues: readonly ServerReviewSessionStaleIssue[];
29
+ readonly comparison: ServerReviewSessionFreshnessComparison;
30
+ constructor(comparison: ServerReviewSessionFreshnessComparison);
31
+ }
32
+ export type ServerReviewSessionEventValidationIssue = ReviewSessionReplayIssue | {
33
+ readonly code: "session-name-mismatch";
34
+ readonly eventName: string;
35
+ readonly sequence: number;
36
+ readonly expectedSessionName: string;
37
+ readonly actualSessionName: string;
38
+ readonly message: string;
39
+ };
40
+ export declare class ServerReviewSessionEventValidationError extends Error {
41
+ readonly name = "ServerReviewSessionEventValidationError";
42
+ readonly issues: readonly ServerReviewSessionEventValidationIssue[];
43
+ constructor(issues: readonly ServerReviewSessionEventValidationIssue[]);
44
+ }
45
+ export interface CreateServerReviewSessionRecordOptions {
46
+ readonly sessionName: string;
47
+ readonly snapshot: ReviewQueueSessionState;
48
+ readonly eventCount?: number;
49
+ readonly updatedAt?: string | Date;
50
+ }
51
+ export declare function createServerReviewSessionRecord(options: CreateServerReviewSessionRecordOptions): ServerReviewSessionRecord;
52
+ export declare function hashReviewSessionSnapshot(snapshot: ReviewQueueSessionState): string;
53
+ export declare function compareServerReviewSessionFreshness(record: ServerReviewSessionRecord, snapshot: ReviewQueueSessionState, eventCount?: number): ServerReviewSessionFreshnessComparison;
54
+ export declare function assertServerReviewSessionFreshness(record: ServerReviewSessionRecord, snapshot: ReviewQueueSessionState, eventCount?: number): void;
55
+ export declare function validateServerReviewSessionEvents(record: ServerReviewSessionRecord, events: readonly ReviewSessionEvent[]): ServerReviewSessionEventValidationIssue[];
56
+ export declare function assertServerReviewSessionEvents(record: ServerReviewSessionRecord, events: readonly ReviewSessionEvent[]): void;
@@ -0,0 +1,120 @@
1
+ import { createHash } from "node:crypto";
2
+ import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
3
+ export class StaleServerReviewSessionError extends Error {
4
+ name = "StaleServerReviewSessionError";
5
+ issues;
6
+ comparison;
7
+ constructor(comparison) {
8
+ const issues = staleIssuesForComparison(comparison);
9
+ super(`Review session is stale: ${issues.map((issue) => issue.message).join(" ")}`);
10
+ this.issues = issues;
11
+ this.comparison = comparison;
12
+ }
13
+ }
14
+ export class ServerReviewSessionEventValidationError extends Error {
15
+ name = "ServerReviewSessionEventValidationError";
16
+ issues;
17
+ constructor(issues) {
18
+ super(`Review session events are invalid: ${issues.map((issue) => issue.message).join(" ")}`);
19
+ this.issues = issues;
20
+ }
21
+ }
22
+ export function createServerReviewSessionRecord(options) {
23
+ return {
24
+ sessionName: options.sessionName,
25
+ snapshot: options.snapshot,
26
+ snapshotHash: hashReviewSessionSnapshot(options.snapshot),
27
+ eventCount: options.eventCount,
28
+ updatedAt: isoTimestamp(options.updatedAt ?? new Date()),
29
+ };
30
+ }
31
+ export function hashReviewSessionSnapshot(snapshot) {
32
+ return sha256(canonicalJson(snapshot));
33
+ }
34
+ export function compareServerReviewSessionFreshness(record, snapshot, eventCount) {
35
+ const actualSnapshotHash = hashReviewSessionSnapshot(snapshot);
36
+ const eventCountMatches = record.eventCount === undefined || eventCount === undefined || record.eventCount === eventCount;
37
+ const current = record.snapshotHash === actualSnapshotHash && eventCountMatches;
38
+ return {
39
+ status: current ? "current" : "stale",
40
+ expectedSnapshotHash: record.snapshotHash,
41
+ actualSnapshotHash,
42
+ expectedEventCount: record.eventCount,
43
+ actualEventCount: eventCount,
44
+ };
45
+ }
46
+ export function assertServerReviewSessionFreshness(record, snapshot, eventCount) {
47
+ const comparison = compareServerReviewSessionFreshness(record, snapshot, eventCount);
48
+ if (comparison.status === "stale") {
49
+ throw new StaleServerReviewSessionError(comparison);
50
+ }
51
+ }
52
+ export function validateServerReviewSessionEvents(record, events) {
53
+ const replayIssues = validateReviewSessionEventsForSnapshot(record.snapshot, events);
54
+ const sessionIssues = events.flatMap((event) => {
55
+ if (event.spec.sessionName === record.sessionName) {
56
+ return [];
57
+ }
58
+ return [{
59
+ code: "session-name-mismatch",
60
+ eventName: event.metadata.name,
61
+ sequence: event.spec.sequence,
62
+ expectedSessionName: record.sessionName,
63
+ actualSessionName: event.spec.sessionName,
64
+ message: `ReviewSessionEvent ${event.metadata.name} references session ${event.spec.sessionName}, but the server session is ${record.sessionName}.`,
65
+ }];
66
+ });
67
+ return [...sessionIssues, ...replayIssues];
68
+ }
69
+ export function assertServerReviewSessionEvents(record, events) {
70
+ const issues = validateServerReviewSessionEvents(record, events);
71
+ if (issues.length > 0) {
72
+ throw new ServerReviewSessionEventValidationError(issues);
73
+ }
74
+ }
75
+ function staleIssuesForComparison(comparison) {
76
+ const issues = [];
77
+ if (comparison.expectedSnapshotHash !== comparison.actualSnapshotHash) {
78
+ issues.push({
79
+ code: "snapshot-hash-mismatch",
80
+ expected: comparison.expectedSnapshotHash,
81
+ actual: comparison.actualSnapshotHash,
82
+ message: `Expected snapshot hash ${comparison.expectedSnapshotHash}, received ${comparison.actualSnapshotHash}.`,
83
+ });
84
+ }
85
+ if (comparison.expectedEventCount !== undefined
86
+ && comparison.actualEventCount !== undefined
87
+ && comparison.expectedEventCount !== comparison.actualEventCount) {
88
+ issues.push({
89
+ code: "event-count-mismatch",
90
+ expected: comparison.expectedEventCount,
91
+ actual: comparison.actualEventCount,
92
+ message: `Expected ${comparison.expectedEventCount} events, received ${comparison.actualEventCount}.`,
93
+ });
94
+ }
95
+ return issues;
96
+ }
97
+ function canonicalJson(value) {
98
+ return JSON.stringify(canonicalize(value));
99
+ }
100
+ function canonicalize(value) {
101
+ if (value instanceof Date) {
102
+ return value.toISOString();
103
+ }
104
+ if (Array.isArray(value)) {
105
+ return value.map((entry) => canonicalize(entry));
106
+ }
107
+ if (value && typeof value === "object") {
108
+ const entries = Object.entries(value)
109
+ .filter(([, entryValue]) => entryValue !== undefined)
110
+ .sort(([left], [right]) => left.localeCompare(right));
111
+ return Object.fromEntries(entries.map(([key, entryValue]) => [key, canonicalize(entryValue)]));
112
+ }
113
+ return value;
114
+ }
115
+ function sha256(value) {
116
+ return createHash("sha256").update(value).digest("hex");
117
+ }
118
+ function isoTimestamp(value) {
119
+ return value instanceof Date ? value.toISOString() : value;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
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",
@@ -22,6 +22,10 @@
22
22
  "types": "./dist/src/review-workbench/review-workbench.d.ts",
23
23
  "default": "./dist/src/review-workbench/review-workbench.js"
24
24
  },
25
+ "./review-workbench/server-review-session": {
26
+ "types": "./dist/src/review-workbench/server-review-session.d.ts",
27
+ "default": "./dist/src/review-workbench/server-review-session.js"
28
+ },
25
29
  "./review-workbench.css": "./dist/src/review-workbench/review-workbench.css",
26
30
  "./review-workbench/standalone.css": "./dist/src/review-workbench/review-workbench.standalone.css",
27
31
  "./fixtures/public-field-review": "./dist/fixtures/public-field-review.js",