@kontourai/survey 0.4.17 → 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,12 +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
- Use `persistReviewSessionEvents` when server code needs to save review events
107
- and then replay exactly the persisted event set before applying product policy.
108
- For a generic, test-covered example of the consumer adapter contract, see
109
- [`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts).
106
+ Use `persistReviewSessionEvents` when server code needs to save review events,
107
+ then pass the persisted event set to `deriveReviewSessionApplyResultForSnapshot`
108
+ before applying product policy. Survey derives selected review results and
109
+ structured replay/completion issues; the producer still owns current-record
110
+ validation and writes.
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.
110
122
  For the current decision on why Survey is not adding a generic review adapter
111
123
  builder yet, see
112
124
  [`docs/consumer-adapter-abstraction-assessment.md`](docs/consumer-adapter-abstraction-assessment.md).
@@ -1,16 +1,19 @@
1
1
  import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
2
- import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewWorkbenchSessionExportForSnapshot, buildSurfaceProjectionPreview, initialReviewQueueSessionState, type ReviewPresentationAdapter } from "../../src/review-workbench/review-workbench.js";
2
+ import { buildReviewItemPresentation, buildReviewResultPresentation, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, type ReviewPresentationAdapter } from "../../src/review-workbench/review-workbench.js";
3
3
  import type { ReviewSessionEvent } from "../../src/review-resource.js";
4
4
  export declare const facilityCredentialPresentationAdapter: ReviewPresentationAdapter;
5
5
  export declare function buildFacilityCredentialConsumerExample(): Promise<FacilityCredentialConsumerExample>;
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[];
12
13
  readonly persistedEventCount: number;
13
- readonly sessionExport: ReturnType<typeof buildReviewWorkbenchSessionExportForSnapshot>;
14
+ readonly applyResult: Extract<ReturnType<typeof deriveReviewSessionApplyResultForSnapshot>, {
15
+ readonly ok: true;
16
+ }>;
14
17
  readonly itemPresentation: ReturnType<typeof buildReviewItemPresentation>;
15
18
  readonly resultPresentation: ReturnType<typeof buildReviewResultPresentation>;
16
19
  readonly surfaceProjectionPreview: NonNullable<ReturnType<typeof buildSurfaceProjectionPreview>>;
@@ -1,5 +1,5 @@
1
1
  import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
2
- import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewSessionEvents, buildReviewWorkbenchSessionExportForSnapshot, buildSurfaceProjectionPreview, initialReviewQueueSessionState, persistReviewSessionEvents, } from "../../src/review-workbench/review-workbench.js";
2
+ import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewSessionEvents, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, persistReviewSessionEvents, } from "../../src/review-workbench/review-workbench.js";
3
3
  export const facilityCredentialPresentationAdapter = {
4
4
  labelForTarget: (target) => target === "operatingLicenseCredential"
5
5
  ? "Operating license credential"
@@ -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
  },
@@ -70,8 +73,15 @@ export async function buildFacilityCredentialConsumerExample() {
70
73
  return { eventCount: persistedEvents.length };
71
74
  },
72
75
  });
73
- const sessionExport = buildReviewWorkbenchSessionExportForSnapshot(reviewedSnapshot, persisted.events);
74
- const [result] = sessionExport.results;
76
+ const applyResult = deriveReviewSessionApplyResultForSnapshot({
77
+ snapshot: reviewSessionSnapshot,
78
+ events: persisted.events,
79
+ requiredResolvedItems: "all",
80
+ });
81
+ if (!applyResult.ok) {
82
+ throw new Error(`Expected persisted credential events to replay before apply: ${applyResult.issues.map((issue) => issue.message).join(" ")}`);
83
+ }
84
+ const [result] = applyResult.results;
75
85
  if (!result) {
76
86
  throw new Error("Expected the reviewed credential snapshot to produce one review result.");
77
87
  }
@@ -83,11 +93,12 @@ export async function buildFacilityCredentialConsumerExample() {
83
93
  }
84
94
  return {
85
95
  reviewItem: facilityCredentialReviewItemFixture,
96
+ reviewSessionSnapshot,
86
97
  reviewedSnapshot,
87
98
  eventsToPersist,
88
99
  persistedEvents: persisted.events,
89
100
  persistedEventCount: persisted.eventCount,
90
- sessionExport,
101
+ applyResult,
91
102
  itemPresentation,
92
103
  resultPresentation,
93
104
  surfaceProjectionPreview,
@@ -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,37 @@ 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";
15
- export interface ReviewSessionReplayIssue {
16
- readonly code: ReviewSessionReplayIssueCode;
17
- readonly eventName: string;
18
- readonly sequence: number;
19
- readonly reviewItemName?: string;
20
- readonly candidateId?: string;
16
+ export type ReviewSessionApplyResolutionRequirement = "all" | "any" | "none";
17
+ export type ReviewSessionApplyIssue = ReviewSessionReplayIssue | {
18
+ readonly code: "unresolved-review-item";
19
+ readonly reviewItemName: string;
20
+ readonly message: string;
21
+ } | {
22
+ readonly code: "no-resolved-review-items";
21
23
  readonly message: string;
24
+ };
25
+ export interface DeriveReviewSessionApplyResultForSnapshotOptions {
26
+ readonly snapshot: ReviewQueueSessionState;
27
+ readonly events: readonly ReviewSessionEvent[];
28
+ readonly requiredResolvedItems?: ReviewSessionApplyResolutionRequirement;
22
29
  }
30
+ export type DeriveReviewSessionApplyResultForSnapshotResult = {
31
+ readonly ok: true;
32
+ readonly issues: readonly [];
33
+ readonly unresolvedItemNames: readonly string[];
34
+ readonly replayedSession: ReviewQueueSessionState;
35
+ readonly sessionExport: ReviewWorkbenchSessionExport;
36
+ readonly decisions: readonly ReviewDecision[];
37
+ readonly results: readonly ReviewWorkbenchResult[];
38
+ } | {
39
+ readonly ok: false;
40
+ readonly issues: readonly ReviewSessionApplyIssue[];
41
+ readonly unresolvedItemNames: readonly string[];
42
+ readonly replayedSession?: ReviewQueueSessionState;
43
+ readonly sessionExport?: ReviewWorkbenchSessionExport;
44
+ readonly decisions: readonly ReviewDecision[];
45
+ readonly results: readonly ReviewWorkbenchResult[];
46
+ };
23
47
  export interface ReviewSessionEventStore {
24
48
  load(session: ReviewQueueSessionState): readonly ReviewSessionEvent[] | undefined;
25
49
  save(session: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): void;
@@ -77,9 +101,9 @@ export interface ReviewWorkbenchResult {
77
101
  export declare function buildReviewDecisionsFromSession(session: ReviewQueueSessionState): ReviewDecision[];
78
102
  export declare function buildReviewWorkbenchResultsFromSession(session: ReviewQueueSessionState): ReviewWorkbenchResult[];
79
103
  export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSessionState, events?: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
80
- export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
81
104
  export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
82
105
  export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
106
+ export declare function deriveReviewSessionApplyResultForSnapshot(options: DeriveReviewSessionApplyResultForSnapshotOptions): DeriveReviewSessionApplyResultForSnapshotResult;
83
107
  export declare function createInMemoryReviewSessionEventStore(initialEvents?: readonly ReviewSessionEvent[]): ReviewSessionEventStore & {
84
108
  events(): readonly ReviewSessionEvent[];
85
109
  };
@@ -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,49 +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.candidateId && itemName && itemsByName.has(itemName)) {
122
- const item = itemsByName.get(itemName);
123
- const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
124
- if (!hasCandidate) {
125
- issues.push({
126
- ...eventRef,
127
- code: "unknown-candidate",
128
- reviewItemName: itemName,
129
- candidateId: event.spec.candidateId,
130
- message: `ReviewSessionEvent ${event.metadata.name} references candidate ${event.spec.candidateId}, but ReviewItem ${itemName} in the supplied session snapshot does not contain that candidate.`,
131
- });
132
- }
133
- }
134
- return issues;
135
- });
136
- }
137
96
  export function replayReviewSessionEventsForSnapshot(snapshot, events) {
138
97
  const issues = validateReviewSessionEventsForSnapshot(snapshot, events);
139
98
  if (issues.length > 0) {
@@ -145,6 +104,59 @@ export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events) {
145
104
  const replayedSession = replayReviewSessionEventsForSnapshot(snapshot, events);
146
105
  return buildReviewWorkbenchSessionExport(replayedSession, events);
147
106
  }
107
+ export function deriveReviewSessionApplyResultForSnapshot(options) {
108
+ const requiredResolvedItems = options.requiredResolvedItems ?? "none";
109
+ const replayIssues = validateReviewSessionEventsForSnapshot(options.snapshot, options.events);
110
+ if (replayIssues.length > 0) {
111
+ return {
112
+ ok: false,
113
+ issues: replayIssues,
114
+ unresolvedItemNames: options.snapshot.items.map((item) => item.metadata.name),
115
+ decisions: [],
116
+ results: [],
117
+ };
118
+ }
119
+ const replayedSession = replayReviewSessionEvents(options.snapshot, options.events);
120
+ const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events);
121
+ const resolvedItemNames = new Set(sessionExport.results.map((result) => result.reviewItemName));
122
+ const unresolvedItemNames = options.snapshot.items
123
+ .map((item) => item.metadata.name)
124
+ .filter((itemName) => !resolvedItemNames.has(itemName));
125
+ const issues = [];
126
+ if (requiredResolvedItems === "all") {
127
+ issues.push(...unresolvedItemNames.map((reviewItemName) => ({
128
+ code: "unresolved-review-item",
129
+ reviewItemName,
130
+ message: `Review item ${reviewItemName} has no resolved review decision.`,
131
+ })));
132
+ }
133
+ if (requiredResolvedItems === "any" && sessionExport.results.length === 0) {
134
+ issues.push({
135
+ code: "no-resolved-review-items",
136
+ message: "Review session has no resolved review decisions.",
137
+ });
138
+ }
139
+ if (issues.length > 0) {
140
+ return {
141
+ ok: false,
142
+ issues,
143
+ unresolvedItemNames,
144
+ replayedSession,
145
+ sessionExport,
146
+ decisions: sessionExport.decisions,
147
+ results: sessionExport.results,
148
+ };
149
+ }
150
+ return {
151
+ ok: true,
152
+ issues: [],
153
+ unresolvedItemNames,
154
+ replayedSession,
155
+ sessionExport,
156
+ decisions: sessionExport.decisions,
157
+ results: sessionExport.results,
158
+ };
159
+ }
148
160
  export function createInMemoryReviewSessionEventStore(initialEvents = []) {
149
161
  let savedEvents = [...initialEvents];
150
162
  return {
@@ -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.17",
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",