instar 1.3.830 → 1.3.832

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 (114) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +83 -12
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/AutonomousRealCheckAnnotator.d.ts +105 -0
  5. package/dist/core/AutonomousRealCheckAnnotator.d.ts.map +1 -0
  6. package/dist/core/AutonomousRealCheckAnnotator.js +122 -0
  7. package/dist/core/AutonomousRealCheckAnnotator.js.map +1 -0
  8. package/dist/core/AutonomousRunStore.d.ts +29 -0
  9. package/dist/core/AutonomousRunStore.d.ts.map +1 -1
  10. package/dist/core/AutonomousRunStore.js +38 -0
  11. package/dist/core/AutonomousRunStore.js.map +1 -1
  12. package/dist/core/BackupManager.d.ts.map +1 -1
  13. package/dist/core/BackupManager.js +47 -1
  14. package/dist/core/BackupManager.js.map +1 -1
  15. package/dist/core/CircuitBreakingIntelligenceProvider.d.ts +12 -0
  16. package/dist/core/CircuitBreakingIntelligenceProvider.d.ts.map +1 -1
  17. package/dist/core/CircuitBreakingIntelligenceProvider.js +56 -4
  18. package/dist/core/CircuitBreakingIntelligenceProvider.js.map +1 -1
  19. package/dist/core/CompletionEvaluator.d.ts +62 -2
  20. package/dist/core/CompletionEvaluator.d.ts.map +1 -1
  21. package/dist/core/CompletionEvaluator.js +124 -6
  22. package/dist/core/CompletionEvaluator.js.map +1 -1
  23. package/dist/core/DecisionQualityRecorderImpl.d.ts +192 -0
  24. package/dist/core/DecisionQualityRecorderImpl.d.ts.map +1 -0
  25. package/dist/core/DecisionQualityRecorderImpl.js +363 -0
  26. package/dist/core/DecisionQualityRecorderImpl.js.map +1 -0
  27. package/dist/core/IntelligenceRouter.d.ts +33 -0
  28. package/dist/core/IntelligenceRouter.d.ts.map +1 -1
  29. package/dist/core/IntelligenceRouter.js +240 -54
  30. package/dist/core/IntelligenceRouter.js.map +1 -1
  31. package/dist/core/JudgmentProvenanceLog.d.ts +102 -2
  32. package/dist/core/JudgmentProvenanceLog.d.ts.map +1 -1
  33. package/dist/core/JudgmentProvenanceLog.js +239 -11
  34. package/dist/core/JudgmentProvenanceLog.js.map +1 -1
  35. package/dist/core/MessagingToneGate.d.ts +30 -0
  36. package/dist/core/MessagingToneGate.d.ts.map +1 -1
  37. package/dist/core/MessagingToneGate.js +63 -0
  38. package/dist/core/MessagingToneGate.js.map +1 -1
  39. package/dist/core/PostUpdateMigrator.d.ts +11 -0
  40. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  41. package/dist/core/PostUpdateMigrator.js +32 -0
  42. package/dist/core/PostUpdateMigrator.js.map +1 -1
  43. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  44. package/dist/core/WriteDomainRegistry.js +24 -0
  45. package/dist/core/WriteDomainRegistry.js.map +1 -1
  46. package/dist/core/decisionGradingPass.d.ts +89 -0
  47. package/dist/core/decisionGradingPass.d.ts.map +1 -0
  48. package/dist/core/decisionGradingPass.js +188 -0
  49. package/dist/core/decisionGradingPass.js.map +1 -0
  50. package/dist/core/decisionQualityTypes.d.ts +170 -0
  51. package/dist/core/decisionQualityTypes.d.ts.map +1 -0
  52. package/dist/core/decisionQualityTypes.js +85 -0
  53. package/dist/core/decisionQualityTypes.js.map +1 -0
  54. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  55. package/dist/core/devGatedFeatures.js +6 -0
  56. package/dist/core/devGatedFeatures.js.map +1 -1
  57. package/dist/core/machineCoherenceManifest.d.ts.map +1 -1
  58. package/dist/core/machineCoherenceManifest.js +4 -0
  59. package/dist/core/machineCoherenceManifest.js.map +1 -1
  60. package/dist/core/types.d.ts +69 -4
  61. package/dist/core/types.d.ts.map +1 -1
  62. package/dist/core/types.js.map +1 -1
  63. package/dist/data/provenanceCoverage.d.ts +156 -0
  64. package/dist/data/provenanceCoverage.d.ts.map +1 -0
  65. package/dist/data/provenanceCoverage.js +674 -0
  66. package/dist/data/provenanceCoverage.js.map +1 -0
  67. package/dist/monitoring/ExternalHogDecisionStore.d.ts +256 -0
  68. package/dist/monitoring/ExternalHogDecisionStore.d.ts.map +1 -0
  69. package/dist/monitoring/ExternalHogDecisionStore.js +481 -0
  70. package/dist/monitoring/ExternalHogDecisionStore.js.map +1 -0
  71. package/dist/monitoring/ExternalHogRealAdapters.d.ts +6 -2
  72. package/dist/monitoring/ExternalHogRealAdapters.d.ts.map +1 -1
  73. package/dist/monitoring/ExternalHogRealAdapters.js +2 -2
  74. package/dist/monitoring/ExternalHogRealAdapters.js.map +1 -1
  75. package/dist/monitoring/ExternalHogScanTick.d.ts +95 -3
  76. package/dist/monitoring/ExternalHogScanTick.d.ts.map +1 -1
  77. package/dist/monitoring/ExternalHogScanTick.js +102 -9
  78. package/dist/monitoring/ExternalHogScanTick.js.map +1 -1
  79. package/dist/monitoring/ExternalHogSentinel.d.ts +71 -3
  80. package/dist/monitoring/ExternalHogSentinel.d.ts.map +1 -1
  81. package/dist/monitoring/ExternalHogSentinel.js +126 -2
  82. package/dist/monitoring/ExternalHogSentinel.js.map +1 -1
  83. package/dist/monitoring/ExternalHogServerPrimitives.d.ts +10 -2
  84. package/dist/monitoring/ExternalHogServerPrimitives.d.ts.map +1 -1
  85. package/dist/monitoring/ExternalHogServerPrimitives.js +1 -1
  86. package/dist/monitoring/ExternalHogServerPrimitives.js.map +1 -1
  87. package/dist/monitoring/FeatureMetricsLedger.d.ts +304 -0
  88. package/dist/monitoring/FeatureMetricsLedger.d.ts.map +1 -1
  89. package/dist/monitoring/FeatureMetricsLedger.js +731 -0
  90. package/dist/monitoring/FeatureMetricsLedger.js.map +1 -1
  91. package/dist/scaffold/templates.d.ts.map +1 -1
  92. package/dist/scaffold/templates.js +2 -1
  93. package/dist/scaffold/templates.js.map +1 -1
  94. package/dist/server/AgentServer.d.ts.map +1 -1
  95. package/dist/server/AgentServer.js +55 -1
  96. package/dist/server/AgentServer.js.map +1 -1
  97. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  98. package/dist/server/CapabilityIndex.js +15 -1
  99. package/dist/server/CapabilityIndex.js.map +1 -1
  100. package/dist/server/fileRoutes.d.ts.map +1 -1
  101. package/dist/server/fileRoutes.js +9 -0
  102. package/dist/server/fileRoutes.js.map +1 -1
  103. package/dist/server/routes.d.ts.map +1 -1
  104. package/dist/server/routes.js +334 -5
  105. package/dist/server/routes.js.map +1 -1
  106. package/package.json +1 -1
  107. package/src/data/builtin-manifest.json +65 -65
  108. package/src/data/provenanceCoverage.ts +850 -0
  109. package/src/scaffold/templates/jobs/instar/llm-decision-grading.md +32 -0
  110. package/src/scaffold/templates.ts +2 -1
  111. package/upgrades/1.3.831.md +30 -0
  112. package/upgrades/1.3.832.md +22 -0
  113. package/upgrades/side-effects/llm-decision-quality-meter.md +95 -0
  114. package/upgrades/side-effects/messaging-tone-gate-provenance-enrollment.md +68 -0
