@kontourai/survey 0.4.16 → 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
@@ -70,10 +70,20 @@ Survey also exposes a framework-neutral review workbench for downstream
70
70
  products that already produce `ReviewItem` queues.
71
71
 
72
72
  ```ts
73
- import { mountReviewWorkbench } from "@kontourai/survey/review-workbench";
73
+ import {
74
+ mountReviewWorkbench,
75
+ type ReviewPresentationAdapter,
76
+ } from "@kontourai/survey/review-workbench";
74
77
  import "@kontourai/survey/review-workbench.css";
75
78
 
76
- mountReviewWorkbench(element, reviewQueueSession);
79
+ const presentationAdapter = {
80
+ labelForTarget: (target) => target === "registrationStatus"
81
+ ? "Registration status"
82
+ : undefined,
83
+ linkForReviewItem: (item) => ({ href: `/review/${item.metadata.name}` }),
84
+ } satisfies ReviewPresentationAdapter;
85
+
86
+ mountReviewWorkbench(element, reviewQueueSession, { presentationAdapter });
77
87
  ```
78
88
 
79
89
  The default stylesheet is scoped to `.survey-workbench-embed` and bundles the
@@ -93,6 +103,13 @@ review events, exported results, and optional Surface projection, see
93
103
  That guide also covers the server-side apply boundary: producers should derive
94
104
  write results from reviewed snapshots plus persisted events, not from
95
105
  browser-computed decisions or exported result payloads.
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 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).
96
113
  For the current decision on why Survey is not adding a generic review adapter
97
114
  builder yet, see
98
115
  [`docs/consumer-adapter-abstraction-assessment.md`](docs/consumer-adapter-abstraction-assessment.md).
