@wyattjoh/demur 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -48,11 +48,13 @@ or sensitive names. Review TypeSafe's service terms and data-handling policy
48
48
  before enabling demur in a sensitive repository. Do not run secrets directly in
49
49
  shell arguments when the guard is active.
50
50
 
51
- Pi training capture stores the complete command, working directory, operating
52
- mode, full verdict and judgments, and resulting host action locally. These
53
- records can therefore contain secrets or sensitive names from shell arguments
54
- and paths. Training capture is off by default and is unavailable while the Pi
55
- integration is disabled.
51
+ Pi training capture stores the complete command, exact TypeSafe state, local
52
+ static-analysis result, model/question/policy versions, exact policy thresholds,
53
+ operating mode, full verdict and judgments, and resulting host action locally.
54
+ These records can therefore contain secrets or sensitive names from shell
55
+ arguments, paths, Git
56
+ context, and deterministic command analysis. Training capture is off by default
57
+ and is unavailable while the Pi integration is disabled.
56
58
 
57
59
  ## Requirements
58
60
 
@@ -98,7 +100,7 @@ Pin a specific release when reproducibility matters:
98
100
 
99
101
  <!-- x-release-please-start-version -->
100
102
  ```sh
101
- pi install npm:@wyattjoh/demur@0.6.0
103
+ pi install npm:@wyattjoh/demur@0.7.0
102
104
  ```
103
105
  <!-- x-release-please-end -->
104
106
 
@@ -111,7 +113,9 @@ pi
111
113
  The extension intercepts `bash` tool calls. Because Pi runs extensions under
112
114
  Node.js while demur uses `Bun.secrets`, the extension launches a package-local
113
115
  Bun worker for each judgment. The API key remains inside that worker; only the
114
- command request and resulting verdict cross its local stdio pipes. `ask` opens
116
+ command request and resulting verdict cross its local stdio pipes. When training
117
+ capture is enabled, the exact model state and local policy evidence return with
118
+ the verdict for private local persistence. `ask` opens
115
119
  an interactive confirmation dialog; without an interactive UI, demur blocks the
116
120
  command.
117
121
 
@@ -166,8 +170,11 @@ Set `DEMUR_STATE_HOME` to place `usage.json`, `training.jsonl`, and
166
170
  demur-specific override takes precedence over the XDG and home-directory
167
171
  locations. Training records are retained until the user removes them. Human
168
172
  reviews are appended separately; accepted and corrected records are linked by a
169
- stable record ID, leaving the original evidence unchanged. A training-write
170
- failure is reported but never changes whether the command runs.
173
+ stable record ID, leaving the original evidence unchanged. Corrected reviews
174
+ also carry a machine-readable reason so recurring question, context, policy, and
175
+ service failures can be measured without mining free-form notes. Version-one
176
+ records and reviews remain readable. A training-write failure is reported but
177
+ never changes whether the command runs.
171
178
 
172
179
  Pi packages execute with the user's full system permissions. Review this
173
180
  repository before installing it.
@@ -220,7 +227,8 @@ demur auth logout
220
227
  demur training review
221
228
  demur training list --status=unreviewed --cwd=/workspace
222
229
  demur training list --status=deny --json
223
- demur training review <record-id> --decision=deny --note="would destroy work" --json
230
+ demur training evaluate --json
231
+ demur training review <record-id> --decision=deny --reason=recoverability --note="would destroy work" --json
224
232
  demur judge "git reset --hard HEAD~3"