@@ -16,6 +16,14 @@
16
16
  *
17
17
  * The per-feature key is the existing IntelligenceOptions.attribution.component
18
18
  * tag (e.g. "MessagingToneGate"); calls without one bucket under "unlabeled".
19
+ *
20
+ * Also owns the decision-quality substrate (llm-decision-quality-meter §5.5):
21
+ * the `decision_quality` / `decision_outcomes` / `decision_quality_rollup` /
22
+ * `decision_grading_cursor` tables plus the CANONICAL winning-grade derivation
23
+ * (the `decision_winning_grade` SQL view — the ONE place §5.4's precedence +
24
+ * within-rung rules live; both the read route and the rollup recompute consume
25
+ * it). Same posture as the metrics rows: synchronous WAL writes in isolated
26
+ * try/catch that NEVER throw into a caller, injected now() everywhere.
19
27
  */
20
28
  import * as fs from 'node:fs';
21
29
  import * as path from 'node:path';
@@ -55,6 +63,102 @@ const SCHEMA = [
55
63
  PRIMARY KEY (day, door, model_id)
56
64
  )`,
57
65
  `CREATE INDEX IF NOT EXISTS idx_spend_token_rollup_day ON spend_token_rollup (day)`,
66
+ // ---- Decision-quality substrate (llm-decision-quality-meter §5.5) ----
67
+ // One ~250B content-free row per settled ENROLLED decision, written for EVERY
68
+ // enrolled settlement regardless of volume class (the volume valve governs the
69
+ // provenance JSONL row only) — so outcome rows always have parents and counts
70
+ // are complete. recheck_count/next_recheck_ts carry the per-decision
71
+ // unknown-regrade backoff state (§5.4.6, DC r3).
72
+ `CREATE TABLE IF NOT EXISTS decision_quality (
73
+ correlation_id TEXT PRIMARY KEY,
74
+ decision_point TEXT NOT NULL,
75
+ feature TEXT,
76
+ ts INTEGER NOT NULL,
77
+ verdict_class TEXT,
78
+ minted_by TEXT,
79
+ volume_class TEXT,
80
+ content_class TEXT,
81
+ machine_id TEXT,
82
+ model TEXT,
83
+ framework TEXT,
84
+ prompt_id TEXT,
85
+ recheck_count INTEGER NOT NULL DEFAULT 0,
86
+ next_recheck_ts INTEGER
87
+ )`,
88
+ // Covering index for the read surface + grading keyset walk (point, ts,
89
+ // correlation_id); a plain ts index carries the retention prune.
90
+ `CREATE INDEX IF NOT EXISTS idx_decision_quality_point_ts ON decision_quality (decision_point, ts, correlation_id)`,
91
+ `CREATE INDEX IF NOT EXISTS idx_decision_quality_ts ON decision_quality (ts)`,
92
+ // Outcome annotations (§5.4): upsert key = (correlation_id, graded_by) — a
93
+ // grader supersedes its own prior grade, never multiplies. evidence_note is
94
+ // ≤500 scrubbed chars and NEVER served by /decision-quality.
95
+ `CREATE TABLE IF NOT EXISTS decision_outcomes (
96
+ correlation_id TEXT NOT NULL,
97
+ graded_by TEXT NOT NULL,
98
+ rule_id TEXT NOT NULL,
99
+ rung TEXT NOT NULL,
100
+ evidence_strength TEXT NOT NULL,
101
+ grade TEXT NOT NULL,
102
+ effective_window_ms INTEGER,
103
+ evidence_note TEXT,
104
+ ts INTEGER NOT NULL,
105
+ UNIQUE (correlation_id, graded_by)
106
+ )`,
107
+ `CREATE INDEX IF NOT EXISTS idx_decision_outcomes_ts ON decision_outcomes (ts)`,
108
+ // Content-free daily aggregate: decision_point × the DECISION's UTC day ×
109
+ // winning-grade counts + event counters. Grades are MUTABLE facts (unlike the
110
+ // spend rollup's immutable increments): each outcome upsert RECOMPUTES its
111
+ // affected bucket from decision_quality ⋈ decision_winning_grade. 'expired'
112
+ // is NOT a column — derived at read.
113
+ `CREATE TABLE IF NOT EXISTS decision_quality_rollup (
114
+ decision_point TEXT NOT NULL,
115
+ day TEXT NOT NULL,
116
+ right_count INTEGER NOT NULL DEFAULT 0,
117
+ wrong_count INTEGER NOT NULL DEFAULT 0,
118
+ unknown_count INTEGER NOT NULL DEFAULT 0,
119
+ orphan_outcomes INTEGER NOT NULL DEFAULT 0,
120
+ join_miss INTEGER NOT NULL DEFAULT 0,
121
+ dropped_by_budget INTEGER NOT NULL DEFAULT 0,
122
+ PRIMARY KEY (decision_point, day)
123
+ )`,
124
+ `CREATE INDEX IF NOT EXISTS idx_decision_quality_rollup_day ON decision_quality_rollup (day)`,
125
+ // The grading job's durable per-decision-point keyset cursor (§5.5 — a table,
126
+ // not an implicit "fourth thing"), + the per-point P19 recheck backoff.
127
+ `CREATE TABLE IF NOT EXISTS decision_grading_cursor (
128
+ decision_point TEXT PRIMARY KEY,
129
+ cursor_ts INTEGER NOT NULL DEFAULT 0,
130
+ cursor_correlation_id TEXT NOT NULL DEFAULT '',
131
+ next_recheck_ts INTEGER,
132
+ attempts INTEGER NOT NULL DEFAULT 0,
133
+ updated_at INTEGER NOT NULL DEFAULT 0
134
+ )`,
135
+ // THE CANONICAL WINNING-GRADE DERIVATION (§5.5, codex r7 — defined ONCE).
136
+ // Precedence: deterministic-ground-truth > recurrence > llm-interpreter >
137
+ // self-report (a self-report NEVER overrides an independent grader); within
138
+ // a rung, conservative: wrong > unknown > right. Deterministic tie-break
139
+ // (ts DESC, graded_by ASC) affects only WHICH row reports the grade, never
140
+ // the grade itself. Both the /decision-quality reads AND the rollup
141
+ // recompute consume THIS view — a parallel reimplementation of precedence
142
+ // is a correctness bug by construction.
143
+ `CREATE VIEW IF NOT EXISTS decision_winning_grade AS
144
+ SELECT correlation_id, grade, rung, evidence_strength, rule_id, graded_by
145
+ FROM (
146
+ SELECT o.*, ROW_NUMBER() OVER (
147
+ PARTITION BY o.correlation_id
148
+ ORDER BY
149
+ CASE o.rung
150
+ WHEN 'deterministic-ground-truth' THEN 0
151
+ WHEN 'recurrence' THEN 1
152
+ WHEN 'llm-interpreter' THEN 2
153
+ WHEN 'self-report' THEN 3
154
+ ELSE 4 END ASC,
155
+ CASE o.grade WHEN 'wrong' THEN 0 WHEN 'unknown' THEN 1 WHEN 'right' THEN 2 ELSE 3 END ASC,
156
+ o.ts DESC,
157
+ o.graded_by ASC
158
+ ) AS rn
159
+ FROM decision_outcomes o
160
+ )
161
+ WHERE rn = 1`,
58
162
  ];
59
163
  /**
60
164
  * Columns added after the table's first ship. CREATE TABLE IF NOT EXISTS never
@@ -75,6 +179,36 @@ const ADDED_COLUMNS = [
75
179
  ];
76
180
  /** Batch ceiling for the retention prune (scal-F4): SQLite-portable bounded DELETE. */
77
181
  const PRUNE_BATCH = 5000;
182
+ /** FD3 grade enum — validated at the substrate write (invalid → refused, never stored). */
183
+ const QUALITY_GRADES = new Set(['right', 'wrong', 'unknown']);
184
+ /** §5.4.2 rung enum — the closed set the canonical view's precedence CASE knows. */
185
+ const GRADING_RUNGS = new Set([
186
+ 'deterministic-ground-truth',
187
+ 'recurrence',
188
+ 'llm-interpreter',
189
+ 'self-report',
190
+ ]);
191
+ /**
192
+ * Rollup event-counter columns (closed map — counter names never interpolate
193
+ * user input into SQL). These are EVENT counters, not derivable from the
194
+ * decisions⋈outcomes join, so the bucket recompute/reconcile preserves them.
195
+ */
196
+ const QUALITY_COUNTER_COLUMNS = {
197
+ orphanOutcomes: 'orphan_outcomes',
198
+ joinMiss: 'join_miss',
199
+ droppedByBudget: 'dropped_by_budget',
200
+ };
201
+ /** Outcome evidence-note hard bound (§5.5 — ≤500 scrubbed chars, never served). */
202
+ const EVIDENCE_NOTE_MAX = 500;
203
+ /** Content-free label clamp: trimmed + bounded; null when absent/empty. */
204
+ function clampLabel(v, max = 128) {
205
+ if (v === undefined || v === null)
206
+ return null;
207
+ const t = String(v).trim();
208
+ if (!t)
209
+ return null;
210
+ return t.length > max ? t.slice(0, max) : t;
211
+ }
78
212
  /** UTC day key 'YYYY-MM-DD' for a timestamp (the daily-rollup bucket key). */
79
213
  function dayKey(ms) {
80
214
  return new Date(ms).toISOString().slice(0, 10);
@@ -98,8 +232,12 @@ export class FeatureMetricsLedger {
98
232
  now;
99
233
  insertStmt;
100
234
  rollupUpsertStmt = null;
235
+ /** Quality substrate (§5.5): null = the substrate failed to prepare; writes no-op. */
236
+ decisionInsertStmt = null;
237
+ outcomeUpsertStmt = null;
101
238
  maintainSpendRollup;
102
239
  lastRollupReconcileMs = null;
240
+ lastQualityReconcileMsValue = null;
103
241
  closed = false;
104
242
  constructor(opts) {
105
243
  this.now = opts.now ?? (() => Date.now());
@@ -115,6 +253,17 @@ export class FeatureMetricsLedger {
115
253
  for (const ddl of SCHEMA)
116
254
  this.db.exec(ddl);
117
255
  this.ensureAddedColumns();
256
+ // Partial index for the quality join (feature_metrics.verdict_id → correlation
257
+ // id, §5.5). Guarded separately from the SCHEMA loop: verdict_id shipped in the
258
+ // original CREATE (it is NOT in ADDED_COLUMNS), but an index-create failure on
259
+ // an exotic old DB must degrade (slower join) rather than brick the open path.
260
+ try {
261
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_feature_metrics_verdict_id
262
+ ON feature_metrics (verdict_id) WHERE verdict_id IS NOT NULL`);
263
+ }
264
+ catch {
265
+ // @silent-fallback-ok: the partial index is a read-path optimization only.
266
+ }
118
267
  // Close-on-exit registry (SqliteRegistry.ts) — closed once at shutdown.