@@ -0,0 +1,19 @@
1
+ import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
2
+ import { buildReviewItemPresentation, buildReviewResultPresentation, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, type ReviewPresentationAdapter } from "../../src/review-workbench/review-workbench.js";
3
+ import type { ReviewSessionEvent } from "../../src/review-resource.js";
4
+ export declare const facilityCredentialPresentationAdapter: ReviewPresentationAdapter;
5
+ export declare function buildFacilityCredentialConsumerExample(): Promise<FacilityCredentialConsumerExample>;
6
+ export declare const facilityCredentialConsumerExample: FacilityCredentialConsumerExample;
7
+ export interface FacilityCredentialConsumerExample {
8
+ readonly reviewItem: typeof facilityCredentialReviewItemFixture;
9
+ readonly reviewedSnapshot: ReturnType<typeof initialReviewQueueSessionState>;
10
+ readonly eventsToPersist: readonly ReviewSessionEvent[];
11
+ readonly persistedEvents: readonly ReviewSessionEvent[];
12
+ readonly persistedEventCount: number;
13
+ readonly applyResult: Extract<ReturnType<typeof deriveReviewSessionApplyResultForSnapshot>, {
14
+ readonly ok: true;
15
+ }>;
16
+ readonly itemPresentation: ReturnType<typeof buildReviewItemPresentation>;
17
+ readonly resultPresentation: ReturnType<typeof buildReviewResultPresentation>;
18
+ readonly surfaceProjectionPreview: NonNullable<ReturnType<typeof buildSurfaceProjectionPreview>>;
19
+ }
@@ -0,0 +1,116 @@
1
+ import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
2
+ import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewSessionEvents, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, persistReviewSessionEvents, } from "../../src/review-workbench/review-workbench.js";
3
+ export const facilityCredentialPresentationAdapter = {
4
+ labelForTarget: (target) => target === "operatingLicenseCredential"
5
+ ? "Operating license credential"
6
+ : undefined,
7
+ labelForCandidateRole: (role) => role === "current"
8
+ ? "Current managed credential"
9
+ : role === "proposed"
10
+ ? "Registry candidate"
11
+ : undefined,
12
+ summarizeValue: (value) => {
13
+ if (!isCredentialValue(value)) {
14
+ return undefined;
15
+ }
16
+ const serviceSummary = value.permittedServices.length === 0
17
+ ? "no listed services"
18
+ : value.permittedServices.join(", ");
19
+ return `${value.licenseNumber} is ${value.status} through ${value.expiresAt}; services: ${serviceSummary}`;
20
+ },
21
+ linkForReviewItem: (item) => ({
22
+ label: typeof item.metadata.producer?.displayName === "string"
23
+ ? item.metadata.producer.displayName
24
+ : "Review item",
25
+ href: `/review/items/${encodeURIComponent(item.metadata.name)}`,
26
+ }),
27
+ linkForSource: (sourceRef, { candidate }) => ({
28
+ label: candidate.role === "current" ? "Managed record" : "Registry source",
29
+ href: sourceRef.startsWith("http") ? sourceRef : `/sources/${encodeURIComponent(sourceRef)}`,
30
+ }),
31
+ linkForTraceRef: (ref) => {
32
+ if (ref.kind === "claim") {
33
+ return {
34
+ label: "Claim target",
35
+ href: `/claims/${encodeURIComponent(ref.value)}`,
36
+ };
37
+ }
38
+ if (ref.kind === "candidate-set") {
39
+ return {
40
+ label: "Candidate set",
41
+ href: `/candidate-sets/${encodeURIComponent(ref.value)}`,
42
+ };
43
+ }
44
+ return undefined;
45
+ },
46
+ };
47
+ export async function buildFacilityCredentialConsumerExample() {
48
+ const reviewedSnapshot = {
49
+ ...initialReviewQueueSessionState([facilityCredentialReviewItemFixture]),
50
+ actorId: "review-operator@example.test",
51
+ reviewedAt: "2026-01-17T16:15:00.000Z",
52
+ decisionsByItemName: {
53
+ [facilityCredentialReviewItemFixture.metadata.name]: "accept-proposed",
54
+ },
55
+ notesByItemName: {
56
+ [facilityCredentialReviewItemFixture.metadata.name]: "Registry credential supersedes the managed snapshot.",
57
+ },
58
+ };
59
+ const eventsToPersist = buildReviewSessionEvents(reviewedSnapshot);
60
+ const persistedEvents = [];
61
+ const persisted = await persistReviewSessionEvents({
62
+ session: reviewedSnapshot,
63
+ events: eventsToPersist,
64
+ expectedEventCount: persistedEvents.length,
65
+ persist: async ({ events, expectedEventCount }) => {
66
+ if (expectedEventCount !== persistedEvents.length) {
67
+ throw new Error(`Expected ${expectedEventCount} persisted events, found ${persistedEvents.length}.`);
68
+ }
69
+ persistedEvents.splice(0, persistedEvents.length, ...events);
70
+ return { eventCount: persistedEvents.length };
71
+ },
72
+ });
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;
82
+ if (!result) {
83
+ throw new Error("Expected the reviewed credential snapshot to produce one review result.");
84
+ }
85
+ const itemPresentation = buildReviewItemPresentation(facilityCredentialReviewItemFixture, facilityCredentialPresentationAdapter);
86
+ const resultPresentation = buildReviewResultPresentation(result, facilityCredentialReviewItemFixture, facilityCredentialPresentationAdapter);
87
+ const surfaceProjectionPreview = buildSurfaceProjectionPreview(facilityCredentialReviewItemFixture, result.reviewDecision, facilityCredentialPresentationAdapter);
88
+ if (!surfaceProjectionPreview) {
89
+ throw new Error("Expected the reviewed credential result to produce a Surface projection preview.");
90
+ }
91
+ return {
92
+ reviewItem: facilityCredentialReviewItemFixture,
93
+ reviewedSnapshot,
94
+ eventsToPersist,
95
+ persistedEvents: persisted.events,
96
+ persistedEventCount: persisted.eventCount,
97
+ applyResult,
98
+ itemPresentation,
99
+ resultPresentation,
100
+ surfaceProjectionPreview,
101
+ };
102
+ }
103
+ export const facilityCredentialConsumerExample = await buildFacilityCredentialConsumerExample();
104
+ function isCredentialValue(value) {
105
+ return typeof value === "object"
106
+ && value !== null
107
+ && "licenseNumber" in value
108
+ && typeof value.licenseNumber === "string"
109
+ && "status" in value
110
+ && typeof value.status === "string"
111
+ && "expiresAt" in value
112
+ && typeof value.expiresAt === "string"
113
+ && "permittedServices" in value
114
+ && Array.isArray(value.permittedServices)
115
+ && value.permittedServices.every((service) => typeof service === "string");
116
+ }
@@ -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;
@@ -36,8 +67,19 @@ export interface ReviewSessionPersistenceRequest {
36
67
  readonly expectedEventCount: number;
37
68
  }