225
233
  ```
226
234
 
@@ -249,18 +257,22 @@ and Page Down page within the focused pane, and queue navigation stops at its
249
257
  first and last entries. Mouse clicks select tabs, records, the filter, or either
250
258
  pane; the wheel scrolls the queue and detail pane. Enter selects the original
251
259
  decision, `1`/`2`/`3` choose `allow`/`ask`/`deny`, `s`
252
- leaves a record for a later pass, and `q` or Escape stops. Previously reviewed
253
- records remain available, and changing an answer appends a review revision while
254
- preserving its visible history. The TUI remains open when a view is empty and
260
+ leaves a record for a later pass, and `q` or Escape stops. A correction first
261
+ asks for one structured reason, then accepts an optional note. Previously
262
+ reviewed records remain available, and changing an answer appends a review
263
+ revision while preserving its visible history. The TUI remains open when a view is empty and
255
264
  polls training state for newly captured or externally reviewed evaluations.
256
265
 
257
266
  Flag-based training commands provide the same review operations without the
258
267
  TUI. `demur training list` accepts `--status` and fuzzy `--cwd` filters. Status
259
268
  is derived from the latest human review, so `allow`, `ask`, and `deny` select
260
269
  reviewed records while `unreviewed` selects records without a review. Record a
261
- new append-only review revision by passing a record ID, a required
262
- `--decision=<allow|ask|deny>`, and an optional `--note`. Add `--json` to either
263
- operation to emit one versioned JSON document; JSON failures are written to
270
+ new append-only review revision by passing a record ID and a required
271
+ `--decision=<allow|ask|deny>`. Corrections also require `--reason` with one of
272
+ `inert-or-read-only`, `sensitive-data`, `security-boundary`, `recoverability`,
273
+ `shared-infrastructure`, `blast-radius`, `static-uncertainty`, `missing-context`,
274
+ or `service-failure`; `--note` remains optional. Add `--json` to list, evaluate,
275
+ or review to emit one versioned JSON document; JSON failures are written to
264
276
  stdout with a nonzero exit code. Interactive `demur training review` still opens
265
277
  the TUI, but without a terminal it requires an explicit list or record-ID review
266
278
  operation. Reviews remain separate from the original evidence so they can later
@@ -325,11 +337,36 @@ and deterministic policy controls alongside demur.
325
337
  - `src/guard.internal.ts` — Effect-native orchestration and fail-closed recovery
326
338
  - `src/guard.ts` — managed runtime and Promise boundary
327
339
  - `src/training-review-model.ts` — historical review status and cwd filtering
340
+ - `src/training-evaluation.ts` — offline correction metrics and threshold comparisons
328
341
  - `src/training-review-tui.tsx` — interactive OpenTUI training-review queue
329
342
  - `extensions/demur/` — Pi `tool_call` integration and training-state storage
330
343
  - `src/adapters/claude-code.ts` — Claude Code `PreToolUse` integration
331
344
  - `eval/` — safe synthetic contrast cases and the live evaluation runner
332
345
 
346
+ ## Training feedback evaluation
347
+
348
+ `demur training evaluate` joins each record with its latest review and replays
349
+ stored raw judgments through the shared `decide()` policy. By default it makes
350
+ no TypeSafe requests. The report includes a decision matrix, an asymmetric weighted loss
351
+ that penalizes unsafe false allows most heavily, correction counts grouped by
352
+ structured reason, replay fidelity, and up to five single-threshold candidates
353
+ that improve the observed records.
354
+
355
+ Candidates are exploratory and are never applied automatically. The command
356
+ scores them on the same private records used to discover them, so validate a
357
+ candidate on an independent holdout and the synthetic corpus before changing
358
+ `THRESHOLDS`. Newly captured version-two records include the exact model state
359
+ and static-gate analysis required for complete replay; legacy records support
360
+ policy-only replay.
361
+
362
+ Pass `--replay` explicitly to send reviewed version-two records' previously
363
+ captured model state to TypeSafe again using the current question set. This is a
364
+ cost-bearing operation and re-discloses the stored command and context described
365
+ above. It is capped at 20 requests by default; use `--limit=<1-100>` to choose a
366
+ different bound. Commands remain data and are never executed. The replay report shows
367
+ improvements, regressions, failures, token usage, and per-record raw judgments;
368
+ use it when changing one question at a time.
369
+
333
370
  ## Synthetic evaluation
334
371
 
335
372
  The synthetic evaluation measures whether the two policy-qualification questions
@@ -7,7 +7,11 @@ import {
7
7
  type ToolCallEvent,
8
8
  type ToolCallEventResult,
9
9
  } from "@earendil-works/pi-coding-agent";
10
- import type { Verdict } from "../../src/types.ts";
10
+ import type {
11
+ GuardEvaluation,
12
+ GuardEvidence,
13
+ Verdict,
14
+ } from "../../src/types.ts";
11
15
  import {
12
16
  estimateInputCostUsd,
13
17
  formatUsd,
@@ -54,9 +58,15 @@ export async function handleToolCall(
54
58
  if (command.trim() === "") return undefined;
55
59
 
56
60
  const evaluationStartedAt = performance.now();
57
- let verdict: Verdict;
61
+ let evaluation: GuardEvaluation;
58
62
  try {
59
- verdict = await runGuardWorker(command, ctx.cwd, ctx.signal);
63
+ evaluation = await runGuardWorkerRequest(
64
+ command,
65
+ ctx.cwd,
66
+ ctx.signal,
67
+ process.env,
68
+ settings.training,
69
+ );
60
70
  } catch (error: unknown) {
61
71
  const evaluationMs = performance.now() - evaluationStartedAt;
62
72
  if (ctx.signal?.aborted) {
@@ -74,16 +84,20 @@ export async function handleToolCall(
74
84
  };
75
85
  }
76
86
 
77
- verdict = {
78
- decision: "deny",
79
- reason: `demur: guard worker crashed — ${errorDetail(error)}`,
80
- judgments: undefined,
81
- failure: "unexpected",
82
- latencyMs: evaluationMs,
83
- usage: undefined,
87
+ evaluation = {
88
+ verdict: {
89
+ decision: "deny",
90
+ reason: `demur: guard worker crashed — ${errorDetail(error)}`,
91
+ judgments: undefined,
92
+ failure: "unexpected",
93
+ latencyMs: evaluationMs,
94
+ usage: undefined,
95
+ },
96
+ evidence: undefined,
84
97
  };
85
98
  }
86
99
 
100
+ const verdict = evaluation.verdict;
87
101
  const evaluationMs = performance.now() - evaluationStartedAt;
88
102
  const inputTokens = verdict.usage?.inputTokens;
89
103
  const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
@@ -109,6 +123,7 @@ export async function handleToolCall(
109
123
  ctx,
110
124
  settings.mode,
111
125
  verdict,
126
+ evaluation.evidence,
112
127
  result,
113
128
  );
114
129
  }
@@ -302,6 +317,18 @@ export function runGuardWorker(
302
317
  signal: AbortSignal | undefined,
303
318
  environment: NodeJS.ProcessEnv = process.env,
304
319
  ): Promise<Verdict> {
320
+ return runGuardWorkerRequest(command, cwd, signal, environment, false).then(
321
+ (evaluation) => evaluation.verdict,
322
+ );
323
+ }
324
+
325
+ function runGuardWorkerRequest(
326
+ command: string,
327
+ cwd: string,
328
+ signal: AbortSignal | undefined,
329
+ environment: NodeJS.ProcessEnv,
330
+ includeEvidence: boolean,
331
+ ): Promise<GuardEvaluation> {
305
332
  return new Promise((resolve, reject) => {
306
333
  if (signal?.aborted) {
307
334
  reject(new Error("guard request cancelled"));
@@ -333,7 +360,6 @@ export function runGuardWorker(
333
360
  signal?.addEventListener("abort", abort, { once: true });
334
361
  child.stdout.on("data", (chunk: Buffer) => {
335
362
  stdout = appendBounded(stdout, chunk);
336
- if (stdout.length >= MAX_WORKER_OUTPUT_BYTES) child.kill();
337
363
  });
338
364
  child.stderr.on("data", (chunk: Buffer) => {
339
365
  stderr = appendBounded(stderr, chunk);
@@ -349,7 +375,7 @@ export function runGuardWorker(
349
375
  }
350
376
 
351
377
  try {
352
- resolve(parseVerdict(stdout));
378
+ resolve(parseGuardWorkerResponse(stdout, includeEvidence));
353
379
  } catch (error: unknown) {
354
380
  reject(new Error(`invalid guard worker response: ${errorDetail(error)}`));
355
381
  }
@@ -357,7 +383,7 @@ export function runGuardWorker(
357
383
  });
358
384
 
359
385
  child.stdin.on("error", (error) => finish(() => reject(error)));
360
- child.stdin.end(JSON.stringify({ command, cwd }));
386
+ child.stdin.end(JSON.stringify({ command, cwd, includeEvidence }));
361
387
  });
362
388
  }
363
389
 
@@ -496,14 +522,22 @@ async function recordTrainingResult(
496
522
  ctx: ExtensionContext,
497
523
  mode: "enforce" | "passive",
498
524
  verdict: Verdict,
525
+ evidence: GuardEvidence | undefined,
499
526
  result: ToolCallEventResult | undefined,
500
527
  ): Promise<void> {
501
528
  try {
529
+ if (verdict.judgments !== undefined && evidence === undefined) {
530
+ ctx.ui.notify(
531
+ "demur: recording training evaluation without replay evidence",
532
+ "warning",
533
+ );
534
+ }
502
535
  await recordTrainingEvaluation({
503
536
  command,
504
537
  cwd: ctx.cwd,
505
538
  mode,
506
539
  verdict,
540
+ evidence,
507
541
  hostAction: result?.block === true ? "block" : "allow",
508
542
  });
509
543
  } catch (error: unknown) {
@@ -630,8 +664,36 @@ function formatDecimal(value: number, fractionDigits: number): string {
630
664
  .replace(/\.0+$/, "");
631
665
  }
632
666
 
633
- function parseVerdict(output: string): Verdict {
634
- const value: unknown = JSON.parse(output);
667
+ function parseGuardWorkerResponse(
668
+ output: string,
669
+ includeEvidence: boolean,
670
+ ): GuardEvaluation {
671
+ if (!includeEvidence) {
672
+ return {
673
+ verdict: parseVerdictValue(JSON.parse(output)),
674
+ evidence: undefined,
675
+ };
676
+ }
677
+
678
+ const separator = output.indexOf("\n");
679
+ if (separator < 0) {
680
+ throw new Error("training guard response did not contain a verdict line");
681
+ }
682
+
683
+ const verdict = parseVerdictValue(JSON.parse(output.slice(0, separator)));
684
+ const encodedEvidence = output.slice(separator + 1);
685
+ try {
686
+ return {
687
+ verdict,
688
+ evidence: parseGuardEvidence(JSON.parse(encodedEvidence)),
689
+ };
690
+ } catch {
691
+ // Training evidence is best-effort and must never change the guard verdict.
692
+ return { verdict, evidence: undefined };
693
+ }
694
+ }
695
+
696
+ function parseVerdictValue(value: unknown): Verdict {
635
697
  if (value === null || typeof value !== "object") {
636
698
  throw new Error("verdict must be an object");
637
699
  }
@@ -657,6 +719,28 @@ function parseVerdict(output: string): Verdict {
657
719
  };
658
720
  }
659
721
 
722
+ function parseGuardEvidence(value: unknown): GuardEvidence | undefined {
723
+ if (value === undefined || value === null) return undefined;
724
+ if (typeof value !== "object") {
725
+ throw new Error("guard evidence must be an object");
726
+ }
727
+
728
+ const evidence = value as Record<string, unknown>;
729
+ if (
730
+ evidence.modelState === null ||
731
+ typeof evidence.modelState !== "object" ||
732
+ typeof evidence.model !== "string" ||
733
+ !Number.isInteger(evidence.questionSetVersion) ||
734
+ !Number.isInteger(evidence.policyVersion) ||
735
+ evidence.policyThresholds === null ||
736
+ typeof evidence.policyThresholds !== "object"
737
+ ) {
738
+ throw new Error("guard evidence is incomplete");
739
+ }
740
+
741
+ return evidence as GuardEvidence;
742
+ }
743
+
660
744
  function errorDetail(error: unknown): string {
661
745
  return error instanceof Error ? error.message : String(error);
662
746
  }
@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import type { Decision, Verdict } from "../../src/types.ts";
5
+ import type {
6
+ Decision,
7
+ GuardEvidence,
8
+ Verdict,
9
+ } from "../../src/types.ts";
6
10
  import { getDemurStateDirectory } from "./paths.ts";
7
11
 
8
12
  const LOCK_RETRY_MS = 10;
@@ -19,9 +23,9 @@ export type TrainingMode = "enforce" | "passive";
19
23
  export type TrainingHostAction = "allow" | "block";
20
24
 
21
25
  /**
22
- * Complete evidence captured for one training-mode command evaluation.
26
+ * Legacy training record captured before replayable invocation evidence existed.
23
27
  */
24
- export type TrainingRecord = {
28
+ export type TrainingRecordV1 = {
25
29
  version: 1;
26
30
  id: string;
27
31
  recordedAt: string;
@@ -33,9 +37,60 @@ export type TrainingRecord = {
33
37
  };
34
38
 
35
39
  /**
36
- * Human review of one captured training evaluation.
40
+ * Training record with exact model state and policy provenance.
37
41
  */
38
- export type TrainingReview = {
42
+ export type TrainingRecordV2 = {
43
+ version: 2;
44
+ id: string;
45
+ recordedAt: string;
46
+ command: string;
47
+ cwd: string;
48
+ mode: TrainingMode;
49
+ verdict: Verdict;
50
+ evidence: GuardEvidence | undefined;
51
+ hostAction: TrainingHostAction;
52
+ };
53
+
54
+ /**
55
+ * Complete evidence captured for one training-mode command evaluation.
56
+ */
57
+ export type TrainingRecord = TrainingRecordV1 | TrainingRecordV2;
58
+
59
+ /**
60
+ * Machine-readable explanation for why a human corrected a verdict.
61
+ */
62
+ export type TrainingCorrectionReason =
63
+ | "inert-or-read-only"
64
+ | "sensitive-data"
65
+ | "security-boundary"
66
+ | "recoverability"
67
+ | "shared-infrastructure"
68
+ | "blast-radius"
69
+ | "static-uncertainty"
70
+ | "missing-context"
71
+ | "service-failure";
72
+
73
+ /**
74
+ * Stable correction-reason values accepted by storage and CLI boundaries.
75
+ */
76
+ export const TRAINING_CORRECTION_REASONS: ReadonlyArray<
77
+ TrainingCorrectionReason
78
+ > = [
79
+ "inert-or-read-only",
80
+ "sensitive-data",
81
+ "security-boundary",
82
+ "recoverability",
83
+ "shared-infrastructure",
84
+ "blast-radius",
85
+ "static-uncertainty",
86
+ "missing-context",
87
+ "service-failure",
88
+ ];
89
+
90
+ /**
91
+ * Legacy human review recorded before structured correction reasons existed.
92
+ */
93
+ export type TrainingReviewV1 = {
39
94
  version: 1;
40
95
  recordId: string;
41
96
  reviewedAt: string;
@@ -44,6 +99,24 @@ export type TrainingReview = {
44
99
  note: string | undefined;
45
100
  };
46
101
 
102
+ /**
103
+ * Human review with a structured reason for corrected model decisions.
104
+ */
105
+ export type TrainingReviewV2 = {
106
+ version: 2;
107
+ recordId: string;
108
+ reviewedAt: string;
109
+ originalDecision: Decision;
110
+ expectedDecision: Decision;
111
+ correctionReason: TrainingCorrectionReason | undefined;
112
+ note: string | undefined;
113
+ };
114
+
115
+ /**
116
+ * Human review of one captured training evaluation.
117
+ */
118
+ export type TrainingReview = TrainingReviewV1 | TrainingReviewV2;
119
+
47
120
  /**
48
121
  * Input required to append a human review.
49
122
  */
@@ -51,9 +124,18 @@ export type TrainingReviewInput = {
51
124
  recordId: string;
52
125
  originalDecision: Decision;
53
126
  expectedDecision: Decision;
127
+ correctionReason: TrainingCorrectionReason | undefined;
54
128
  note: string | undefined;
55
129
  };
56
130
 
131
+ /**
132
+ * Input required to append a replayable training evaluation.
133
+ */
134
+ export type TrainingRecordInput = Omit<
135
+ TrainingRecordV2,
136
+ "version" | "id" | "recordedAt"
137
+ >;
138
+
57
139
  /**
58
140
  * Resolve the global training-record file according to demur and XDG overrides.
59
141
  *
@@ -96,11 +178,11 @@ export function getTrainingReviewPath(
96
178
  * @returns The persisted record with generated identity and timestamp
97
179
  */
98
180
  export async function recordTrainingEvaluation(
99
- input: Omit<TrainingRecord, "version" | "id" | "recordedAt">,
181
+ input: TrainingRecordInput,
100
182
  logPath: string = getTrainingLogPath(),
101
- ): Promise<TrainingRecord> {
102
- const record: TrainingRecord = {
103
- version: 1,
183
+ ): Promise<TrainingRecordV2> {
184
+ const record: TrainingRecordV2 = {
185
+ version: 2,
104
186
  id: randomUUID(),
105
187
  recordedAt: new Date().toISOString(),
106
188
  ...input,
@@ -131,9 +213,22 @@ export async function loadTrainingRecords(
131
213
  export async function recordTrainingReview(
132
214
  input: TrainingReviewInput,
133
215
  reviewPath: string = getTrainingReviewPath(),
134
- ): Promise<TrainingReview> {
135
- const review: TrainingReview = {
136
- version: 1,
216
+ ): Promise<TrainingReviewV2> {
217
+ if (
218
+ input.originalDecision !== input.expectedDecision &&
219
+ input.correctionReason === undefined
220
+ ) {
221
+ throw new Error("corrected training reviews require a correction reason");
222
+ }
223
+ if (
224
+ input.originalDecision === input.expectedDecision &&
225
+ input.correctionReason !== undefined
226
+ ) {
227
+ throw new Error("accepted training reviews cannot have a correction reason");
228
+ }
229
+
230
+ const review: TrainingReviewV2 = {
231
+ version: 2,
137
232
  reviewedAt: new Date().toISOString(),
138
233
  ...input,
139
234
  };
@@ -213,41 +308,37 @@ function parseTrainingRecord(
213
308
 
214
309
  const record = value as Record<string, unknown>;
215
310
  if (
216
- record.version !== 1 ||
311
+ (record.version !== 1 && record.version !== 2) ||
217
312
  typeof record.id !== "string" ||
218
313
  typeof record.recordedAt !== "string" ||
219
314
  typeof record.command !== "string" ||
220
315
  typeof record.cwd !== "string" ||
221
316
  (record.mode !== "enforce" && record.mode !== "passive") ||
222
317
  (record.hostAction !== "allow" && record.hostAction !== "block") ||
223
- !isVerdict(record.verdict)
318
+ !isVerdict(record.verdict) ||
319
+ (record.version === 2 && !isGuardEvidence(record.evidence))
224
320
  ) {
225
321
  throw invalidRecord(path, line);
226
322
  }
227
323
 
228
- const verdict = record.verdict as Record<string, unknown>;
229
- return {
230
- version: 1,
324
+ const verdict = normalizeVerdict(record.verdict);
325
+ const common = {
231
326
  id: record.id,
232
327
  recordedAt: record.recordedAt,
233
328
  command: record.command,
234
329
  cwd: record.cwd,
235
330
  mode: record.mode,
236
- verdict: {
237
- decision: verdict.decision as Decision,
238
- reason: verdict.reason as string,
239
- judgments: verdict.judgments === null
240
- ? undefined
241
- : verdict.judgments as Verdict["judgments"],
242
- failure: verdict.failure === null
243
- ? undefined
244
- : verdict.failure as Verdict["failure"],
245
- latencyMs: verdict.latencyMs as number,
246
- usage: verdict.usage === null
247
- ? undefined
248
- : verdict.usage as Verdict["usage"],
249
- },
331
+ verdict,
250
332
  hostAction: record.hostAction,
333
+ } as const;
334
+
335
+ if (record.version === 1) return { version: 1, ...common };
336
+ return {
337
+ version: 2,
338
+ ...common,
339
+ evidence: record.evidence === null
340
+ ? undefined
341
+ : normalizeGuardEvidence(record.evidence),
251
342
  };
252
343
  }
253
344
 
@@ -262,25 +353,55 @@ function parseTrainingReview(
262
353
 
263
354
  const review = value as Record<string, unknown>;
264
355
  if (
265
- review.version !== 1 ||
356
+ (review.version !== 1 && review.version !== 2) ||
266
357
  typeof review.recordId !== "string" ||
267
358
  typeof review.reviewedAt !== "string" ||
268
359
  !isDecision(review.originalDecision) ||
269
360
  !isDecision(review.expectedDecision) ||
270
361
  (review.note !== undefined &&
271
362
  review.note !== null &&
272
- typeof review.note !== "string")
363
+ typeof review.note !== "string") ||
364
+ (review.version === 2 &&
365
+ (!isReviewCorrectionReasonValid(review) ||
366
+ (review.correctionReason !== undefined &&
367
+ review.correctionReason !== null &&
368
+ !isTrainingCorrectionReason(review.correctionReason))))
273
369
  ) {
274
370
  throw invalidRecord(path, line);
275
371
  }
276
372
 
277
- return {
278
- version: 1,
373
+ const common = {
279
374
  recordId: review.recordId,
280
375
  reviewedAt: review.reviewedAt,
281
376
  originalDecision: review.originalDecision,
282
377
  expectedDecision: review.expectedDecision,
283
378
  note: review.note === null ? undefined : review.note,
379
+ } as const;
380
+ if (review.version === 1) return { version: 1, ...common };
381
+ return {
382
+ version: 2,
383
+ ...common,
384
+ correctionReason: review.correctionReason === null
385
+ ? undefined
386
+ : review.correctionReason as TrainingCorrectionReason | undefined,
387
+ };
388
+ }
389
+
390
+ function normalizeVerdict(value: unknown): Verdict {
391
+ const verdict = value as Record<string, unknown>;
392
+ return {
393
+ decision: verdict.decision as Decision,
394
+ reason: verdict.reason as string,
395
+ judgments: verdict.judgments === null
396
+ ? undefined
397
+ : verdict.judgments as Verdict["judgments"],
398
+ failure: verdict.failure === null
399
+ ? undefined
400
+ : verdict.failure as Verdict["failure"],
401
+ latencyMs: verdict.latencyMs as number,
402
+ usage: verdict.usage === null
403
+ ? undefined
404
+ : verdict.usage as Verdict["usage"],
284
405
  };
285
406
  }
286
407
 
@@ -296,6 +417,63 @@ function isDecision(value: unknown): value is Decision {
296
417
  return value === "allow" || value === "ask" || value === "deny";
297
418
  }
298
419
 
420
+ function isReviewCorrectionReasonValid(
421
+ review: Record<string, unknown>,
422
+ ): boolean {
423
+ const corrected = review.originalDecision !== review.expectedDecision;
424
+ const hasReason = review.correctionReason !== undefined &&
425
+ review.correctionReason !== null;
426
+ return corrected === hasReason;
427
+ }
428
+
429
+ /**
430
+ * Check whether a value is a supported structured correction reason.
431
+ *
432
+ * @param value - Candidate correction-reason value
433
+ * @returns Whether the value belongs to the stable correction taxonomy
434
+ */
435
+ export function isTrainingCorrectionReason(
436
+ value: unknown,
437
+ ): value is TrainingCorrectionReason {
438
+ return typeof value === "string" &&
439
+ TRAINING_CORRECTION_REASONS.includes(value as TrainingCorrectionReason);
440
+ }
441
+
442
+ function isGuardEvidence(value: unknown): boolean {
443
+ if (value === null || value === undefined) return true;
444
+ if (typeof value !== "object") return false;
445
+ const evidence = value as Record<string, unknown>;
446
+ return evidence.modelState !== null &&
447
+ typeof evidence.modelState === "object" &&
448
+ (evidence.analysis === null || evidence.analysis === undefined ||
449
+ typeof evidence.analysis === "object") &&
450
+ typeof evidence.model === "string" &&
451
+ Number.isInteger(evidence.questionSetVersion) &&
452
+ Number.isInteger(evidence.policyVersion) &&
453
+ isNumberRecord(evidence.policyThresholds);
454
+ }
455
+
456
+ function normalizeGuardEvidence(value: unknown): GuardEvidence {
457
+ const evidence = value as Record<string, unknown>;
458
+ return {
459
+ modelState: evidence.modelState as GuardEvidence["modelState"],
460
+ analysis: evidence.analysis === null
461
+ ? undefined
462
+ : evidence.analysis as GuardEvidence["analysis"],
463
+ model: evidence.model as string,
464
+ questionSetVersion: evidence.questionSetVersion as number,
465
+ policyVersion: evidence.policyVersion as number,
466
+ policyThresholds: evidence.policyThresholds as GuardEvidence["policyThresholds"],
467
+ };
468
+ }
469
+
470
+ function isNumberRecord(value: unknown): boolean {
471
+ if (value === null || typeof value !== "object") return false;
472
+ return Object.values(value).every((entry) =>
473
+ typeof entry === "number" && Number.isFinite(entry)
474
+ );
475
+ }
476
+
299
477
  function invalidRecord(path: string, line: number): Error {
300
478
  return new Error(`invalid training record in ${path} at line ${line}`);
301
479
  }