@kontourai/survey 0.4.17 → 0.4.19

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
@@ -103,8 +103,11 @@ review events, exported results, and optional Surface projection, see
103
103
  That guide also covers the server-side apply boundary: producers should derive
104
104
  write results from reviewed 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.
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.
108
111
  For a generic, test-covered example of the consumer adapter contract, see
109
112
  [`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts).
110
113
  For the current decision on why Survey is not adding a generic review adapter
@@ -1,5 +1,5 @@
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>;
@@ -10,7 +10,9 @@ export interface FacilityCredentialConsumerExample {
10
10
  readonly eventsToPersist: readonly ReviewSessionEvent[];
11
11
  readonly persistedEvents: readonly ReviewSessionEvent[];
12
12
  readonly persistedEventCount: number;
13
- readonly sessionExport: ReturnType<typeof buildReviewWorkbenchSessionExportForSnapshot>;
13
+ readonly applyResult: Extract<ReturnType<typeof deriveReviewSessionApplyResultForSnapshot>, {
14
+ readonly ok: true;
15
+ }>;
14
16
  readonly itemPresentation: ReturnType<typeof buildReviewItemPresentation>;
15
17
  readonly resultPresentation: ReturnType<typeof buildReviewResultPresentation>;
16
18
  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"
@@ -70,8 +70,15 @@ export async function buildFacilityCredentialConsumerExample() {
70
70
  return { eventCount: persistedEvents.length };
71
71
  },
72
72
  });
73
- const sessionExport = buildReviewWorkbenchSessionExportForSnapshot(reviewedSnapshot, persisted.events);
74
- const [result] = sessionExport.results;
73
+ const applyResult = deriveReviewSessionApplyResultForSnapshot({
74
+ snapshot: reviewedSnapshot,
75
+ events: persisted.events,
76
+ requiredResolvedItems: "all",
77
+ });
78
+ if (!applyResult.ok) {
79
+ throw new Error(`Expected persisted credential events to replay before apply: ${applyResult.issues.map((issue) => issue.message).join(" ")}`);
80
+ }
81
+ const [result] = applyResult.results;
75
82
  if (!result) {
76
83
  throw new Error("Expected the reviewed credential snapshot to produce one review result.");
77
84
  }
@@ -87,7 +94,7 @@ export async function buildFacilityCredentialConsumerExample() {
87
94
  eventsToPersist,
88
95
  persistedEvents: persisted.events,
89
96
  persistedEventCount: persisted.eventCount,
90
- sessionExport,
97
+ applyResult,
91
98
  itemPresentation,
92
99
  resultPresentation,
93
100
  surfaceProjectionPreview,
@@ -11,7 +11,7 @@ export interface ReviewWorkbenchSessionExport {
11
11
  readonly decisions: readonly ReviewDecision[];
12
12
  readonly results: readonly ReviewWorkbenchResult[];
13
13
  }
14
- export type ReviewSessionReplayIssueCode = "unknown-active-item" | "unknown-review-item" | "unknown-candidate";
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
15
  export interface ReviewSessionReplayIssue {
16
16
  readonly code: ReviewSessionReplayIssueCode;
17
17
  readonly eventName: string;
@@ -20,6 +20,37 @@ export interface ReviewSessionReplayIssue {
20
20
  readonly candidateId?: string;
21
21
  readonly message: string;
22
22
  }
23
+ export type ReviewSessionApplyResolutionRequirement = "all" | "any" | "none";
24
+ export type ReviewSessionApplyIssue = ReviewSessionReplayIssue | {
25
+ readonly code: "unresolved-review-item";
26
+ readonly reviewItemName: string;
27
+ readonly message: string;
28
+ } | {
29
+ readonly code: "no-resolved-review-items";
30
+ readonly message: string;
31
+ };
32
+ export interface DeriveReviewSessionApplyResultForSnapshotOptions {
33
+ readonly snapshot: ReviewQueueSessionState;
34
+ readonly events: readonly ReviewSessionEvent[];
35
+ readonly requiredResolvedItems?: ReviewSessionApplyResolutionRequirement;
36
+ }
37
+ export type DeriveReviewSessionApplyResultForSnapshotResult = {
38
+ readonly ok: true;
39
+ readonly issues: readonly [];
40
+ readonly unresolvedItemNames: readonly string[];
41
+ readonly replayedSession: ReviewQueueSessionState;
42
+ readonly sessionExport: ReviewWorkbenchSessionExport;
43
+ readonly decisions: readonly ReviewDecision[];
44
+ readonly results: readonly ReviewWorkbenchResult[];
45
+ } | {
46
+ readonly ok: false;
47
+ readonly issues: readonly ReviewSessionApplyIssue[];
48
+ readonly unresolvedItemNames: readonly string[];
49
+ readonly replayedSession?: ReviewQueueSessionState;
50
+ readonly sessionExport?: ReviewWorkbenchSessionExport;
51
+ readonly decisions: readonly ReviewDecision[];
52
+ readonly results: readonly ReviewWorkbenchResult[];
53
+ };
23
54
  export interface ReviewSessionEventStore {
24
55
  load(session: ReviewQueueSessionState): readonly ReviewSessionEvent[] | undefined;
25
56
  save(session: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): void;
@@ -80,6 +111,7 @@ export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSe
80
111
  export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
81
112
  export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
82
113
  export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
114
+ export declare function deriveReviewSessionApplyResultForSnapshot(options: DeriveReviewSessionApplyResultForSnapshotOptions): DeriveReviewSessionApplyResultForSnapshotResult;
83
115
  export declare function createInMemoryReviewSessionEventStore(initialEvents?: readonly ReviewSessionEvent[]): ReviewSessionEventStore & {
84
116
  events(): readonly ReviewSessionEvent[];
85
117
  };
@@ -118,6 +118,52 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
118
118
  message: `ReviewSessionEvent ${event.metadata.name} references review item ${reviewItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
119
119
  });