38
69
  export interface ReviewSessionPersistenceResult {
70
+ readonly events?: readonly ReviewSessionEvent[];
39
71
  readonly eventCount?: number;
40
72
  }
73
+ export interface PersistReviewSessionEventsOptions {
74
+ readonly session: ReviewQueueSessionState;
75
+ readonly events: readonly ReviewSessionEvent[];
76
+ readonly expectedEventCount?: number;
77
+ readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
78
+ }
79
+ export interface PersistReviewSessionEventsResult {
80
+ readonly events: readonly ReviewSessionEvent[];
81
+ readonly eventCount: number;
82
+ }
41
83
  export interface PersistentReviewSessionEventStoreOptions {
42
84
  readonly initialEvents?: readonly ReviewSessionEvent[];
43
85
  readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
@@ -69,10 +111,12 @@ export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSe
69
111
  export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
70
112
  export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
71
113
  export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
114
+ export declare function deriveReviewSessionApplyResultForSnapshot(options: DeriveReviewSessionApplyResultForSnapshotOptions): DeriveReviewSessionApplyResultForSnapshotResult;
72
115
  export declare function createInMemoryReviewSessionEventStore(initialEvents?: readonly ReviewSessionEvent[]): ReviewSessionEventStore & {
73
116
  events(): readonly ReviewSessionEvent[];
74
117
  };
75
118
  export declare function createLocalStorageReviewSessionEventStore(storage: Pick<Storage, "getItem" | "setItem">, keyPrefix?: string): ReviewSessionEventStore;
119
+ export declare function persistReviewSessionEvents(options: PersistReviewSessionEventsOptions): Promise<PersistReviewSessionEventsResult>;
76
120
  export declare function createPersistentReviewSessionEventStore(options: PersistentReviewSessionEventStoreOptions): ReviewSessionEventStore & {
77
121
  events(): readonly ReviewSessionEvent[];
78
122
  };
@@ -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 {
@@ -175,6 +279,19 @@ export function createLocalStorageReviewSessionEventStore(storage, keyPrefix = r
175
279
  },
176
280
  };
177
281
  }
282
+ export async function persistReviewSessionEvents(options) {
283
+ const events = [...options.events];
284
+ const result = await options.persist({
285
+ session: options.session,
286
+ events,
287
+ expectedEventCount: options.expectedEventCount ?? 0,
288
+ });
289
+ const persistedEvents = result?.events ? [...result.events] : events;
290
+ return {
291
+ events: persistedEvents,
292
+ eventCount: result?.eventCount ?? persistedEvents.length,
293
+ };
294
+ }
178
295
  export function createPersistentReviewSessionEventStore(options) {
179
296
  let savedEvents = [...(options.initialEvents ?? [])];
180
297
  let lastPersistedSerialized = JSON.stringify(savedEvents);
@@ -198,15 +315,16 @@ export function createPersistentReviewSessionEventStore(options) {
198
315
  return;
199
316
  }
200
317
  emit({ status: "saving", events: normalizedEvents });
201
- const result = await options.persist({
318
+ const result = await persistReviewSessionEvents({
202
319
  session,
203
320
  events: normalizedEvents,
204
321
  expectedEventCount: lastPersistedEventCount,
322
+ persist: options.persist,
205
323
  });
206
- savedEvents = normalizedEvents;
207
- lastPersistedSerialized = serialized;
208
- lastPersistedEventCount = result?.eventCount ?? normalizedEvents.length;
209
- emit({ status: "saved", events: normalizedEvents });
324
+ savedEvents = result.events;
325
+ lastPersistedSerialized = JSON.stringify(result.events);
326
+ lastPersistedEventCount = result.eventCount;
327
+ emit({ status: "saved", events: result.events });
210
328
  })
211
329
  .catch((error) => {
212
330
  emit({ status: "error", events: normalizedEvents, error });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "0.4.16",
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",