119
268
  registerSqliteHandle(() => { try {
120
269
  this.db?.close();
@@ -143,6 +292,37 @@ export class FeatureMetricsLedger {
143
292
  this.rollupUpsertStmt = null;
144
293
  }
145
294
  }
295
+ // Quality substrate (llm-decision-quality-meter §5.5): prepared writes + the
296
+ // bounded BOOT reconcile (mirrors reconcileSpendRollup's boot arm; the PERIODIC
297
+ // arm rides AgentServer's boot+6h prune timer — both are the same idempotent
298
+ // fold). Not option-gated: the tables always exist here and the fold is a cheap
299
+ // indexed no-op on an agent with no enrolled decisions. Isolated try/catch — a
300
+ // quality-substrate failure degrades quality writes to no-ops and must never
301
+ // break the primary metrics insert path.
302
+ try {
303
+ this.decisionInsertStmt = this.db.prepare(`INSERT OR IGNORE INTO decision_quality
304
+ (correlation_id, decision_point, feature, ts, verdict_class, minted_by, volume_class, content_class, machine_id, model, framework, prompt_id)
305
+ VALUES (@correlationId, @decisionPoint, @feature, @ts, @verdictClass, @mintedBy, @volumeClass, @contentClass, @machineId, @model, @framework, @promptId)`);
306
+ this.outcomeUpsertStmt = this.db.prepare(`INSERT INTO decision_outcomes
307
+ (correlation_id, graded_by, rule_id, rung, evidence_strength, grade, effective_window_ms, evidence_note, ts)
308
+ VALUES (@correlationId, @gradedBy, @ruleId, @rung, @evidenceStrength, @grade, @effectiveWindowMs, @evidenceNote, @ts)
309
+ ON CONFLICT(correlation_id, graded_by) DO UPDATE SET
310
+ rule_id = excluded.rule_id,
311
+ rung = excluded.rung,
312
+ evidence_strength = excluded.evidence_strength,
313
+ grade = excluded.grade,
314
+ effective_window_ms = excluded.effective_window_ms,
315
+ evidence_note = excluded.evidence_note,
316
+ ts = excluded.ts`);
317
+ this.reconcileQualityRollup(30);
318
+ }
319
+ catch {
320
+ // @silent-fallback-ok: the quality substrate is observability. A prepare
321
+ // failure leaves recordDecision/upsertOutcome as no-ops (the quality view
322
+ // degrades to what it can read) — never the primary insert path's problem.
323
+ this.decisionInsertStmt = null;
324
+ this.outcomeUpsertStmt = null;
325
+ }
146
326
  }
147
327
  /** Add post-ship columns to an existing table, idempotently (pragma-guarded). */
148
328
  ensureAddedColumns() {
@@ -672,6 +852,557 @@ export class FeatureMetricsLedger {
672
852
  return [];
673
853
  }
674
854
  }
855
+ /* ---------------------------------------------------------------------- *
856
+ * Decision-quality substrate (llm-decision-quality-meter §5.5)
857
+ * ---------------------------------------------------------------------- */
858
+ /**
859
+ * Record one settled ENROLLED decision (the router-settlement write). Write-
860
+ * once per correlation id (INSERT OR IGNORE — the first settlement wins; a
861
+ * duplicate settle is a no-op, never a rewrite). Synchronous WAL insert in an
862
+ * isolated try/catch — NEVER throws into the decision path (the record()
863
+ * posture), strictly ≤1 per settled decision.
864
+ */
865
+ recordDecision(rec) {
866
+ if (this.closed || !this.decisionInsertStmt)
867
+ return;
868
+ const correlationId = clampLabel(rec.correlationId, 128);
869
+ const decisionPoint = clampLabel(rec.decisionPoint, 128);
870
+ if (!correlationId || !decisionPoint)
871
+ return; // keyless row = unjoinable noise
872
+ try {
873
+ this.decisionInsertStmt.run({
874
+ correlationId,
875
+ decisionPoint,
876
+ feature: clampLabel(rec.feature),
877
+ ts: rec.ts ?? this.now(),
878
+ verdictClass: clampLabel(rec.verdictClass),
879
+ mintedBy: clampLabel(rec.mintedBy),
880
+ volumeClass: clampLabel(rec.volumeClass),
881
+ contentClass: clampLabel(rec.contentClass),
882
+ machineId: clampLabel(rec.machineId, 32),
883
+ model: clampLabel(rec.model),
884
+ framework: clampLabel(rec.framework),
885
+ promptId: clampLabel(rec.promptId),
886
+ });
887
+ }
888
+ catch {
889
+ // @silent-fallback-ok: observability must never break the path it observes;
890
+ // a dropped decision row surfaces as joinMiss on the read surface, not a throw.
891
+ }
892
+ }
893
+ /**
894
+ * Indexed COUNT of decision_quality rows for a point since `sinceMs` — the
895
+ * `budget:<rows/day>` volume-class enforcement read (§5.6: UTC-day window,
896
+ * restart-safe, no new state; rides idx_decision_quality_point_ts). Returns
897
+ * null when the count cannot be produced (closed / substrate degraded) —
898
+ * the caller decides the fail direction, honestly, rather than trusting a
899
+ * fabricated 0.
900
+ */
901
+ countDecisionsSince(decisionPoint, sinceMs) {
902
+ if (this.closed || !this.decisionInsertStmt)
903
+ return null;
904
+ try {
905
+ const row = this.db
906
+ .prepare(`SELECT COUNT(*) AS n FROM decision_quality WHERE decision_point = ? AND ts >= ?`)
907
+ .get(decisionPoint, sinceMs);
908
+ return Number(row?.n) || 0;
909
+ }
910
+ catch {
911
+ // @silent-fallback-ok: a failed budget count degrades to null — the seam
912
+ // treats it as "budget unverifiable" and writes (observability must not
913
+ // starve a decision class on a transient read failure).
914
+ return null;
915
+ }
916
+ }
917
+ /**
918
+ * Upsert one outcome annotation (§5.4.4: key = correlationId × gradedBy — a
919
+ * re-run supersedes its own prior grade, never multiplies), then RECOMPUTE
920
+ * the affected (decision_point, decision-UTC-day) rollup bucket from
921
+ * decision_quality ⋈ decision_winning_grade (§5.5 reference implementation —
922
+ * bounded, self-healing; a grade flip decrements the old bucket column and
923
+ * increments the new one by construction). An outcome with no local parent
924
+ * row is an ORPHAN (FD10 cross-machine honesty): stored + counted under the
925
+ * caller's decisionPoint hint, never an error and never a graded decision.
926
+ * Never throws into the caller.
927
+ */
928
+ upsertOutcome(o) {
929
+ if (this.closed || !this.outcomeUpsertStmt)
930
+ return { applied: false, orphan: false, reason: 'closed' };
931
+ const correlationId = clampLabel(o.correlationId, 128);
932
+ const gradedBy = clampLabel(o.gradedBy, 128);
933
+ const ruleId = clampLabel(o.ruleId, 128);
934
+ if (!correlationId || !gradedBy || !ruleId)
935
+ return { applied: false, orphan: false, reason: 'missing-key' };
936
+ if (!QUALITY_GRADES.has(o.grade))
937
+ return { applied: false, orphan: false, reason: 'invalid-grade' };
938
+ if (!GRADING_RUNGS.has(o.rung))
939
+ return { applied: false, orphan: false, reason: 'invalid-rung' };
940
+ const ts = o.ts ?? this.now();
941
+ try {
942
+ const tx = this.db.transaction(() => {
943
+ this.outcomeUpsertStmt.run({
944
+ correlationId,
945
+ gradedBy,
946
+ ruleId,
947
+ rung: o.rung,
948
+ evidenceStrength: clampLabel(o.evidenceStrength) ?? 'self-report',
949
+ grade: o.grade,
950
+ effectiveWindowMs: o.effectiveWindowMs ?? null,
951
+ evidenceNote: clampLabel(o.evidenceNote, EVIDENCE_NOTE_MAX),
952
+ ts,
953
+ });
954
+ const parent = this.db
955
+ .prepare(`SELECT decision_point AS decisionPoint, ts FROM decision_quality WHERE correlation_id = ?`)
956
+ .get(correlationId);
957
+ if (!parent) {
958
+ this.bumpQualityCounterInTx(clampLabel(o.decisionPoint) ?? 'unknown', dayKey(ts), 'orphanOutcomes', 1);
959
+ return { applied: true, orphan: true };
960
+ }
961
+ // The bucket is the DECISION's UTC day (looked up from decision_quality),
962
+ // never the outcome's — late evidence lands in the day it grades.
963
+ this.recomputeQualityBucketInTx(parent.decisionPoint, dayKey(parent.ts));
964
+ return { applied: true, orphan: false };
965
+ });
966
+ return tx();
967
+ }
968
+ catch {
969
+ // @silent-fallback-ok: a dropped upsert is repaired by the bounded reconcile
970
+ // from raw truth — never lost data, never a throw into the grading caller.
971
+ return { applied: false, orphan: false, reason: 'write-failed' };
972
+ }
973
+ }
974
+ /**
975
+ * Recompute ONE (decision_point, day) bucket's winning-grade counts from
976
+ * truth via the canonical view. Event counters (orphan/joinMiss/dropped) are
977
+ * preserved — they are not derivable from the join. Caller holds the tx.
978
+ */
979
+ recomputeQualityBucketInTx(decisionPoint, day) {
980
+ const start = dayStartMs(day);
981
+ const agg = this.db
982
+ .prepare(`SELECT
983
+ SUM(CASE WHEN w.grade='right' THEN 1 ELSE 0 END) AS rightCount,
984
+ SUM(CASE WHEN w.grade='wrong' THEN 1 ELSE 0 END) AS wrongCount,
985
+ SUM(CASE WHEN w.grade='unknown' THEN 1 ELSE 0 END) AS unknownCount
986
+ FROM decision_quality q
987
+ JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
988
+ WHERE q.decision_point = ? AND q.ts >= ? AND q.ts < ?`)
989
+ .get(decisionPoint, start, start + 86_400_000);
990
+ this.db
991
+ .prepare(`INSERT INTO decision_quality_rollup (decision_point, day, right_count, wrong_count, unknown_count)
992
+ VALUES (?, ?, ?, ?, ?)
993
+ ON CONFLICT(decision_point, day) DO UPDATE SET
994
+ right_count = excluded.right_count,
995
+ wrong_count = excluded.wrong_count,
996
+ unknown_count = excluded.unknown_count`)
997
+ .run(decisionPoint, day, Number(agg?.rightCount) || 0, Number(agg?.wrongCount) || 0, Number(agg?.unknownCount) || 0);
998
+ }
999
+ /** In-tx counter bump (column name from the closed map — never interpolated input). */
1000
+ bumpQualityCounterInTx(decisionPoint, day, counter, n) {
1001
+ const col = QUALITY_COUNTER_COLUMNS[counter];
1002
+ this.db
1003
+ .prepare(`INSERT INTO decision_quality_rollup (decision_point, day, ${col})
1004
+ VALUES (?, ?, ?)
1005
+ ON CONFLICT(decision_point, day) DO UPDATE SET ${col} = ${col} + excluded.${col}`)
1006
+ .run(decisionPoint, day, n);
1007
+ }
1008
+ /**
1009
+ * Increment a rollup EVENT counter (orphanOutcomes / joinMiss /
1010
+ * droppedByBudget) for a decision point's UTC-day bucket. These counters are
1011
+ * additive events (not recomputable from the join) and survive bucket
1012
+ * recompute + reconcile. Never throws.
1013
+ */
1014
+ bumpQualityCounter(decisionPoint, counter, opts = {}) {
1015
+ if (this.closed)
1016
+ return;
1017
+ const point = clampLabel(decisionPoint) ?? 'unknown';
1018
+ const n = Math.max(1, Math.floor(opts.n ?? 1));
1019
+ try {
1020
+ this.bumpQualityCounterInTx(point, dayKey(opts.ts ?? this.now()), counter, n);
1021
+ }
1022
+ catch {
1023
+ // @silent-fallback-ok: a dropped counter bump under-counts an event class;
1024
+ // it must never throw into the settlement/annotation path.
1025
+ }
1026
+ }
1027
+ /**
1028
+ * THE canonical winning-grade read (§5.5 — the single derivation API). Reads
1029
+ * the `decision_winning_grade` view; chunked IN-lists keep parameter counts
1030
+ * bounded. Fail-open to [].
1031
+ */
1032
+ getWinningGrades(correlationIds) {
1033
+ if (this.closed || correlationIds.length === 0)
1034
+ return [];
1035
+ try {
1036
+ const out = [];
1037
+ for (let i = 0; i < correlationIds.length; i += 500) {
1038
+ const chunk = correlationIds.slice(i, i + 500);
1039
+ const rows = this.db
1040
+ .prepare(`SELECT correlation_id AS correlationId, grade, rung,
1041
+ evidence_strength AS evidenceStrength, rule_id AS ruleId, graded_by AS gradedBy
1042
+ FROM decision_winning_grade
1043
+ WHERE correlation_id IN (${chunk.map(() => '?').join(',')})`)
1044
+ .all(...chunk);
1045
+ out.push(...rows);
1046
+ }
1047
+ return out;
1048
+ }
1049
+ catch {
1050
+ // @silent-fallback-ok: a read-only reporting query — degrade to [].
1051
+ return [];
1052
+ }
1053
+ }
1054
+ /**
1055
+ * Bounded reconcile of the quality rollup's GRADE counts from raw truth
1056
+ * (decision_quality ⋈ decision_winning_grade) over the last `days` of
1057
+ * DECISION days — the §5.5 self-heal for a crash between the outcome upsert
1058
+ * and its bucket recompute (and for a hand-corrupted bucket). Event counters
1059
+ * are preserved. Idempotent fold; runs at boot (constructor) AND from the
1060
+ * AgentServer 6h prune timer. Returns buckets written.
1061
+ */
1062
+ reconcileQualityRollup(days) {
1063
+ if (this.closed)
1064
+ return 0;
1065
+ try {
1066
+ const cutoffDay = dayKey(this.now() - days * 86_400_000);
1067
+ const cutoffMs = dayStartMs(cutoffDay);
1068
+ const tx = this.db.transaction(() => {
1069
+ // Zero (not DELETE) the window's grade counts so event counters survive
1070
+ // and a bucket whose underlying rows vanished is honestly zeroed.
1071
+ this.db
1072
+ .prepare(`UPDATE decision_quality_rollup SET right_count = 0, wrong_count = 0, unknown_count = 0 WHERE day >= ?`)
1073
+ .run(cutoffDay);
1074
+ const res = this.db
1075
+ .prepare(`INSERT INTO decision_quality_rollup (decision_point, day, right_count, wrong_count, unknown_count)
1076
+ SELECT
1077
+ q.decision_point,
1078
+ strftime('%Y-%m-%d', q.ts/1000, 'unixepoch') AS day,
1079
+ SUM(CASE WHEN w.grade='right' THEN 1 ELSE 0 END),
1080
+ SUM(CASE WHEN w.grade='wrong' THEN 1 ELSE 0 END),
1081
+ SUM(CASE WHEN w.grade='unknown' THEN 1 ELSE 0 END)
1082
+ FROM decision_quality q
1083
+ JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
1084
+ WHERE q.ts >= ?
1085
+ GROUP BY q.decision_point, day
1086
+ ON CONFLICT(decision_point, day) DO UPDATE SET
1087
+ right_count = excluded.right_count,
1088
+ wrong_count = excluded.wrong_count,
1089
+ unknown_count = excluded.unknown_count`)
1090
+ .run(cutoffMs);
1091
+ return Number(res.changes ?? 0);
1092
+ });
1093
+ const written = tx();
1094
+ this.lastQualityReconcileMsValue = this.now();
1095
+ return written;
1096
+ }
1097
+ catch {
1098
+ // @silent-fallback-ok: a reconcile failure leaves the last-good rollup in
1099
+ // place; the next boot/timer tick retries. Raw truth is never touched.
1100
+ return 0;
1101
+ }
1102
+ }
1103
+ /** When the quality rollup was last reconciled from raw truth (read-honesty surface). */
1104
+ lastQualityReconcileMs() {
1105
+ return this.lastQualityReconcileMsValue;
1106
+ }
1107
+ /** Quality rollup buckets (per decision_point × UTC day), oldest first. Fail-open to []. */
1108
+ decisionQualityRollupDaily(opts = {}) {
1109
+ try {
1110
+ const where = [];
1111
+ const params = [];
1112
+ if (opts.sinceDays && opts.sinceDays > 0) {
1113
+ where.push('day >= ?');
1114
+ params.push(dayKey(this.now() - opts.sinceDays * 86_400_000));
1115
+ }
1116
+ if (opts.decisionPoint) {
1117
+ where.push('decision_point = ?');
1118
+ params.push(opts.decisionPoint);
1119
+ }
1120
+ const rows = this.db
1121
+ .prepare(`SELECT decision_point AS decisionPoint, day,
1122
+ right_count AS rightCount, wrong_count AS wrongCount, unknown_count AS unknownCount,
1123
+ orphan_outcomes AS orphanOutcomes, join_miss AS joinMiss, dropped_by_budget AS droppedByBudget
1124
+ FROM decision_quality_rollup
1125
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
1126
+ ORDER BY day ASC, decision_point ASC`)
1127
+ .all(...params);
1128
+ return rows.map((r) => ({
1129
+ decisionPoint: String(r.decisionPoint),
1130
+ day: String(r.day),
1131
+ dayStartMs: dayStartMs(String(r.day)),
1132
+ right: Number(r.rightCount) || 0,
1133
+ wrong: Number(r.wrongCount) || 0,
1134
+ unknown: Number(r.unknownCount) || 0,
1135
+ orphanOutcomes: Number(r.orphanOutcomes) || 0,
1136
+ joinMiss: Number(r.joinMiss) || 0,
1137
+ droppedByBudget: Number(r.droppedByBudget) || 0,
1138
+ }));
1139
+ }
1140
+ catch {
1141
+ // @silent-fallback-ok: a read-only reporting query — degrade to [].
1142
+ return [];
1143
+ }
1144
+ }
1145
+ /**
1146
+ * Keyset walk of `decision_quality` rows for ONE point AFTER the compound
1147
+ * cursor `(ts, correlation_id)` — the P9 grade-pass's bounded, same-ms-safe
1148
+ * page (§5.5: "keyset pagination ORDER BY (ts, correlation_id) with the
1149
+ * compound cursor as the page boundary — same-ms bursts cannot skip rows").
1150
+ * Rides idx_decision_quality_point_ts (decision_point, ts, correlation_id).
1151
+ * Content-free: only the join key + decision ts. Fail-open to [].
1152
+ */
1153
+ walkDecisionsForGrading(decisionPoint, cursorTs, cursorCorrelationId, limit) {
1154
+ if (this.closed)
1155
+ return [];
1156
+ const cap = Math.max(1, Math.min(10_000, Math.floor(limit) || 1));
1157
+ try {
1158
+ const rows = this.db
1159
+ .prepare(`SELECT correlation_id AS correlationId, ts
1160
+ FROM decision_quality
1161
+ WHERE decision_point = ?
1162
+ AND (ts > ? OR (ts = ? AND correlation_id > ?))
1163
+ ORDER BY ts ASC, correlation_id ASC
1164
+ LIMIT ?`)
1165
+ .all(decisionPoint, cursorTs, cursorTs, cursorCorrelationId, cap);
1166
+ return rows.map((r) => ({ correlationId: String(r.correlationId), ts: Number(r.ts) || 0 }));
1167
+ }
1168
+ catch {
1169
+ // @silent-fallback-ok: a failed keyset read yields an empty page — the
1170
+ // grade-pass makes no progress this run and retries next tick (never a throw).
1171
+ return [];
1172
+ }
1173
+ }
1174
+ /**
1175
+ * Per-decision-point read model for GET /decision-quality (§5.5): decision
1176
+ * COUNT + distinct attribution labels (model/framework/prompt_id) over the
1177
+ * window. Pure indexed reads. Fail-open to [].
1178
+ */
1179
+ decisionPointStats(opts = {}) {
1180
+ if (this.closed)
1181
+ return [];
1182
+ const sinceMs = this.sinceMsFrom(opts);
1183
+ try {
1184
+ const counts = this.db
1185
+ .prepare(`SELECT decision_point AS dp, COUNT(*) AS n FROM decision_quality WHERE ts >= ? GROUP BY decision_point`)
1186
+ .all(sinceMs);
1187
+ const attr = this.db
1188
+ .prepare(`SELECT DISTINCT decision_point AS dp, model, framework, prompt_id AS promptId
1189
+ FROM decision_quality WHERE ts >= ?`)
1190
+ .all(sinceMs);
1191
+ const models = new Map();
1192
+ const frameworks = new Map();
1193
+ const prompts = new Map();
1194
+ const add = (m, k, v) => {
1195
+ if (!v)
1196
+ return;
1197
+ const s = m.get(k) ?? new Set();
1198
+ s.add(v);
1199
+ m.set(k, s);
1200
+ };
1201
+ for (const r of attr) {
1202
+ add(models, r.dp, r.model);
1203
+ add(frameworks, r.dp, r.framework);
1204
+ add(prompts, r.dp, r.promptId);
1205
+ }
1206
+ return counts.map((c) => ({
1207
+ decisionPoint: String(c.dp),
1208
+ decisions: Number(c.n) || 0,
1209
+ models: Array.from(models.get(c.dp) ?? []).sort(),
1210
+ frameworks: Array.from(frameworks.get(c.dp) ?? []).sort(),
1211
+ promptIds: Array.from(prompts.get(c.dp) ?? []).sort(),
1212
+ }));
1213
+ }
1214
+ catch {
1215
+ // @silent-fallback-ok: read-only reporting — degrade to [].
1216
+ return [];
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Grade breakdown for GET /decision-quality (§5.5): winning grades joined to
1221
+ * their decision point over the window, grouped by (decisionPoint, ruleId,
1222
+ * rung, evidenceStrength, grade). The route pivots these into
1223
+ * by-strength/by-rule/by-rung views (strength FIRST — proof-like and
1224
+ * heuristic grades are never conflated). Consumes the CANONICAL
1225
+ * `decision_winning_grade` view (one derivation, both consumers). Window is on
1226
+ * the DECISION ts. Fail-open to [].
1227
+ */
1228
+ decisionGradeBreakdown(opts = {}) {
1229
+ if (this.closed)
1230
+ return [];
1231
+ const sinceMs = this.sinceMsFrom(opts);
1232
+ try {
1233
+ const rows = this.db
1234
+ .prepare(`SELECT q.decision_point AS decisionPoint, w.rule_id AS ruleId, w.rung,
1235
+ w.evidence_strength AS evidenceStrength, w.grade, COUNT(*) AS n
1236
+ FROM decision_quality q
1237
+ JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
1238
+ WHERE q.ts >= ?
1239
+ GROUP BY q.decision_point, w.rule_id, w.rung, w.evidence_strength, w.grade`)
1240
+ .all(sinceMs);
1241
+ return rows.map((r) => ({
1242
+ decisionPoint: String(r.decisionPoint),
1243
+ ruleId: String(r.ruleId),
1244
+ rung: String(r.rung),
1245
+ evidenceStrength: String(r.evidenceStrength),
1246
+ grade: String(r.grade),
1247
+ n: Number(r.n) || 0,
1248
+ }));
1249
+ }
1250
+ catch {
1251
+ // @silent-fallback-ok: read-only reporting — degrade to [].
1252
+ return [];
1253
+ }
1254
+ }
1255
+ /**
1256
+ * Per-point count of EXPIRED decisions for GET /decision-quality (§5.5 read
1257
+ * honesty): in-window decisions whose age exceeds `expiryCutoffMs` (the
1258
+ * evidence-carrier horizon — beyond which no window-close grade can arrive)
1259
+ * AND which carry NO winning grade. `expired` is derived at READ, never a
1260
+ * stored grade. Fail-open to [].
1261
+ */
1262
+ countExpiredByPoint(opts) {
1263
+ if (this.closed)
1264
+ return [];
1265
+ const sinceMs = this.sinceMsFrom(opts);
1266
+ try {
1267
+ const rows = this.db
1268
+ .prepare(`SELECT q.decision_point AS decisionPoint, COUNT(*) AS n
1269
+ FROM decision_quality q
1270
+ LEFT JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
1271
+ WHERE q.ts >= ? AND q.ts < ? AND w.correlation_id IS NULL
1272
+ GROUP BY q.decision_point`)
1273
+ .all(sinceMs, opts.expiryCutoffMs);
1274
+ return rows.map((r) => ({ decisionPoint: String(r.decisionPoint), expired: Number(r.n) || 0 }));
1275
+ }
1276
+ catch {
1277
+ // @silent-fallback-ok: read-only reporting — degrade to [].
1278
+ return [];
1279
+ }
1280
+ }
1281
+ /** The grading job's durable per-decision-point cursor. null = no cursor yet (or read failed). */
1282
+ getGradingCursor(decisionPoint) {
1283
+ if (this.closed)
1284
+ return null;
1285
+ try {
1286
+ const row = this.db
1287
+ .prepare(`SELECT decision_point AS decisionPoint, cursor_ts AS cursorTs,
1288
+ cursor_correlation_id AS cursorCorrelationId,
1289
+ next_recheck_ts AS nextRecheckTs, attempts, updated_at AS updatedAt
1290
+ FROM decision_grading_cursor WHERE decision_point = ?`)
1291
+ .get(decisionPoint);
1292
+ return row ?? null;
1293
+ }
1294
+ catch {
1295
+ // @silent-fallback-ok: a failed cursor read makes the grading pass start
1296
+ // from its last durable boundary next run — never a throw.
1297
+ return null;
1298
+ }
1299
+ }
1300
+ /**
1301
+ * Upsert the grading cursor (keyset boundary + P19 recheck backoff).
1302
+ * `updated_at` is stamped from the injected clock — it is the staleness key
1303
+ * cursor pruning keys on. Never throws.
1304
+ */
1305
+ setGradingCursor(decisionPoint, c) {
1306
+ if (this.closed)
1307
+ return;
1308
+ const point = clampLabel(decisionPoint);
1309
+ if (!point)
1310
+ return;
1311
+ try {
1312
+ this.db
1313
+ .prepare(`INSERT INTO decision_grading_cursor
1314
+ (decision_point, cursor_ts, cursor_correlation_id, next_recheck_ts, attempts, updated_at)
1315
+ VALUES (@decisionPoint, @cursorTs, @cursorCorrelationId, @nextRecheckTs, @attempts, @updatedAt)
1316
+ ON CONFLICT(decision_point) DO UPDATE SET
1317
+ cursor_ts = excluded.cursor_ts,
1318
+ cursor_correlation_id = excluded.cursor_correlation_id,
1319
+ next_recheck_ts = excluded.next_recheck_ts,
1320
+ attempts = excluded.attempts,
1321
+ updated_at = excluded.updated_at`)
1322
+ .run({
1323
+ decisionPoint: point,
1324
+ cursorTs: c.cursorTs,
1325
+ cursorCorrelationId: clampLabel(c.cursorCorrelationId, 128) ?? '',
1326
+ nextRecheckTs: c.nextRecheckTs ?? null,
1327
+ attempts: Math.max(0, Math.floor(c.attempts ?? 0)),
1328
+ updatedAt: this.now(),
1329
+ });
1330
+ }
1331
+ catch {
1332
+ // @silent-fallback-ok: a dropped cursor write re-grades a page idempotently
1333
+ // next pass (upserts converge) — never a throw into the grading job.
1334
+ }
1335
+ }
1336
+ /**
1337
+ * Retention prune for `decision_quality` (default horizon 90d via
1338
+ * provenance.quality.decisionRetentionDays). PRUNE_BATCH-bounded. Fail-open.
1339
+ */
1340
+ pruneDecisionQuality(retentionDays, opts = {}) {
1341
+ if (this.closed || retentionDays <= 0)
1342
+ return 0;
1343
+ const cutoff = this.now() - retentionDays * 86_400_000;
1344
+ return this.batchedPrune(`DELETE FROM decision_quality WHERE rowid IN
1345
+ (SELECT rowid FROM decision_quality WHERE ts < ? LIMIT ${PRUNE_BATCH})`, [cutoff], opts.maxBatches ?? 20);
1346
+ }
1347
+ /**
1348
+ * Retention prune for `decision_outcomes`. Governed by the same
1349
+ * decisionRetentionDays knob but FLOORED at 30d — grading evidence windows +
1350
+ * slack need the outcomes to outlive the raw metrics horizon, so a
1351
+ * mis-tuned low knob can never starve the grade join. Fail-open.
1352
+ */
1353
+ pruneDecisionOutcomes(retentionDays, opts = {}) {
1354
+ if (this.closed || retentionDays <= 0)
1355
+ return 0;
1356
+ const cutoff = this.now() - Math.max(30, retentionDays) * 86_400_000;
1357
+ return this.batchedPrune(`DELETE FROM decision_outcomes WHERE rowid IN
1358
+ (SELECT rowid FROM decision_outcomes WHERE ts < ? LIMIT ${PRUNE_BATCH})`, [cutoff], opts.maxBatches ?? 20);
1359
+ }
1360
+ /**
1361
+ * Retention prune for the quality rollup (provenance.quality.rollupRetentionDays,
1362
+ * default 90) — day-keyed like pruneSpendRollup, batch-bounded like the rest. Fail-open.
1363
+ */
1364
+ pruneQualityRollup(retentionDays, opts = {}) {
1365
+ if (this.closed || retentionDays <= 0)
1366
+ return 0;
1367
+ const cutoffDay = dayKey(this.now() - retentionDays * 86_400_000);
1368
+ return this.batchedPrune(`DELETE FROM decision_quality_rollup WHERE rowid IN
1369
+ (SELECT rowid FROM decision_quality_rollup WHERE day < ? LIMIT ${PRUNE_BATCH})`, [cutoffDay], opts.maxBatches ?? 20);
1370
+ }
1371
+ /**
1372
+ * Cursor hygiene: a cursor row whose decision_point is REGISTERED is never
1373
+ * pruned (pass the census's decision-point ids); an UNKNOWN point's cursor is
1374
+ * pruned only once stale past `retentionDays` (updated_at — a live grading
1375
+ * job re-stamps it every pass, so only a de-registered/abandoned point ages
1376
+ * out; re-grading after a cursor loss converges idempotently). Fail-open.
1377
+ */
1378
+ pruneGradingCursors(retentionDays, opts = {}) {
1379
+ if (this.closed || retentionDays <= 0)
1380
+ return 0;
1381
+ const cutoff = this.now() - retentionDays * 86_400_000;
1382
+ const registered = opts.registeredDecisionPoints ?? [];
1383
+ const notIn = registered.length ? ` AND decision_point NOT IN (${registered.map(() => '?').join(',')})` : '';
1384
+ return this.batchedPrune(`DELETE FROM decision_grading_cursor WHERE rowid IN
1385
+ (SELECT rowid FROM decision_grading_cursor WHERE updated_at < ?${notIn} LIMIT ${PRUNE_BATCH})`, [cutoff, ...registered], opts.maxBatches ?? 20);
1386
+ }
1387
+ /** Shared bounded-DELETE loop (the pruneOlderThan idiom). Fail-open to count-so-far. */
1388
+ batchedPrune(deleteSql, params, maxBatches) {
1389
+ let deleted = 0;
1390
+ try {
1391
+ const stmt = this.db.prepare(deleteSql);
1392
+ for (let i = 0; i < maxBatches; i++) {
1393
+ const n = Number(stmt.run(...params).changes ?? 0);
1394
+ deleted += n;
1395
+ if (n < PRUNE_BATCH)
1396
+ break; // drained
1397
+ }
1398
+ return deleted;
1399
+ }
1400
+ catch {
1401
+ // @silent-fallback-ok: retention prune is best-effort housekeeping — a failed
1402
+ // batch leaves older rows for the next tick; never a throw.
1403
+ return deleted;
1404
+ }
1405
+ }
675
1406
  close() {
676
1407
  if (this.closed)
677
1408
  return;