@kontourai/survey 3.0.0 → 4.0.0

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.
Files changed (37) hide show
  1. package/README.md +4 -0
  2. package/dist/example-data/public-directory-review-resource.d.ts +3 -3
  3. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  4. package/dist/examples/calibrated-auto-accept.js +40 -36
  5. package/dist/src/calibration.d.ts +48 -21
  6. package/dist/src/calibration.js +72 -33
  7. package/dist/src/console/review-console-server.d.ts +3 -1
  8. package/dist/src/console/review-console-server.js +203 -50
  9. package/dist/src/extraction-envelope.d.ts +22 -0
  10. package/dist/src/extraction-envelope.js +25 -4
  11. package/dist/src/index.d.ts +8 -7
  12. package/dist/src/index.js +2 -2
  13. package/dist/src/inquiry-mapping.d.ts +15 -1
  14. package/dist/src/inquiry-mapping.js +10 -2
  15. package/dist/src/mcp/review-mcp.js +70 -65
  16. package/dist/src/producer-profile.d.ts +41 -2
  17. package/dist/src/producer-profile.js +29 -2
  18. package/dist/src/review-session-file.d.ts +64 -0
  19. package/dist/src/review-session-file.js +320 -0
  20. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  21. package/dist/src/review-workbench/edited-value.js +147 -0
  22. package/dist/src/review-workbench/review-presentation.d.ts +44 -0
  23. package/dist/src/review-workbench/review-presentation.js +49 -0
  24. package/dist/src/review-workbench/review-queue-session.js +8 -1
  25. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  26. package/dist/src/review-workbench/review-session-replay.js +77 -0
  27. package/dist/src/review-workbench/review-workbench.d.ts +8 -4
  28. package/dist/src/review-workbench/review-workbench.js +19 -7
  29. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  30. package/dist/src/review-workbench/server-review-session.js +1 -0
  31. package/dist/src/reviewed-candidate-resolution.js +13 -7
  32. package/dist/src/schema-mapping.d.ts +23 -0
  33. package/dist/src/schema-mapping.js +30 -20
  34. package/dist/src/to-surface.d.ts +30 -6
  35. package/dist/src/to-surface.js +196 -18
  36. package/dist/src/types.d.ts +39 -0
  37. package/package.json +5 -4
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Shared session-file persistence for the local review writers
3
+ * (`survey-review-console` and `survey-review-mcp`).
4
+ *
5
+ * Every write goes through {@link updateReviewSessionFile}, which takes one
6
+ * exclusive lock file next to the session, re-reads the session inside the
7
+ * lock, applies the caller's change, writes a uniquely named temp file and
8
+ * renames it into place. Both writers must use this helper: a lock that only
9
+ * one of them takes still races with the other (kontourai/survey#281).
10
+ */
11
+ import { execFile } from "node:child_process";
12
+ import { createHash, randomUUID } from "node:crypto";
13
+ import { open, readdir, readFile, rename, rm, stat } from "node:fs/promises";
14
+ import { basename, dirname, join } from "node:path";
15
+ import { defaultReviewSessionName } from "./review-workbench/review-queue-session.js";
16
+ export class ReviewSessionFileLockTimeoutError extends Error {
17
+ constructor(lockPath, timeoutMs) {
18
+ super(`Timed out after ${timeoutMs}ms waiting for session lock ${lockPath}`);
19
+ this.name = "ReviewSessionFileLockTimeoutError";
20
+ }
21
+ }
22
+ const DEFAULT_TIMEOUT_MS = 10_000;
23
+ const DEFAULT_STALE_MS = 30_000;
24
+ /** A lock file that is still empty this long after creation was left by an acquirer that died between create and write. */
25
+ const EMPTY_LOCK_STALE_MS = 1_000;
26
+ /** The break mutex is held for a few milliseconds; older means its holder died mid-break. */
27
+ const BREAK_MUTEX_STALE_MS = 5_000;
28
+ const RETRY_MIN_MS = 10;
29
+ const RETRY_MAX_MS = 50;
30
+ export function reviewSessionLockPath(sessionPath) {
31
+ return `${sessionPath}.lock`;
32
+ }
33
+ export async function readReviewSessionFile(sessionPath) {
34
+ return JSON.parse(await readFile(sessionPath, "utf8"));
35
+ }
36
+ /**
37
+ * Opaque revision token for an event log: a digest of its serialized form.
38
+ * An event count is not enough, because the log is regenerated from session
39
+ * state and a changed decision keeps the count while changing the content.
40
+ */
41
+ export function reviewSessionRevision(events) {
42
+ return createHash("sha256").update(JSON.stringify(events)).digest("hex").slice(0, 32);
43
+ }
44
+ function isPidAlive(pid) {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ return error.code === "EPERM";
51
+ }
52
+ }
53
+ /**
54
+ * An identity for the process currently running as `pid`: its start time, as
55
+ * the OS reports it. A pid can be reused after its holder dies, so a live pid
56
+ * only proves the holder is alive when the start time also matches the one
57
+ * the holder recorded (kontourai/survey#298). Linux reads `/proc/<pid>/stat`
58
+ * field 22 (start time in clock ticks since boot); elsewhere `ps -o lstart=`
59
+ * (one-second resolution), rendered in UTC with the C locale so every reader
60
+ * spells the same instant the same way whatever its own TZ or locale. Resolves `undefined` when the platform offers
61
+ * neither or the process is gone, which leaves the pid-only rule in force.
62
+ */
63
+ async function processStartIdentity(pid) {
64
+ if (process.platform === "linux") {
65
+ try {
66
+ const procStat = await readFile(`/proc/${pid}/stat`, "utf8");
67
+ // Fields after the parenthesized command name, which may contain spaces.
68
+ const fields = procStat.slice(procStat.lastIndexOf(")") + 2).split(" ");
69
+ const startTicks = fields[19];
70
+ return startTicks ? `linux-starttime:${startTicks}` : undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ if (process.platform === "win32")
77
+ return undefined;
78
+ return new Promise((resolveIdentity) => {
79
+ execFile("ps", ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 2_000 }, (error, stdout) => {
80
+ const started = error ? "" : stdout.trim().replace(/\s+/g, " ");
81
+ resolveIdentity(started ? `ps-lstart:${started}` : undefined);
82
+ });
83
+ });
84
+ }
85
+ let ownStartIdentity;
86
+ /**
87
+ * A lock is stale only when we can show its holder is gone, never merely
88
+ * because it is old. Liveness is authoritative whenever the lock carries a
89
+ * usable pid: a holder that is alive but slow (age past `staleMs`) is NOT
90
+ * stale — age alone used to be enough to break it, which let a live-but-slow
91
+ * holder's lock be removed out from under it (kontourai/survey#281 review:
92
+ * 135 double-hold events in 60 rounds of the reviewer's stress test). We
93
+ * deliberately do not add a hard age ceiling that overrides a live pid: with
94
+ * the default timeoutMs (10s) well under the default staleMs (30s), a waiter
95
+ * simply times out with {@link ReviewSessionFileLockTimeoutError} against a
96
+ * live holder that never releases in time, which is a loud, safe failure
97
+ * mode rather than a silent double-hold. A genuinely wedged holder (hung
98
+ * forever) requires an operator to remove the lock file by hand; that is the
99
+ * accepted cost of "never remove a live lock" actually holding.
100
+ *
101
+ * The age rule is kept only as the fallback for a lock we cannot judge by
102
+ * liveness: no pid field (unknown-PID); a live pid whose current start time
103
+ * differs from the one the holder recorded, i.e. the holder died and its pid
104
+ * was reused by an unrelated process (kontourai/survey#298); or, since the lock carries no host
105
+ * identity, a pid from another host/container sharing this volume (`isPidAlive`
106
+ * is meaningless there, in either direction — the residual this repo has
107
+ * always accepted for that case).
108
+ */
109
+ async function lockIsStale(lockPath, staleMs) {
110
+ try {
111
+ const info = await stat(lockPath);
112
+ const age = Date.now() - info.mtimeMs;
113
+ const raw = await readFile(lockPath, "utf8");
114
+ if (raw.trim() === "")
115
+ return age > EMPTY_LOCK_STALE_MS;
116
+ const holder = JSON.parse(raw);
117
+ if (typeof holder.pid === "number") {
118
+ if (!isPidAlive(holder.pid))
119
+ return true;
120
+ if (typeof holder.startIdentity !== "string")
121
+ return false;
122
+ const current = await processStartIdentity(holder.pid);
123
+ // Unknown now (or never recorded): trust liveness, as before.
124
+ if (current === undefined || current === holder.startIdentity)
125
+ return false;
126
+ }
127
+ return age > staleMs;
128
+ }
129
+ catch {
130
+ // Vanished (released) or half-written by a live acquirer: not stale.
131
+ return false;
132
+ }
133
+ }
134
+ /**
135
+ * Remove a stale lock without ever removing a live one.
136
+ *
137
+ * Breakers serialize on a separate `<lock>.break` mutex and re-judge the lock
138
+ * inside it. While a breaker holds the mutex, the lock file can only change if
139
+ * its owner releases it, and {@link lockIsStale} only calls a lock stale once
140
+ * its holder is provably dead (or, lacking a usable pid, sufficiently old), so
141
+ * the file the breaker removes is the one it judged stale. The earlier
142
+ * rename-aside scheme let two waiters that both judged the same lock stale
143
+ * each remove it, the second removing the first's fresh lock
144
+ * (kontourai/survey#281 review). A holder that is merely alive-but-slow is
145
+ * never broken, by construction, no matter its age (see {@link lockIsStale}).
146
+ * Residual (accepted): a breaker that dies inside the few-millisecond break
147
+ * section leaves a mutex that is removed after BREAK_MUTEX_STALE_MS, and pid
148
+ * liveness means nothing across hosts or containers that share the session
149
+ * volume (the age rule is the only signal available there, and it is a
150
+ * pre-existing limitation, not one this fix introduces).
151
+ */
152
+ async function breakStaleLock(lockPath, staleMs) {
153
+ const breakPath = `${lockPath}.break`;
154
+ try {
155
+ await (await open(breakPath, "wx")).close();
156
+ }
157
+ catch (error) {
158
+ if (error.code !== "EEXIST")
159
+ throw error;
160
+ try {
161
+ if (Date.now() - (await stat(breakPath)).mtimeMs > BREAK_MUTEX_STALE_MS) {
162
+ await rm(breakPath, { force: true });
163
+ }
164
+ }
165
+ catch {
166
+ // Released meanwhile.
167
+ }
168
+ return;
169
+ }
170
+ try {
171
+ if (await lockIsStale(lockPath, staleMs)) {
172
+ await rm(lockPath, { force: true });
173
+ }
174
+ }
175
+ finally {
176
+ await rm(breakPath, { force: true });
177
+ }
178
+ }
179
+ /**
180
+ * Acquire the exclusive session lock. Resolves with a release function.
181
+ * Exported so tests can hold the lock and observe that writers wait for it.
182
+ */
183
+ export async function acquireReviewSessionFileLock(sessionPath, options = {}) {
184
+ const lockPath = reviewSessionLockPath(sessionPath);
185
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
186
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
187
+ const deadline = Date.now() + timeoutMs;
188
+ const token = randomUUID();
189
+ ownStartIdentity ??= processStartIdentity(process.pid);
190
+ const startIdentity = await ownStartIdentity;
191
+ for (;;) {
192
+ try {
193
+ const handle = await open(lockPath, "wx");
194
+ try {
195
+ await handle.writeFile(JSON.stringify({ pid: process.pid, ...(startIdentity ? { startIdentity } : {}), token, acquiredAt: new Date().toISOString() }));
196
+ }
197
+ finally {
198
+ await handle.close();
199
+ }
200
+ return async () => {
201
+ // Only remove the lock if it is still ours (a stale-lock breaker may
202
+ // have replaced it after we were presumed dead).
203
+ try {
204
+ const holder = JSON.parse(await readFile(lockPath, "utf8"));
205
+ if (holder.token === token)
206
+ await rm(lockPath, { force: true });
207
+ }
208
+ catch {
209
+ // Already gone.
210
+ }
211
+ };
212
+ }
213
+ catch (error) {
214
+ if (error.code !== "EEXIST")
215
+ throw error;
216
+ }
217
+ if (await lockIsStale(lockPath, staleMs)) {
218
+ await breakStaleLock(lockPath, staleMs);
219
+ continue;
220
+ }
221
+ if (Date.now() >= deadline) {
222
+ throw new ReviewSessionFileLockTimeoutError(lockPath, timeoutMs);
223
+ }
224
+ const delay = RETRY_MIN_MS + Math.floor(Math.random() * (RETRY_MAX_MS - RETRY_MIN_MS));
225
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, delay));
226
+ }
227
+ }
228
+ function tempFilePattern(sessionPath) {
229
+ const escaped = basename(sessionPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
230
+ return new RegExp(`^${escaped}\\.\\d+\\.[0-9a-f-]{36}\\.tmp$`);
231
+ }
232
+ /**
233
+ * Remove temp files a crashed writer left between write and rename. Called
234
+ * only while holding the lock, when no other writer can have a temp file in
235
+ * flight, so every match is an orphan.
236
+ */
237
+ async function removeOrphanTempFiles(sessionPath) {
238
+ const pattern = tempFilePattern(sessionPath);
239
+ let entries;
240
+ try {
241
+ entries = await readdir(dirname(sessionPath));
242
+ }
243
+ catch {
244
+ return;
245
+ }
246
+ await Promise.all(entries.filter((entry) => pattern.test(entry)).map((entry) => rm(join(dirname(sessionPath), entry), { force: true })));
247
+ }
248
+ async function writeReviewSessionFileAtomic(sessionPath, content) {
249
+ const tmp = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;
250
+ try {
251
+ const handle = await open(tmp, "wx");
252
+ try {
253
+ await handle.writeFile(JSON.stringify(content, null, 2), "utf8");
254
+ // Durable before it becomes visible: a crash after the rename must not
255
+ // leave a renamed but empty or partial session file.
256
+ await handle.sync();
257
+ }
258
+ finally {
259
+ await handle.close();
260
+ }
261
+ await rename(tmp, sessionPath);
262
+ }
263
+ catch (error) {
264
+ await rm(tmp, { force: true });
265
+ throw error;
266
+ }
267
+ }
268
+ /**
269
+ * Locked read-modify-write of a session file. `mutate` receives the content
270
+ * read inside the lock and returns the content to write, or `undefined` to
271
+ * leave the file untouched. Whatever `mutate` throws propagates after the lock
272
+ * is released.
273
+ */
274
+ export async function updateReviewSessionFile(sessionPath, mutate, options = {}) {
275
+ const release = await acquireReviewSessionFileLock(sessionPath, options);
276
+ try {
277
+ await removeOrphanTempFiles(sessionPath);
278
+ const current = await readReviewSessionFile(sessionPath);
279
+ const { next, result } = await mutate(current);
280
+ if (next !== undefined) {
281
+ await writeReviewSessionFileAtomic(sessionPath, next);
282
+ }
283
+ return result;
284
+ }
285
+ finally {
286
+ await release();
287
+ }
288
+ }
289
+ /**
290
+ * The session name the stored log is recorded under: the name its events
291
+ * already carry, else the stored ReviewSession's name, else `fallback` (the
292
+ * writer's own default). Appended events are renamed into it so one log never mixes names.
293
+ */
294
+ export function storedReviewSessionName(content, fallback = defaultReviewSessionName) {
295
+ const fromEvents = content.events[0]?.spec.sessionName;
296
+ if (fromEvents)
297
+ return fromEvents;
298
+ const session = content.session;
299
+ return typeof session?.metadata?.name === "string" ? session.metadata.name : fallback;
300
+ }
301
+ /**
302
+ * Append events to the stored log, renumbering them after the stored events
303
+ * and renaming them into the stored session. The stored log is never
304
+ * rewritten, so decision reversals and note changes stay on record.
305
+ */
306
+ export function appendReviewSessionEvents(content, appended) {
307
+ const sessionName = storedReviewSessionName(content);
308
+ const start = content.events.length;
309
+ const renumbered = [...appended]
310
+ .sort((left, right) => left.spec.sequence - right.spec.sequence)
311
+ .map((event, index) => {
312
+ const sequence = start + index + 1;
313
+ return {
314
+ ...event,
315
+ metadata: { ...event.metadata, name: `${sessionName}-${String(sequence).padStart(4, "0")}-${event.spec.eventType}` },
316
+ spec: { ...event.spec, sessionName, sequence },
317
+ };
318
+ });
319
+ return [...content.events, ...renumbered];
320
+ }
@@ -0,0 +1,70 @@
1
+ import type { ReviewItem, ReviewValueDescriptor } from "../review-resource.js";
2
+ /**
3
+ * Result of checking an inline edit (`data.workbenchEditedValue` on an
4
+ * `accept-proposed` decision event) against the ReviewItem it edits.
5
+ *
6
+ * - `ok: true` — the edit is allowed; `value` is the value to store. When
7
+ * `convertedFromText` is true, the stored edit was descriptor-typed text
8
+ * written by an older workbench (for example `"42"` on a number field) and
9
+ * `value` is its typed form (`42`).
10
+ * - `ok: false` — the edit must be refused: the item is not editable, or the
11
+ * value does not satisfy the item's `valueDescriptor`.
12
+ */
13
+ export type EditedValueCheck = {
14
+ readonly ok: true;
15
+ readonly value: unknown;
16
+ readonly convertedFromText: boolean;
17
+ } | {
18
+ readonly ok: false;
19
+ readonly code: "edited-value-not-editable" | "edited-value-type-mismatch";
20
+ readonly message: string;
21
+ };
22
+ /**
23
+ * True for a `YYYY-MM-DD` string naming a real calendar day. The Y/M/D must
24
+ * round-trip through a UTC date, so an impossible day such as `2026-02-31`
25
+ * (which `Date.parse` silently rolls over to March) is refused.
26
+ */
27
+ export declare function isIsoCalendarDate(value: string): boolean;
28
+ /**
29
+ * Parses number-field editor text, or returns `undefined` when it is not a
30
+ * plain decimal or would not survive storage as a JSON number exactly.
31
+ *
32
+ * Integer-only text (no `.` and no exponent) is checked by
33
+ * `Number.isSafeInteger` instead of the digit count: every safe integer
34
+ * (|value| ≤ 2^53−1, i.e. up to 9007199254740991, 16 digits) round-trips
35
+ * through a JSON number exactly, so the 15-significant-digit rule would
36
+ * wrongly refuse `9007199254740991` while accepting some 15-digit values
37
+ * that are actually less precise. `9007199254740993` is refused: it is
38
+ * outside the safe range and `Number(...)` rounds it to `9007199254740992`,
39
+ * a different integer than the text named.
40
+ *
41
+ * A fractional value (has `.` or an exponent) keeps the 15-significant-digit
42
+ * rule: `Number.isSafeInteger` does not apply to it, and IEEE 754 doubles
43
+ * only guarantee exactness up to 15 significant decimal digits, so anything
44
+ * longer is refused rather than silently rounded. A value that overflows to
45
+ * Infinity is refused. `-0` is stored as `0` (JSON has no negative zero).
46
+ */
47
+ export declare function parsePlainDecimal(text: string): number | undefined;
48
+ /**
49
+ * Converts a reviewer's editor text to the JSON type the item's descriptor
50
+ * declares, using the same parsing rules the workbench's `validateProposedValue`
51
+ * checks. Returns `undefined` when the text does not parse for that type. With
52
+ * no descriptor, or a type that has no single-line form (string, array, object),
53
+ * the text is returned unchanged.
54
+ */
55
+ export declare function editedValueFromEditorText(descriptor: ReviewValueDescriptor | undefined, text: string): unknown;
56
+ /**
57
+ * Checks an inline edit carried by an `accept-proposed` decision against the
58
+ * item's `editable` flag and `valueDescriptor`. This is the server-side
59
+ * counterpart of the browser editor's validation (kontourai/survey#278): the
60
+ * apply boundary must not trust an edit the workbench would never have let a
61
+ * reviewer make.
62
+ *
63
+ * Legacy sessions: workbenches before this check stored every edit as the
64
+ * editor's text, so a number or boolean edit arrives as `"42"` / `"true"`. Such
65
+ * text (and date/enum text with surrounding whitespace, which the old editor
66
+ * accepted and stored untrimmed) is converted to its typed value when it parses
67
+ * cleanly under the workbench's own rules (`convertedFromText: true`, reported as a warning by the
68
+ * apply derivation); text that does not parse is refused.
69
+ */
70
+ export declare function checkEditedValueForItem(item: ReviewItem, value: unknown): EditedValueCheck;
@@ -0,0 +1,147 @@
1
+ const isoDatePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
2
+ /**
3
+ * True for a `YYYY-MM-DD` string naming a real calendar day. The Y/M/D must
4
+ * round-trip through a UTC date, so an impossible day such as `2026-02-31`
5
+ * (which `Date.parse` silently rolls over to March) is refused.
6
+ */
7
+ export function isIsoCalendarDate(value) {
8
+ const match = isoDatePattern.exec(value);
9
+ if (!match)
10
+ return false;
11
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
12
+ const date = new Date(Date.UTC(year, month - 1, day));
13
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
14
+ }
15
+ // Plain decimal only: optional minus, no leading zeros, optional fraction and
16
+ // exponent. Refuses hex/binary/octal forms, "Infinity", "+1", ".5" and "007".
17
+ const plainDecimalPattern = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
18
+ /** A JSON number keeps at most 15 significant decimal digits exactly (IEEE 754 double). */
19
+ const maxSignificantDigits = 15;
20
+ /**
21
+ * Parses number-field editor text, or returns `undefined` when it is not a
22
+ * plain decimal or would not survive storage as a JSON number exactly.
23
+ *
24
+ * Integer-only text (no `.` and no exponent) is checked by
25
+ * `Number.isSafeInteger` instead of the digit count: every safe integer
26
+ * (|value| ≤ 2^53−1, i.e. up to 9007199254740991, 16 digits) round-trips
27
+ * through a JSON number exactly, so the 15-significant-digit rule would
28
+ * wrongly refuse `9007199254740991` while accepting some 15-digit values
29
+ * that are actually less precise. `9007199254740993` is refused: it is
30
+ * outside the safe range and `Number(...)` rounds it to `9007199254740992`,
31
+ * a different integer than the text named.
32
+ *
33
+ * A fractional value (has `.` or an exponent) keeps the 15-significant-digit
34
+ * rule: `Number.isSafeInteger` does not apply to it, and IEEE 754 doubles
35
+ * only guarantee exactness up to 15 significant decimal digits, so anything
36
+ * longer is refused rather than silently rounded. A value that overflows to
37
+ * Infinity is refused. `-0` is stored as `0` (JSON has no negative zero).
38
+ */
39
+ export function parsePlainDecimal(text) {
40
+ const match = plainDecimalPattern.exec(text);
41
+ if (!match)
42
+ return undefined;
43
+ const isIntegerOnly = match[2] === undefined && match[3] === undefined;
44
+ if (isIntegerOnly) {
45
+ const parsed = Number(text);
46
+ if (!Number.isSafeInteger(parsed))
47
+ return undefined;
48
+ return parsed === 0 ? 0 : parsed;
49
+ }
50
+ const digits = `${match[1]}${(match[2] ?? "").slice(1)}`.replace(/^0+/, "").replace(/0+$/, "");
51
+ if (digits.length > maxSignificantDigits)
52
+ return undefined;
53
+ const parsed = Number(text);
54
+ if (!Number.isFinite(parsed))
55
+ return undefined;
56
+ return parsed === 0 ? 0 : parsed;
57
+ }
58
+ /**
59
+ * Converts a reviewer's editor text to the JSON type the item's descriptor
60
+ * declares, using the same parsing rules the workbench's `validateProposedValue`
61
+ * checks. Returns `undefined` when the text does not parse for that type. With
62
+ * no descriptor, or a type that has no single-line form (string, array, object),
63
+ * the text is returned unchanged.
64
+ */
65
+ export function editedValueFromEditorText(descriptor, text) {
66
+ if (!descriptor)
67
+ return text;
68
+ const trimmed = text.trim();
69
+ switch (descriptor.type) {
70
+ case "number":
71
+ return parsePlainDecimal(trimmed);
72
+ case "boolean":
73
+ return trimmed === "true" ? true : trimmed === "false" ? false : undefined;
74
+ case "date":
75
+ return isIsoCalendarDate(trimmed) ? trimmed : undefined;
76
+ case "enum": {
77
+ const allowed = descriptor.enumValues ?? [];
78
+ return allowed.length === 0 || allowed.includes(trimmed) ? trimmed : undefined;
79
+ }
80
+ default:
81
+ return text;
82
+ }
83
+ }
84
+ function valueMatchesDescriptor(descriptor, value) {
85
+ switch (descriptor.type) {
86
+ case "number":
87
+ return typeof value === "number" && Number.isFinite(value);
88
+ case "boolean":
89
+ return typeof value === "boolean";
90
+ case "date":
91
+ return typeof value === "string" && isIsoCalendarDate(value);
92
+ case "enum": {
93
+ if (typeof value !== "string")
94
+ return false;
95
+ const allowed = descriptor.enumValues ?? [];
96
+ return allowed.length === 0 || allowed.includes(value);
97
+ }
98
+ case "string":
99
+ return typeof value === "string";
100
+ default:
101
+ // array/object: the workbench has no typed editor for these, so Survey
102
+ // declares no constraint on the edit's shape.
103
+ return true;
104
+ }
105
+ }
106
+ /**
107
+ * Checks an inline edit carried by an `accept-proposed` decision against the
108
+ * item's `editable` flag and `valueDescriptor`. This is the server-side
109
+ * counterpart of the browser editor's validation (kontourai/survey#278): the
110
+ * apply boundary must not trust an edit the workbench would never have let a
111
+ * reviewer make.
112
+ *
113
+ * Legacy sessions: workbenches before this check stored every edit as the
114
+ * editor's text, so a number or boolean edit arrives as `"42"` / `"true"`. Such
115
+ * text (and date/enum text with surrounding whitespace, which the old editor
116
+ * accepted and stored untrimmed) is converted to its typed value when it parses
117
+ * cleanly under the workbench's own rules (`convertedFromText: true`, reported as a warning by the
118
+ * apply derivation); text that does not parse is refused.
119
+ */
120
+ export function checkEditedValueForItem(item, value) {
121
+ const itemName = item.metadata.name;
122
+ if (item.spec.editable === false) {
123
+ return {
124
+ ok: false,
125
+ code: "edited-value-not-editable",
126
+ message: `ReviewItem ${itemName} is not editable (spec.editable: false), but the decision carries an edited value.`,
127
+ };
128
+ }
129
+ const descriptor = item.spec.valueDescriptor;
130
+ if (!descriptor || valueMatchesDescriptor(descriptor, value)) {
131
+ return { ok: true, value, convertedFromText: false };
132
+ }
133
+ if (typeof value === "string") {
134
+ const converted = editedValueFromEditorText(descriptor, value);
135
+ if (converted !== undefined) {
136
+ return { ok: true, value: converted, convertedFromText: true };
137
+ }
138
+ }
139
+ const allowed = descriptor.type === "enum" && descriptor.enumValues?.length
140
+ ? ` (one of: ${descriptor.enumValues.join(", ")})`
141
+ : "";
142
+ return {
143
+ ok: false,
144
+ code: "edited-value-type-mismatch",
145
+ message: `ReviewItem ${itemName} declares value type ${descriptor.type}${allowed}, but the decision carries edited value ${JSON.stringify(value)}.`,
146
+ };
147
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ReviewCandidate, type ReviewItem } from "../review-resource.js";
2
+ import type { InterpretationAnswerImpact, InterpretationReadingKind } from "../types.js";
2
3
  import { type ReviewWorkbenchResult } from "./review-workbench.js";
3
4
  export interface ReviewPresentationAdapter {
4
5
  readonly labelForTarget?: (target: string, context: ReviewItemPresentationContext) => string | undefined;
@@ -61,6 +62,49 @@ export interface ReviewResultPresentation {
61
62
  readonly reviewItemLink?: ReviewPresentationLink;
62
63
  readonly traceRefs: readonly ReviewTraceRef[];
63
64
  }
65
+ /**
66
+ * Structural input for {@link buildInterpretationReadingPresentation}: either
67
+ * a Survey `Interpretation` record (`id`) or the entry Survey projects onto a
68
+ * claim at `metadata.survey.interpretations[]` (`interpretationId`). Kind and
69
+ * impact arrive as plain strings when read back from projected metadata.
70
+ */
71
+ export interface InterpretationReadingSource {
72
+ readonly id?: string;
73
+ readonly interpretationId?: string;
74
+ readonly readingKind?: string;
75
+ readonly answerImpact?: string;
76
+ readonly ruleLocator: string;
77
+ readonly reading: string;
78
+ readonly actor: string;
79
+ readonly recordedAt: string;
80
+ }
81
+ export interface InterpretationReadingPresentation {
82
+ readonly interpretationId: string;
83
+ readonly readingKind: InterpretationReadingKind;
84
+ readonly kindLabel: string;
85
+ readonly answerImpact?: InterpretationAnswerImpact;
86
+ readonly answerImpactLabel?: string;
87
+ readonly reading: string;
88
+ readonly actor: string;
89
+ readonly recordedAt: string;
90
+ readonly ruleLocator: string;
91
+ /**
92
+ * Always `"authored-judgment"`. This is DERIVED from the record type, not a
93
+ * stored flag: every Interpretation reading is a producer-authored reading
94
+ * by contract (CONTEXT.md "Interpretation Record"), never a machine-observed
95
+ * fact. Renderers must present readings under this marking, visually
96
+ * distinct from machine-observed values — the StatementBadge / ADR 0003 §4
97
+ * discipline (blending the two is the defect class of #247).
98
+ */
99
+ readonly provenance: "authored-judgment";
100
+ readonly provenanceLabel: string;
101
+ }
102
+ /**
103
+ * Presents one interpretation reading as authored judgment. Fails closed on
104
+ * unknown reading-kind / answer-impact vocabulary rather than rendering an
105
+ * authored record under a label nothing derived.
106
+ */
107
+ export declare function buildInterpretationReadingPresentation(source: InterpretationReadingSource): InterpretationReadingPresentation;
64
108
  export declare function buildReviewItemPresentation(item: ReviewItem, adapter?: ReviewPresentationAdapter): ReviewItemPresentation;
65
109
  export declare function buildReviewCandidatePresentation(item: ReviewItem, candidate: ReviewCandidate, adapter?: ReviewPresentationAdapter, targetLabel?: string): ReviewCandidatePresentation;
66
110
  export declare function buildReviewResultPresentation(result: ReviewWorkbenchResult, item: ReviewItem | undefined, adapter?: ReviewPresentationAdapter): ReviewResultPresentation;
@@ -1,5 +1,54 @@
1
1
  import { findSoleCandidateById } from "../review-resource.js";
2
2
  import { formatValue } from "./review-surface-preview.js";
3
+ const INTERPRETATION_KIND_LABELS = {
4
+ "policy-standard": "Policy-standard reading",
5
+ gleaned: "Gleaned from results",
6
+ answerImpact: "Answer impact",
7
+ };
8
+ const ANSWER_IMPACT_LABELS = {
9
+ supported: "Supported the answer",
10
+ narrowed: "Narrowed the answer",
11
+ "accepted-risk": "Accepted as a risk",
12
+ };
13
+ /**
14
+ * Presents one interpretation reading as authored judgment. Fails closed on
15
+ * unknown reading-kind / answer-impact vocabulary rather than rendering an
16
+ * authored record under a label nothing derived.
17
+ */
18
+ export function buildInterpretationReadingPresentation(source) {
19
+ const interpretationId = source.interpretationId ?? source.id;
20
+ if (!interpretationId) {
21
+ throw new Error("Interpretation reading presentation requires an id or interpretationId.");
22
+ }
23
+ const readingKind = (source.readingKind ?? "policy-standard");
24
+ const kindLabel = INTERPRETATION_KIND_LABELS[readingKind];
25
+ if (!kindLabel) {
26
+ throw new Error(`Interpretation ${interpretationId} has unknown readingKind ${String(source.readingKind)}`);
27
+ }
28
+ const answerImpact = source.answerImpact;
29
+ const answerImpactLabel = answerImpact === undefined ? undefined : ANSWER_IMPACT_LABELS[answerImpact];
30
+ if (answerImpact !== undefined && !answerImpactLabel) {
31
+ throw new Error(`Interpretation ${interpretationId} has unknown answerImpact ${String(source.answerImpact)}`);
32
+ }
33
+ if (readingKind === "answerImpact" && answerImpact === undefined) {
34
+ throw new Error(`Interpretation ${interpretationId} readingKind answerImpact requires an answerImpact value`);
35
+ }
36
+ if (readingKind !== "answerImpact" && answerImpact !== undefined) {
37
+ throw new Error(`Interpretation ${interpretationId} sets answerImpact but readingKind is ${readingKind}`);
38
+ }
39
+ return {
40
+ interpretationId,
41
+ readingKind,
42
+ kindLabel,
43
+ ...(answerImpact !== undefined ? { answerImpact, answerImpactLabel } : {}),
44
+ reading: source.reading,
45
+ actor: source.actor,
46
+ recordedAt: source.recordedAt,
47
+ ruleLocator: source.ruleLocator,
48
+ provenance: "authored-judgment",
49
+ provenanceLabel: "Authored judgment",
50
+ };
51
+ }
3
52
  export function buildReviewItemPresentation(item, adapter = {}) {
4
53
  const context = { item };
5
54
  const targetLabel = adapter.labelForTarget?.(item.spec.target, context) ?? humanizeIdentifier(item.spec.target);
@@ -1,5 +1,6 @@
1
1
  import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
+ import { checkEditedValueForItem } from "./edited-value.js";
3
4
  import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
4
5
  export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
5
6
  export const defaultReviewSessionName = "review-workbench-session";
@@ -368,7 +369,13 @@ export function replayReviewSessionEvents(startState, events) {
368
369
  const editedValuesByItemName = { ...session.editedValuesByItemName };
369
370
  const attemptEvidenceIdsByItemName = { ...session.attemptEvidenceIdsByItemName };
370
371
  if (decision === "accept-proposed" && editedValue !== undefined) {
371
- editedValuesByItemName[itemName] = editedValue;
372
+ // Legacy sessions stored typed edits as editor text ("42"); store the
373
+ // descriptor-typed value so effectiveValue is 42. An edit the item does
374
+ // not allow is left as carried: validated replay refuses it before this
375
+ // point (kontourai/survey#278).
376
+ const item = session.items.find((entry) => entry.metadata.name === itemName);
377
+ const check = item ? checkEditedValueForItem(item, editedValue) : undefined;
378
+ editedValuesByItemName[itemName] = check?.ok ? check.value : editedValue;
372
379
  }
373
380
  else {
374
381
  delete editedValuesByItemName[itemName];