120
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
+ }
121
167
  if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
122
168
  const item = itemsByName.get(itemName);
123
169
  const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
@@ -134,6 +180,11 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
134
180
  return issues;
135
181
  });
136
182
  }
183
+ function replayableWorkbenchDecision(value) {
184
+ return typeof value === "string" && value in workbenchDecisionDefinitions
185
+ ? value
186
+ : undefined;
187
+ }
137
188
  export function replayReviewSessionEventsForSnapshot(snapshot, events) {
138
189
  const issues = validateReviewSessionEventsForSnapshot(snapshot, events);
139
190
  if (issues.length > 0) {
@@ -145,6 +196,59 @@ export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events) {
145
196
  const replayedSession = replayReviewSessionEventsForSnapshot(snapshot, events);
146
197
  return buildReviewWorkbenchSessionExport(replayedSession, events);
147
198
  }
199
+ export function deriveReviewSessionApplyResultForSnapshot(options) {
200
+ const requiredResolvedItems = options.requiredResolvedItems ?? "none";
201
+ const replayIssues = validateReviewSessionEventsForSnapshot(options.snapshot, options.events);
202
+ if (replayIssues.length > 0) {
203
+ return {
204
+ ok: false,
205
+ issues: replayIssues,
206
+ unresolvedItemNames: options.snapshot.items.map((item) => item.metadata.name),
207
+ decisions: [],
208
+ results: [],
209
+ };
210
+ }
211
+ const replayedSession = replayReviewSessionEvents(options.snapshot, options.events);
212
+ const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events);
213
+ const resolvedItemNames = new Set(sessionExport.results.map((result) => result.reviewItemName));
214
+ const unresolvedItemNames = options.snapshot.items
215
+ .map((item) => item.metadata.name)
216
+ .filter((itemName) => !resolvedItemNames.has(itemName));
217
+ const issues = [];
218
+ if (requiredResolvedItems === "all") {
219
+ issues.push(...unresolvedItemNames.map((reviewItemName) => ({
220
+ code: "unresolved-review-item",
221
+ reviewItemName,
222
+ message: `Review item ${reviewItemName} has no resolved review decision.`,
223
+ })));
224
+ }
225
+ if (requiredResolvedItems === "any" && sessionExport.results.length === 0) {
226
+ issues.push({
227
+ code: "no-resolved-review-items",
228
+ message: "Review session has no resolved review decisions.",
229
+ });
230
+ }
231
+ if (issues.length > 0) {
232
+ return {
233
+ ok: false,
234
+ issues,
235
+ unresolvedItemNames,
236
+ replayedSession,
237
+ sessionExport,
238
+ decisions: sessionExport.decisions,
239
+ results: sessionExport.results,
240
+ };
241
+ }
242
+ return {
243
+ ok: true,
244
+ issues: [],
245
+ unresolvedItemNames,
246
+ replayedSession,
247
+ sessionExport,
248
+ decisions: sessionExport.decisions,
249
+ results: sessionExport.results,
250
+ };
251
+ }
148
252
  export function createInMemoryReviewSessionEventStore(initialEvents = []) {
149
253
  let savedEvents = [...initialEvents];
150
254
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
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",