@kontourai/survey 3.0.0 → 5.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 (46) hide show
  1. package/README.md +4 -0
  2. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  3. package/dist/examples/calibrated-auto-accept.js +40 -36
  4. package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
  5. package/dist/src/calibration.d.ts +48 -21
  6. package/dist/src/calibration.js +72 -33
  7. package/dist/src/canonical-reviewed-trust-input.js +73 -34
  8. package/dist/src/console/review-console-server.d.ts +3 -1
  9. package/dist/src/console/review-console-server.js +203 -50
  10. package/dist/src/extraction-envelope.d.ts +81 -3
  11. package/dist/src/extraction-envelope.js +183 -24
  12. package/dist/src/index.d.ts +9 -8
  13. package/dist/src/index.js +3 -3
  14. package/dist/src/inquiry-mapping.d.ts +15 -1
  15. package/dist/src/inquiry-mapping.js +10 -2
  16. package/dist/src/mcp/review-mcp.js +112 -90
  17. package/dist/src/producer-profile.d.ts +41 -2
  18. package/dist/src/producer-profile.js +29 -2
  19. package/dist/src/review-session-file.d.ts +64 -0
  20. package/dist/src/review-session-file.js +320 -0
  21. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  22. package/dist/src/review-workbench/edited-value.js +147 -0
  23. package/dist/src/review-workbench/extraction-inspector.d.ts +15 -1
  24. package/dist/src/review-workbench/extraction-inspector.js +55 -19
  25. package/dist/src/review-workbench/queue-binding.js +1 -1
  26. package/dist/src/review-workbench/review-presentation.d.ts +46 -1
  27. package/dist/src/review-workbench/review-presentation.js +72 -1
  28. package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
  29. package/dist/src/review-workbench/review-queue-session.js +54 -14
  30. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  31. package/dist/src/review-workbench/review-session-replay.js +82 -3
  32. package/dist/src/review-workbench/review-workbench-css.generated.js +2 -0
  33. package/dist/src/review-workbench/review-workbench.css +2 -0
  34. package/dist/src/review-workbench/review-workbench.d.ts +22 -11
  35. package/dist/src/review-workbench/review-workbench.js +91 -26
  36. package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
  37. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  38. package/dist/src/review-workbench/server-review-session.js +1 -0
  39. package/dist/src/reviewed-candidate-resolution.js +13 -7
  40. package/dist/src/schema-mapping.d.ts +23 -0
  41. package/dist/src/schema-mapping.js +30 -20
  42. package/dist/src/surface-reviewed-extraction.js +4 -0
  43. package/dist/src/to-surface.d.ts +30 -6
  44. package/dist/src/to-surface.js +312 -18
  45. package/dist/src/types.d.ts +44 -1
  46. package/package.json +5 -4
@@ -1,6 +1,6 @@
1
1
  import { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
2
2
  import { canonicalJson } from "./review-workbench/canonical.js";
3
- import { workbenchDecisionDefinitions } from "./review-workbench/review-queue-session.js";
3
+ import { decisionSelectsNoCandidate, workbenchDecisionDefinitions } from "./review-workbench/review-queue-session.js";
4
4
  /**
5
5
  * Projects server-applied review records into the complete SurveyInput consumed
6
6
  * by buildSurveyTrustBundle. The ReviewItem and ReviewWorkbenchResult are the
@@ -33,15 +33,24 @@ export function buildCanonicalReviewedTrustInput(options) {
33
33
  throw new Error(`ReviewItem ${item.metadata.name} has no canonical server-applied result.`);
34
34
  }
35
35
  assertCanonicalResult(item, result);
36
+ // Reject-all or could-not-confirm on a conflict selects no candidate: the
37
+ // set, the review outcome and the claim then name none of its values.
38
+ const selectsNone = decisionSelectsNoCandidate(item, result.decision);
39
+ const rejectsAll = selectsNone && result.decision === "reject-proposed";
40
+ const rejectedRole = workbenchDecisionDefinitions[result.decision].candidateRole;
41
+ const rejectAllReason = result.rationale?.trim() || "Every proposed value for this claim was rejected.";
36
42
  const candidates = item.spec.candidates.map((candidate) => {
37
- const records = projectCandidate(item, candidate);
43
+ const records = projectCandidate(item, rejectsAll && candidate.role === rejectedRole
44
+ ? { ...candidate, rejectionReason: candidate.rejectionReason ?? rejectAllReason }
45
+ : candidate);
38
46
  addConsistent(rawSources, records.rawSource, "raw source");
39
47
  addConsistent(extractions, records.extraction, "extraction");
40
48
  return records.candidate;
41
49
  });
42
- const selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
43
- const selectedRecordId = selected.projection?.candidateId ?? selected.id;
44
- const candidateSetId = selected.projection?.candidateSetId
50
+ const selected = selectsNone ? undefined : item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
51
+ const selectedRecordId = selected ? selected.projection?.candidateId ?? selected.id : undefined;
52
+ // Every candidate carries the same candidate-set id (checked below).
53
+ const candidateSetId = (selected ?? item.spec.candidates[0])?.projection?.candidateSetId
45
54
  ?? item.spec.projection?.candidateSetId
46
55
  ?? `${item.metadata.name}.candidates`;
47
56
  if (candidates.some((candidate) => candidate.metadata?.candidateSetId !== candidateSetId)) {
@@ -56,10 +65,10 @@ export function buildCanonicalReviewedTrustInput(options) {
56
65
  ? { metadata: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "candidateSetId")) }
57
66
  : {}),
58
67
  })),
59
- selectedCandidateId: selectedRecordId,
68
+ ...(selectedRecordId !== undefined ? { selectedCandidateId: selectedRecordId } : {}),
60
69
  status: result.decision === "could-not-confirm"
61
70
  ? (item.spec.candidateSetStatus ?? "needs-review")
62
- : "resolved",
71
+ : rejectsAll ? "rejected" : "resolved",
63
72
  ...(result.rationale ?? item.spec.rationale
64
73
  ? { rationale: result.rationale ?? item.spec.rationale }
65
74
  : {}),
@@ -67,12 +76,12 @@ export function buildCanonicalReviewedTrustInput(options) {
67
76
  addConsistent(candidateSets, candidateSet, "candidate set");
68
77
  const decision = result.reviewDecision.spec;
69
78
  const reviewOutcomeId = decision.projection?.reviewOutcomeId
70
- ?? selected.projection?.reviewOutcomeId
79
+ ?? (selected ?? item.spec).projection?.reviewOutcomeId
71
80
  ?? `${item.metadata.name}.${result.decision}.review-outcome`;
72
81
  const reviewOutcome = {
73
82
  id: reviewOutcomeId,
74
83
  candidateSetId,
75
- candidateId: selectedRecordId,
84
+ ...(selectedRecordId !== undefined ? { candidateId: selectedRecordId } : {}),
76
85
  status: result.status,
77
86
  ...(decision.resolution ? { resolution: decision.resolution } : {}),
78
87
  ...(decision.resolutionReason ? { resolutionReason: decision.resolutionReason } : {}),
@@ -90,34 +99,42 @@ export function buildCanonicalReviewedTrustInput(options) {
90
99
  },
91
100
  };
92
101
  addConsistent(reviewOutcomes, reviewOutcome, "review outcome");
93
- const hint = selected.claimTarget;
102
+ // Every candidate names the same claim target (checked by assertCanonicalResult).
103
+ const hint = (selected ?? item.spec.candidates[0]).claimTarget;
94
104
  assertSingleProjectionId("claim", item.metadata.name, [
95
105
  decision.projection?.claimId,
96
106
  item.spec.projection?.claimId,
97
107
  ...item.spec.candidates.flatMap((candidate) => [candidate.projection?.claimId, candidate.claimTarget.claimId]),
98
108
  ]);
99
109
  const claimId = decision.projection?.claimId
100
- ?? selected.projection?.claimId
110
+ ?? selected?.projection?.claimId
101
111
  ?? item.spec.projection?.claimId
102
112
  ?? hint.claimId
103
113
  ?? `${item.metadata.name}.claim`;
104
114
  const claim = {
105
115
  id: claimId,
106
116
  candidateSetId,
107
- candidateId: selectedRecordId,
117
+ ...(selectedRecordId !== undefined ? { candidateId: selectedRecordId } : {}),
108
118
  subjectType: hint.subjectType,
109
119
  subjectId: hint.subjectId,
110
120
  facet: hint.facet,
111
121
  claimType: hint.claimType,
112
122
  fieldOrBehavior: hint.fieldOrBehavior,
113
- value: result.effectiveValue,
114
- status: result.status,
123
+ // Surface requires a claim value; a claim that selects none of its
124
+ // candidates carries null, and buildSurveyTrustBundle lists every value.
125
+ value: selectsNone ? null : result.effectiveValue,
126
+ // Could-not-confirm keeps the pre-review posture, and a conflicting or
127
+ // escalated candidate set is disputed before any review (see
128
+ // docs/decisions/could-not-confirm.md), never merely proposed.
129
+ status: result.decision === "could-not-confirm" && (candidateSet.status === "conflict" || candidateSet.status === "escalated")
130
+ ? "disputed"
131
+ : result.status,
115
132
  impactLevel: hint.impactLevel,
116
133
  updatedAt: decision.reviewedAt ?? options.generatedAt,
117
134
  ...(hint.evidenceType ? { evidenceType: hint.evidenceType } : {}),
118
135
  ...(hint.evidenceMethod ? { evidenceMethod: hint.evidenceMethod } : {}),
119
136
  ...(hint.derivedFrom ? { derivedFrom: [...hint.derivedFrom] } : {}),
120
- collectedBy: hint.collectedBy ?? selected.extraction.extractor ?? options.source,
137
+ collectedBy: hint.collectedBy ?? sharedExtractor(selected ? [selected] : item.spec.candidates) ?? options.source,
121
138
  ...(decision.actor?.id ? { actor: decision.actor.id } : {}),
122
139
  };
123
140
  addConsistent(claims, claim, "claim target");
@@ -137,24 +154,38 @@ export function buildCanonicalReviewedTrustInput(options) {
137
154
  };
138
155
  }
139
156
  function assertCanonicalResult(item, result) {
140
- const selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
141
- if (!selected) {
142
- throw new Error(`Review result ${result.reviewItemName} selects an unknown candidate.`);
143
- }
144
- if (canonicalJson(selected) !== canonicalJson(result.selectedCandidate)) {
145
- throw new Error(`Review result ${result.reviewItemName} selected candidate does not match its canonical ReviewItem.`);
146
- }
147
- const unselected = item.spec.candidates.filter((candidate) => candidate.id !== selected.id);
148
- if (canonicalJson(unselected) !== canonicalJson(result.unselectedCandidates)) {
149
- throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
157
+ const selectsNone = decisionSelectsNoCandidate(item, result.decision);
158
+ let selected;
159
+ if (selectsNone) {
160
+ if (result.selectedCandidate !== undefined || result.selectedCandidateId !== undefined || result.selectedCandidateRole !== undefined
161
+ || result.selectedValue !== undefined || result.selectedDisplayValue !== undefined
162
+ || result.effectiveValue !== undefined || result.effectiveDisplayValue !== undefined || result.editedValue !== undefined) {
163
+ throw new Error(`Review result ${result.reviewItemName} names a selected value, but its ${result.decision} decision selects no candidate.`);
164
+ }
165
+ if (canonicalJson(item.spec.candidates) !== canonicalJson(result.unselectedCandidates)) {
166
+ throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
167
+ }
150
168
  }
151
- if (result.selectedCandidateRole !== selected.role || canonicalJson(result.selectedValue) !== canonicalJson(selected.value)) {
152
- throw new Error(`Review result ${result.reviewItemName} selected identity does not match its canonical ReviewItem.`);
169
+ else {
170
+ selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
171
+ if (!selected) {
172
+ throw new Error(`Review result ${result.reviewItemName} selects an unknown candidate.`);
173
+ }
174
+ if (canonicalJson(selected) !== canonicalJson(result.selectedCandidate)) {
175
+ throw new Error(`Review result ${result.reviewItemName} selected candidate does not match its canonical ReviewItem.`);
176
+ }
177
+ const unselected = item.spec.candidates.filter((candidate) => candidate.id !== selected.id);
178
+ if (canonicalJson(unselected) !== canonicalJson(result.unselectedCandidates)) {
179
+ throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
180
+ }
181
+ if (result.selectedCandidateRole !== selected.role || canonicalJson(result.selectedValue) !== canonicalJson(selected.value)) {
182
+ throw new Error(`Review result ${result.reviewItemName} selected identity does not match its canonical ReviewItem.`);
183
+ }
153
184
  }
154
185
  const decision = result.reviewDecision.spec;
155
186
  const definition = workbenchDecisionDefinitions[result.decision];
156
187
  if (decision.reviewItemName !== item.metadata.name
157
- || decision.candidateId !== result.selectedCandidateId
188
+ || decision.candidateId !== selected?.id
158
189
  || decision.status !== result.status
159
190
  || decision.status !== definition.status
160
191
  || decision.rationale !== result.rationale
@@ -164,14 +195,17 @@ function assertCanonicalResult(item, result) {
164
195
  if ((result.decision === "could-not-confirm") !== (decision.resolution === "could_not_confirm")) {
165
196
  throw new Error(`Review result ${result.reviewItemName} contradicts its canonical review resolution.`);
166
197
  }
167
- const expectedEffective = result.editedValue !== undefined && result.decision === "accept-proposed"
168
- ? result.editedValue
169
- : selected.value;
170
- if (canonicalJson(expectedEffective) !== canonicalJson(result.effectiveValue)) {
171
- throw new Error(`Review result ${result.reviewItemName} effective value is not canonical.`);
198
+ if (selected) {
199
+ const expectedEffective = result.editedValue !== undefined && result.decision === "accept-proposed"
200
+ ? result.editedValue
201
+ : selected.value;
202
+ if (canonicalJson(expectedEffective) !== canonicalJson(result.effectiveValue)) {
203
+ throw new Error(`Review result ${result.reviewItemName} effective value is not canonical.`);
204
+ }
172
205
  }
206
+ const reference = selected ?? item.spec.candidates[0];
173
207
  for (const candidate of item.spec.candidates) {
174
- if (canonicalJson(claimTargetIdentity(candidate.claimTarget)) !== canonicalJson(claimTargetIdentity(selected.claimTarget))) {
208
+ if (canonicalJson(claimTargetIdentity(candidate.claimTarget)) !== canonicalJson(claimTargetIdentity(reference.claimTarget))) {
175
209
  throw new Error(`ReviewItem ${item.metadata.name} candidates carry conflicting claim targets.`);
176
210
  }
177
211
  }
@@ -263,3 +297,8 @@ function assertSingleProjectionId(label, itemName, values) {
263
297
  throw new Error(`ReviewItem ${itemName} carries conflicting ${label} projection ids.`);
264
298
  }
265
299
  }
300
+ /** The extractor every candidate shares, or undefined when they differ. */
301
+ function sharedExtractor(candidates) {
302
+ const extractors = new Set(candidates.map((candidate) => candidate.extraction.extractor));
303
+ return extractors.size === 1 ? [...extractors][0] : undefined;
304
+ }
@@ -5,7 +5,9 @@
5
5
  * Routes:
6
6
  * GET / HTML shell that mounts the workbench
7
7
  * GET /api/session Current session state (snapshot + replayed events)
8
- * POST /api/events Append review session events (same contract as MCP server)
8
+ * POST /api/events Append review session events to the stored log (same
9
+ * validation as MCP server), compare-and-swap on the
10
+ * revision the client last read
9
11
  * GET /api/stream SSE stream: emits "update" events when the session file changes
10
12
  * GET /api/health Health check
11
13
  * GET /dist/* Compiled assets served from the dist tree (traversal-safe)
@@ -5,18 +5,20 @@
5
5
  * Routes:
6
6
  * GET / HTML shell that mounts the workbench
7
7
  * GET /api/session Current session state (snapshot + replayed events)
8
- * POST /api/events Append review session events (same contract as MCP server)
8
+ * POST /api/events Append review session events to the stored log (same
9
+ * validation as MCP server), compare-and-swap on the
10
+ * revision the client last read
9
11
  * GET /api/stream SSE stream: emits "update" events when the session file changes
10
12
  * GET /api/health Health check
11
13
  * GET /dist/* Compiled assets served from the dist tree (traversal-safe)
12
14
  */
13
15
  import { createServer } from "node:http";
14
16
  import { watch } from "node:fs";
15
- import { readFile as readFileAsync, writeFile as writeFileAsync, rename as renameAsync } from "node:fs/promises";
17
+ import { readFile as readFileAsync } from "node:fs/promises";
16
18
  import { resolve, join, dirname, extname, normalize, sep } from "node:path";
17
19
  import { fileURLToPath } from "node:url";
18
- import { defaultReviewSessionName, } from "../review-workbench/review-workbench.js";
19
20
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
21
+ import { appendReviewSessionEvents, readReviewSessionFile, reviewSessionRevision, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
20
22
  // ---------------------------------------------------------------------------
21
23
  // Constants
22
24
  // ---------------------------------------------------------------------------
@@ -55,13 +57,7 @@ function distRoot() {
55
57
  // Session file helpers
56
58
  // ---------------------------------------------------------------------------
57
59
  async function readSession(path) {
58
- const raw = await readFileAsync(path, "utf8");
59
- return JSON.parse(raw);
60
- }
61
- async function writeSessionAtomic(path, content) {
62
- const tmp = `${path}.tmp`;
63
- await writeFileAsync(tmp, JSON.stringify(content, null, 2), "utf8");
64
- await renameAsync(tmp, path);
60
+ return readReviewSessionFile(path);
65
61
  }
66
62
  // ---------------------------------------------------------------------------
67
63
  // SSE broadcaster
@@ -271,6 +267,14 @@ body {
271
267
  .connection-dot.disconnected {
272
268
  background: var(--k-negative, #ff6f6f);
273
269
  }
270
+ .console-save-status {
271
+ padding: 8px 16px;
272
+ border-bottom: 1px solid var(--k-negative, #ff6f6f);
273
+ background: var(--k-panel-raised, #16202d);
274
+ color: var(--k-text, #eef3f8);
275
+ font-size: 13px;
276
+ }
277
+ .console-save-status[hidden] { display: none; }
274
278
  #review-workbench {
275
279
  flex: 1;
276
280
  min-height: 0;
@@ -309,31 +313,92 @@ body {
309
313
  </button>
310
314
  </div>
311
315
  </header>
316
+ <div id="console-save-status" class="console-save-status" role="alert" data-testid="console-save-status" hidden></div>
312
317
  <main id="review-workbench" class="workbench survey-workbench-embed" data-testid="review-workbench"></main>
313
318
  <script type="module">
314
319
  import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionName, buildReviewSessionEvents } from "${workbenchJsPath}";
315
320
 
316
321
  // ---- persistence adapter: POST events to /api/events ----
317
- function createConsoleEventStore() {
318
- let _events = [];
322
+ // One store per mount, bound to the server revision the mount was built from.
323
+ // Each save sends only the workbench events of this mount that are not stored
324
+ // yet; the server appends them to the stored log, so reversals and note
325
+ // changes stay on record and decisions another writer stored (for example the
326
+ // MCP server) are kept. Saves are serialized so each one carries the revision
327
+ // the previous save produced. A save the server refuses (409 conflict or any
328
+ // other failure) is never kept as local truth: the console re-fetches the
329
+ // stored session, re-mounts from it and tells the reviewer once.
330
+ let saveChain = Promise.resolve();
331
+
332
+ function showSaveStatus(message) {
333
+ const el = document.getElementById("console-save-status");
334
+ if (!el) return;
335
+ if (message) {
336
+ el.textContent = message;
337
+ el.hidden = false;
338
+ } else {
339
+ el.textContent = "";
340
+ el.hidden = true;
341
+ }
342
+ }
343
+
344
+ function createConsoleEventStore(baseRevision) {
345
+ let revision = baseRevision;
346
+ // Local events of this mount already appended to the stored log.
347
+ let persistedCount = 0;
348
+ // Set once a save from this mount is refused. Saves still queued from this
349
+ // mount were built on the view the server just refused, and the page has
350
+ // re-mounted from the stored session and told the reviewer: drop them
351
+ // instead of reporting the same conflict again.
352
+ let abandoned = false;
353
+
354
+ async function refused(message) {
355
+ abandoned = true;
356
+ showSaveStatus(message);
357
+ await fetchAndMount();
358
+ }
359
+
360
+ async function persist(localEvents) {
361
+ if (abandoned) return;
362
+ const pending = localEvents.slice(persistedCount);
363
+ if (pending.length === 0) return;
364
+ let res;
365
+ try {
366
+ res = await fetch("/api/events", {
367
+ method: "POST",
368
+ headers: { "Content-Type": "application/json" },
369
+ body: JSON.stringify({ events: pending, baseRevision: revision }),
370
+ });
371
+ } catch (err) {
372
+ console.error("[console] Failed to persist events:", err);
373
+ await refused("Your last change was not saved: the console could not reach the server.");
374
+ return;
375
+ }
376
+ if (res.ok) {
377
+ const body = await res.json().catch(() => ({}));
378
+ if (typeof body.revision === "string") revision = body.revision;
379
+ persistedCount = localEvents.length;
380
+ showSaveStatus("");
381
+ return;
382
+ }
383
+ if (res.status === 409) {
384
+ await refused("Your last change was not saved because the session changed (another tab, reviewer or agent wrote to it). The console reloaded the current session.");
385
+ } else {
386
+ const body = await res.json().catch(() => ({}));
387
+ await refused("Your last change was not saved (HTTP " + res.status + (body.error ? ": " + body.error : "") + "). The console reloaded the stored session.");
388
+ }
389
+ }
390
+
319
391
  return {
320
- load: () => _events.length > 0 ? [..._events] : undefined,
321
- save: async (_session, events) => {
322
- _events = [...events];
323
- try {
324
- await fetch("/api/events", {
325
- method: "POST",
326
- headers: { "Content-Type": "application/json" },
327
- body: JSON.stringify({ events }),
328
- });
329
- } catch (err) {
330
- console.error("[console] Failed to persist events:", err);
331
- }
392
+ // The mount state is already replayed from the stored log.
393
+ load: () => undefined,
394
+ save: (_session, events) => {
395
+ const localEvents = [...events];
396
+ saveChain = saveChain.then(() => persist(localEvents));
397
+ return saveChain;
332
398
  },
333
399
  };
334
400
  }
335
401
 
336
- const eventStore = createConsoleEventStore();
337
402
  let activeItemName = null;
338
403
 
339
404
  async function fetchAndMount() {
@@ -341,7 +406,7 @@ async function fetchAndMount() {
341
406
  const res = await fetch("/api/session");
342
407
  if (!res.ok) throw new Error("Session fetch failed: " + res.status);
343
408
  const data = await res.json();
344
- const { snapshot, events } = data;
409
+ const { snapshot, events, revision } = data;
345
410
  const state = events && events.length > 0
346
411
  ? replayReviewSessionEvents(snapshot, events)
347
412
  : snapshot;
@@ -354,7 +419,7 @@ async function fetchAndMount() {
354
419
  const root = document.getElementById("review-workbench");
355
420
  if (!root) return;
356
421
 
357
- mountReviewWorkbench(root, state, { eventStore });
422
+ mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision) });
358
423
  } catch (err) {
359
424
  console.error("[console] Mount error:", err);
360
425
  }
@@ -451,6 +516,49 @@ async function readJsonBody(req) {
451
516
  return parsed;
452
517
  }
453
518
  // ---------------------------------------------------------------------------
519
+ // Request guards (kontourai/survey#281)
520
+ // ---------------------------------------------------------------------------
521
+ function hostnameOf(hostHeader) {
522
+ try {
523
+ return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "");
524
+ }
525
+ catch {
526
+ return undefined;
527
+ }
528
+ }
529
+ /**
530
+ * The server binds to loopback only, but a browser page on another site can
531
+ * still reach it through DNS rebinding (Host is the attacker's name) or a
532
+ * cross-origin "simple request" (text/plain body, foreign Origin). Each guard
533
+ * returns an error message, or undefined when the request passes.
534
+ */
535
+ function hostGuard(req) {
536
+ const host = req.headers.host;
537
+ const hostname = host ? hostnameOf(host) : undefined;
538
+ return hostname && LOOPBACK_HOSTS.has(hostname) ? undefined : "Host must be a loopback address";
539
+ }
540
+ function originGuard(req, port) {
541
+ const origin = req.headers.origin;
542
+ if (origin === undefined)
543
+ return undefined;
544
+ try {
545
+ const parsed = new URL(origin);
546
+ const originPort = parsed.port === "" ? "80" : parsed.port;
547
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
548
+ if (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(hostname) && originPort === String(port)) {
549
+ return undefined;
550
+ }
551
+ }
552
+ catch {
553
+ // Unparseable (for example "null"): refuse below.
554
+ }
555
+ return "Cross-origin writes are not allowed";
556
+ }
557
+ function contentTypeGuard(req) {
558
+ const mediaType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
559
+ return mediaType === "application/json" ? undefined : "Content-Type must be application/json";
560
+ }
561
+ // ---------------------------------------------------------------------------
454
562
  // Public API
455
563
  // ---------------------------------------------------------------------------
456
564
  export async function startReviewConsoleServer(options) {
@@ -464,8 +572,14 @@ export async function startReviewConsoleServer(options) {
464
572
  const stopWatcher = watchSessionFile(sessionPath, () => {
465
573
  broadcaster.broadcast("update", JSON.stringify({ ts: Date.now() }));
466
574
  });
575
+ let listenPort = 0;
467
576
  const server = createServer(async (req, res) => {
468
577
  try {
578
+ const hostError = hostGuard(req);
579
+ if (hostError) {
580
+ sendJson(res, 403, { error: hostError });
581
+ return;
582
+ }
469
583
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
470
584
  const pathname = url.pathname;
471
585
  // ---- Static dist assets ----
@@ -492,42 +606,80 @@ export async function startReviewConsoleServer(options) {
492
606
  session: content.session,
493
607
  snapshot: content.snapshot,
494
608
  events: content.events,
609
+ revision: reviewSessionRevision(content.events),
495
610
  state: currentSessionState(content.snapshot, content.events),
496
611
  });
497
612
  return;
498
613
  }
499
614
  // ---- Events write (append) ----
500
615
  if (pathname === "/api/events" && req.method === "POST") {
616
+ const originError = originGuard(req, listenPort);
617
+ if (originError) {
618
+ sendJson(res, 403, { error: originError });
619
+ return;
620
+ }
621
+ const contentTypeError = contentTypeGuard(req);
622
+ if (contentTypeError) {
623
+ sendJson(res, 415, { error: contentTypeError });
624
+ return;
625
+ }
501
626
  const body = await readJsonBody(req);
502
627
  const incomingEvents = body.events;
628
+ const baseRevision = body.baseRevision;
503
629
  if (!Array.isArray(incomingEvents)) {
504
630
  sendJson(res, 400, { error: "events must be an array" });
505
631
  return;
506
632
  }
507
- const content = await readSession(sessionPath);
508
- const { snapshot, events: existingEvents } = content;
509
- const record = createServerReviewSessionRecord({
510
- sessionName: defaultReviewSessionName,
511
- snapshot,
512
- eventCount: existingEvents.length,
513
- updatedAt: new Date(),
514
- });
515
- const applyResult = deriveServerReviewSessionApplyResult({
516
- record,
517
- events: incomingEvents,
518
- requiredResolvedItems: "none",
519
- });
520
- if (!applyResult.ok) {
521
- const issueMessages = applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue));
522
- sendJson(res, 422, { error: `Validation failed: ${issueMessages.join("; ")}` });
523
- return;
524
- }
525
- await writeSessionAtomic(sessionPath, {
526
- session: content.session,
527
- snapshot,
528
- events: incomingEvents,
633
+ const outcome = await updateReviewSessionFile(sessionPath, (content) => {
634
+ const { snapshot, events: existingEvents } = content;
635
+ const revision = reviewSessionRevision(existingEvents);
636
+ // Compare-and-swap: the appended events express decisions made on
637
+ // the reviewer's view of the log, so they are only safe to append
638
+ // when that view is the log that is stored right now.
639
+ if (typeof baseRevision !== "string") {
640
+ return { result: { status: 428, body: { error: "baseRevision is required: send the revision from GET /api/session", revision, eventCount: existingEvents.length } } };
641
+ }
642
+ if (baseRevision !== revision) {
643
+ return { result: { status: 409, body: { error: "Session changed since it was read; reload and retry", revision, eventCount: existingEvents.length } } };
644
+ }
645
+ if (incomingEvents.length === 0) {
646
+ return { result: { status: 422, body: { error: "events must contain at least one event to append", revision, eventCount: existingEvents.length } } };
647
+ }
648
+ // Append, never replace: the stored log stays a complete record of
649
+ // reviewer intent (reversals and note changes included).
650
+ const nextEvents = appendReviewSessionEvents(content, incomingEvents);
651
+ const record = createServerReviewSessionRecord({
652
+ sessionName: storedReviewSessionName(content),
653
+ snapshot,
654
+ eventCount: existingEvents.length,
655
+ updatedAt: new Date(),
656
+ });
657
+ let issueMessages;
658
+ try {
659
+ const applyResult = deriveServerReviewSessionApplyResult({
660
+ record,
661
+ events: nextEvents,
662
+ requiredResolvedItems: "none",
663
+ });
664
+ issueMessages = applyResult.ok
665
+ ? []
666
+ : applyResult.issues.map((issue) => ("message" in issue ? issue.message : String(issue)));
667
+ }
668
+ catch (error) {
669
+ issueMessages = [error instanceof Error ? error.message : String(error)];
670
+ }
671
+ if (issueMessages.length > 0) {
672
+ return { result: { status: 422, body: { error: `Validation failed: ${issueMessages.join("; ")}` } } };
673
+ }
674
+ return {
675
+ next: { session: content.session, snapshot, events: nextEvents },
676
+ result: {
677
+ status: 200,
678
+ body: { ok: true, eventCount: nextEvents.length, revision: reviewSessionRevision(nextEvents) },
679
+ },
680
+ };
529
681
  });
530
- sendJson(res, 200, { ok: true, eventCount: incomingEvents.length });
682
+ sendJson(res, outcome.status, outcome.body);
531
683
  return;
532
684
  }
533
685
  // ---- Health ----
@@ -558,6 +710,7 @@ export async function startReviewConsoleServer(options) {
558
710
  if (!address || typeof address === "string") {
559
711
  throw new Error("Unable to determine server address");
560
712
  }
713
+ listenPort = address.port;
561
714
  const normalizedHost = host === "::1" ? "[::1]" : host;
562
715
  const url = `http://${normalizedHost}:${address.port}/`;
563
716
  return {