rcs-context 0.0.0-stage → 0.1.39

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.
@@ -0,0 +1,1054 @@
1
+ import {
2
+ canonicalJson, routeEvidenceValid, routeRowValid, sha256Canonical,
3
+ } from './memory-ledger-core.mjs'
4
+ import { PROPOSAL_PREDICATES } from './memory-proposals.mjs'
5
+
6
+ // The derived threshold lives in the core so the journal and the ledger's read gate
7
+ // cannot drift. Re-exported here because this module remains the journal's public
8
+ // surface for route capability.
9
+ export { routeEnabled } from './memory-ledger-core.mjs'
10
+
11
+ const RUN_STATUSES = new Set(['succeeded', 'failed'])
12
+ const FAILURE_KINDS = new Set(['transient', 'permanent', 'unknown'])
13
+ const ITEM_VERDICTS = new Set(['accepted', 'candidate', 'rejected'])
14
+ const APPLICATION_STATUSES = new Set(['applied', 'failed', 'closed'])
15
+ const RUN_FIELDS = new Set([
16
+ 'runId', 'scope', 'inputHash', 'model', 'modelDigest', 'schemaVersion', 'status',
17
+ 'attempt', 'failureKind', 'rawOutput', 'error', 'createdAt', 'evidenceEventKeys', 'items',
18
+ ])
19
+ const ITEM_FIELDS = new Set(['verdict', 'predicate', 'payload', 'errors', 'reason'])
20
+ const APPLICATION_FIELDS = new Set([
21
+ 'runId', 'scope', 'status', 'assertionCount', 'error', 'updatedAt',
22
+ ])
23
+ const ROUTE_FIELDS = new Set([
24
+ 'scope', 'model', 'modelDigest', 'predicate', 'schemaVersion', 'sampleCount', 'precision',
25
+ 'safetyFailures', 'evidenceJson', 'updatedAt',
26
+ ])
27
+
28
+ // Stable code for a run identity that is bound to a different scope. Callers
29
+ // must never receive foreign-scope runs or items as if they were their own.
30
+ export const PROPOSAL_SCOPE_MISMATCH = 'PROPOSAL_SCOPE_MISMATCH'
31
+
32
+ export class ProposalScopeMismatchError extends Error {
33
+ constructor(runId, storedScope, requestedScope) {
34
+ super(`proposal run ${runId} belongs to scope ${storedScope}, not ${requestedScope}`)
35
+ this.name = 'ProposalScopeMismatchError'
36
+ this.code = PROPOSAL_SCOPE_MISMATCH
37
+ this.runId = runId
38
+ this.storedScope = storedScope
39
+ this.requestedScope = requestedScope
40
+ }
41
+ }
42
+
43
+ function nonEmptyString(value) {
44
+ return typeof value === 'string' && value.trim() !== ''
45
+ }
46
+
47
+ function requireNonEmpty(name, value) {
48
+ if (!nonEmptyString(value)) throw new TypeError(`${name} must be a non-empty string`)
49
+ return value
50
+ }
51
+
52
+ function requireFinite(name, value) {
53
+ if (!Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`)
54
+ return value
55
+ }
56
+
57
+ function requireCount(name, value) {
58
+ if (!Number.isInteger(value) || value < 0) {
59
+ throw new TypeError(`${name} must be a non-negative integer`)
60
+ }
61
+ return value
62
+ }
63
+
64
+ function isPlainObject(value) {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
66
+ const prototype = Object.getPrototypeOf(value)
67
+ return prototype === Object.prototype || prototype === null
68
+ }
69
+
70
+ function rejectUnknownFields(fields, allowed, label) {
71
+ for (const key of Object.keys(fields)) {
72
+ if (!allowed.has(key)) throw new TypeError(`unknown ${label} field: ${key}`)
73
+ }
74
+ }
75
+
76
+ function normalizeItem(item, index) {
77
+ if (!isPlainObject(item)) throw new TypeError(`proposal item ${index} must be an object`)
78
+ rejectUnknownFields(item, ITEM_FIELDS, 'proposal item')
79
+ if (!ITEM_VERDICTS.has(item.verdict)) {
80
+ throw new TypeError(`proposal item ${index} verdict must be accepted, candidate, or rejected`)
81
+ }
82
+ const predicate = item.predicate ?? null
83
+ if (predicate !== null) {
84
+ requireNonEmpty(`proposal item ${index} predicate`, predicate)
85
+ // A rejected row records what the model actually emitted, which may be outside
86
+ // the proposal vocabulary (the engine discovers that only after parsing). An
87
+ // accepted or candidate row is executable state and must name a proposal
88
+ // predicate.
89
+ if (item.verdict !== 'rejected' && !PROPOSAL_PREDICATES.has(predicate)) {
90
+ throw new TypeError(`proposal item ${index} predicate is not a proposal predicate`)
91
+ }
92
+ }
93
+ const payload = item.payload === undefined ? null : item.payload
94
+ // Canonicalize before the transaction starts: a malformed payload must never
95
+ // leave a run row without its items.
96
+ canonicalJson(payload)
97
+ const errors = item.errors === undefined ? [] : item.errors
98
+ if (!Array.isArray(errors) || errors.some((value) => typeof value !== 'string')) {
99
+ throw new TypeError(`proposal item ${index} errors must be an array of strings`)
100
+ }
101
+ const reason = item.reason ?? null
102
+ if (reason !== null && typeof reason !== 'string') {
103
+ throw new TypeError(`proposal item ${index} reason must be a string or null`)
104
+ }
105
+ return { itemIndex: index, verdict: item.verdict, predicate, payload, errors, reason }
106
+ }
107
+
108
+ function normalizeRun(input) {
109
+ if (!isPlainObject(input)) throw new TypeError('recordRun requires a plain object')
110
+ rejectUnknownFields(input, RUN_FIELDS, 'run')
111
+ const runId = requireNonEmpty('runId', input.runId)
112
+ const scope = requireNonEmpty('scope', input.scope)
113
+ const inputHash = requireNonEmpty('inputHash', input.inputHash)
114
+ const model = requireNonEmpty('model', input.model)
115
+ const modelDigest = requireNonEmpty('modelDigest', input.modelDigest)
116
+ const schemaVersion = requireNonEmpty('schemaVersion', input.schemaVersion)
117
+ if (!RUN_STATUSES.has(input.status)) throw new TypeError('run status must be succeeded or failed')
118
+ const createdAt = requireFinite('createdAt', input.createdAt)
119
+
120
+ // Attempts are explicit so retries never overwrite earlier evidence. A replay
121
+ // of the same run ID is still recognised as the same attempt.
122
+ const attempt = input.attempt === undefined ? 1 : input.attempt
123
+ if (!Number.isInteger(attempt) || attempt < 1) {
124
+ throw new TypeError('attempt must be a positive integer')
125
+ }
126
+ const failureKind = input.failureKind ?? null
127
+ if (failureKind !== null && !FAILURE_KINDS.has(failureKind)) {
128
+ throw new TypeError('failureKind must be transient, permanent, unknown, or null')
129
+ }
130
+
131
+ const rawOutput = input.rawOutput ?? null
132
+ if (rawOutput !== null && typeof rawOutput !== 'string') {
133
+ throw new TypeError('rawOutput must be a string or null')
134
+ }
135
+ const error = input.error ?? null
136
+ if (error !== null && !nonEmptyString(error)) {
137
+ throw new TypeError('error must be a non-empty string or null')
138
+ }
139
+
140
+ const rawItems = input.items === undefined ? [] : input.items
141
+ if (!Array.isArray(rawItems)) throw new TypeError('run items must be an array')
142
+
143
+ if (input.status === 'succeeded') {
144
+ if (typeof rawOutput !== 'string') throw new TypeError('succeeded runs require raw output')
145
+ if (error !== null) throw new TypeError('succeeded runs cannot carry an error')
146
+ if (failureKind !== null) throw new TypeError('succeeded runs cannot carry a failure kind')
147
+ } else {
148
+ if (error === null) throw new TypeError('failed runs require an error')
149
+ if (failureKind === null) throw new TypeError('failed runs require a failure kind')
150
+ // A failed attempt has no validated proposal set; allowing items here would
151
+ // let unvalidated model output masquerade as reviewed verdicts.
152
+ if (rawItems.length > 0) throw new TypeError('failed runs carry no proposal items')
153
+ }
154
+
155
+ const items = rawItems.map((item, index) => normalizeItem(item, index))
156
+ // Evidence-set identity is independent of active facts. Persisting it lets the
157
+ // engine reuse a prior proposal for the same archived messages even after the
158
+ // active fact set changes (for example after a retraction).
159
+ const rawEvidenceKeys = input.evidenceEventKeys === undefined ? [] : input.evidenceEventKeys
160
+ if (!Array.isArray(rawEvidenceKeys) ||
161
+ rawEvidenceKeys.some((value) => typeof value !== 'string' || value.trim() === '')) {
162
+ throw new TypeError('evidenceEventKeys must be an array of non-empty strings')
163
+ }
164
+ const evidenceEventKeys = [...new Set(rawEvidenceKeys)].sort()
165
+ return {
166
+ runId, scope, inputHash, model, modelDigest, schemaVersion,
167
+ status: input.status, attempt, failureKind, rawOutput, error, createdAt,
168
+ evidenceEventKeys, items,
169
+ }
170
+ }
171
+
172
+ function normalizeApplication(input) {
173
+ if (!isPlainObject(input)) throw new TypeError('recordApplication requires a plain object')
174
+ rejectUnknownFields(input, APPLICATION_FIELDS, 'application')
175
+ const runId = requireNonEmpty('runId', input.runId)
176
+ const scope = requireNonEmpty('scope', input.scope)
177
+ if (!APPLICATION_STATUSES.has(input.status)) {
178
+ throw new TypeError('application status must be applied, failed, or closed')
179
+ }
180
+ const assertionCount = requireCount('assertionCount', input.assertionCount)
181
+ const updatedAt = requireFinite('updatedAt', input.updatedAt)
182
+ const error = input.error ?? null
183
+ if (input.status === 'applied' && error !== null) {
184
+ throw new TypeError('applied applications cannot carry an error')
185
+ }
186
+ if ((input.status === 'failed' || input.status === 'closed') && !nonEmptyString(error)) {
187
+ throw new TypeError(`${input.status} applications require a reason`)
188
+ }
189
+ return { runId, scope, status: input.status, assertionCount, error, updatedAt }
190
+ }
191
+
192
+ function normalizeRoute(input) {
193
+ if (!isPlainObject(input)) throw new TypeError('setRoute requires a plain object')
194
+ rejectUnknownFields(input, ROUTE_FIELDS, 'route')
195
+ const scope = requireNonEmpty('scope', input.scope)
196
+ const model = requireNonEmpty('model', input.model)
197
+ const modelDigest = requireNonEmpty('modelDigest', input.modelDigest)
198
+ const predicate = requireNonEmpty('predicate', input.predicate)
199
+ if (!PROPOSAL_PREDICATES.has(predicate)) {
200
+ throw new TypeError('predicate is not a proposal predicate')
201
+ }
202
+ const schemaVersion = requireNonEmpty('schemaVersion', input.schemaVersion)
203
+ const sampleCount = requireCount('sampleCount', input.sampleCount)
204
+ const precision = requireFinite('precision', input.precision)
205
+ if (precision < 0 || precision > 1) throw new TypeError('precision must be between 0 and 1')
206
+ const safetyFailures = requireCount('safetyFailures', input.safetyFailures)
207
+ const updatedAt = requireFinite('updatedAt', input.updatedAt)
208
+
209
+ // Route counters are not caller-entered evidence. They are required to be the
210
+ // exact arithmetic result of an evaluation row that names its corpus, evaluator,
211
+ // and model digest. A hand-entered 20/0.95/0 row without this evidence is refused.
212
+ if (input.evidenceJson === undefined || input.evidenceJson === null) {
213
+ throw new TypeError('setRoute requires evidenceJson counters')
214
+ }
215
+ let evidence
216
+ if (typeof input.evidenceJson === 'string') {
217
+ try {
218
+ evidence = JSON.parse(input.evidenceJson)
219
+ } catch {
220
+ throw new TypeError('evidenceJson must be valid JSON')
221
+ }
222
+ } else {
223
+ evidence = input.evidenceJson
224
+ }
225
+ canonicalJson(evidence)
226
+ if (!routeEvidenceValid(evidence)) {
227
+ throw new TypeError('evidenceJson does not match its evaluation counters')
228
+ }
229
+ if (evidence.modelDigest !== modelDigest) {
230
+ throw new TypeError('evidenceJson modelDigest must match the route digest')
231
+ }
232
+ if (sampleCount !== evidence.positiveLabels) {
233
+ throw new TypeError('sampleCount must equal the evidence positiveLabels')
234
+ }
235
+ if (safetyFailures !== evidence.safetyFailures) {
236
+ throw new TypeError('safetyFailures must equal the evidence safetyFailures')
237
+ }
238
+ if (precision !== evidence.tp / (evidence.tp + evidence.fp)) {
239
+ throw new TypeError('precision must equal tp / (tp + fp) from the evidence')
240
+ }
241
+
242
+ return {
243
+ scope, model, modelDigest, predicate, schemaVersion, sampleCount, precision,
244
+ safetyFailures, evidence, updatedAt,
245
+ }
246
+ }
247
+
248
+ function proposalIdFor(run, index) {
249
+ return sha256Canonical({
250
+ runId: run.runId,
251
+ scope: run.scope,
252
+ inputHash: run.inputHash,
253
+ model: run.model,
254
+ modelDigest: run.modelDigest,
255
+ schemaVersion: run.schemaVersion,
256
+ index,
257
+ })
258
+ }
259
+
260
+ function runSignature(run, items) {
261
+ return canonicalJson({
262
+ runId: run.runId,
263
+ scope: run.scope,
264
+ inputHash: run.inputHash,
265
+ model: run.model,
266
+ modelDigest: run.modelDigest,
267
+ schemaVersion: run.schemaVersion,
268
+ status: run.status,
269
+ attempt: run.attempt,
270
+ failureKind: run.failureKind,
271
+ rawOutput: run.rawOutput,
272
+ error: run.error,
273
+ createdAt: run.createdAt,
274
+ evidenceEventKeys: run.evidenceEventKeys ?? [],
275
+ items: items.map((item) => ({
276
+ itemIndex: item.itemIndex,
277
+ verdict: item.verdict,
278
+ predicate: item.predicate,
279
+ payload: item.payload,
280
+ errors: item.errors,
281
+ reason: item.reason,
282
+ })),
283
+ })
284
+ }
285
+
286
+ function decodeRun(row) {
287
+ return {
288
+ runId: row.run_id,
289
+ scope: row.scope,
290
+ inputHash: row.input_hash,
291
+ model: row.model,
292
+ modelDigest: row.model_digest,
293
+ schemaVersion: row.schema_version,
294
+ status: row.status,
295
+ attempt: row.attempt,
296
+ failureKind: row.failure_kind ?? null,
297
+ rawOutput: row.raw_output ?? null,
298
+ error: row.error ?? null,
299
+ createdAt: row.created_at,
300
+ evidenceEventKeys: JSON.parse(row.evidence_keys_json ?? '[]'),
301
+ }
302
+ }
303
+
304
+ function decodeItem(row) {
305
+ return {
306
+ proposalId: row.proposal_id,
307
+ runId: row.run_id,
308
+ scope: row.scope,
309
+ itemIndex: row.item_index,
310
+ verdict: row.verdict,
311
+ predicate: row.predicate ?? null,
312
+ payload: row.payload_json === null ? null : JSON.parse(row.payload_json),
313
+ errors: JSON.parse(row.errors_json),
314
+ reason: row.reason ?? null,
315
+ promotedAt: row.promoted_at ?? null,
316
+ promotedAssertionId: row.promoted_assertion_id ?? null,
317
+ }
318
+ }
319
+
320
+ export class ProposalJournal {
321
+ constructor(db) {
322
+ if (!db || typeof db.prepare !== 'function' || typeof db.exec !== 'function') {
323
+ throw new TypeError('ProposalJournal requires an open DatabaseSync instance')
324
+ }
325
+ this.db = db
326
+ this.db.exec('PRAGMA foreign_keys = ON')
327
+ this.db.exec(`
328
+ CREATE TABLE IF NOT EXISTS ledger_proposal_runs (
329
+ run_id TEXT PRIMARY KEY NOT NULL,
330
+ scope TEXT NOT NULL,
331
+ input_hash TEXT NOT NULL,
332
+ model TEXT NOT NULL,
333
+ model_digest TEXT NOT NULL,
334
+ schema_version TEXT NOT NULL,
335
+ status TEXT NOT NULL CHECK (status IN ('succeeded', 'failed')),
336
+ attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt >= 1),
337
+ failure_kind TEXT CHECK (
338
+ failure_kind IS NULL OR failure_kind IN ('transient', 'permanent', 'unknown')
339
+ ),
340
+ raw_output TEXT,
341
+ error TEXT,
342
+ created_at REAL NOT NULL,
343
+ evidence_keys_json TEXT NOT NULL DEFAULT '[]',
344
+ CHECK (status = 'succeeded' OR error IS NOT NULL),
345
+ CHECK ((status = 'succeeded' AND failure_kind IS NULL) OR
346
+ (status = 'failed' AND failure_kind IS NOT NULL))
347
+ );
348
+
349
+ CREATE TABLE IF NOT EXISTS ledger_proposals (
350
+ proposal_id TEXT PRIMARY KEY NOT NULL,
351
+ run_id TEXT NOT NULL REFERENCES ledger_proposal_runs(run_id) ON DELETE CASCADE,
352
+ scope TEXT NOT NULL,
353
+ item_index INTEGER NOT NULL CHECK (item_index >= 0),
354
+ verdict TEXT NOT NULL CHECK (verdict IN ('accepted', 'candidate', 'rejected')),
355
+ predicate TEXT,
356
+ payload_json TEXT,
357
+ errors_json TEXT NOT NULL,
358
+ reason TEXT,
359
+ UNIQUE (run_id, item_index)
360
+ );
361
+
362
+ CREATE TABLE IF NOT EXISTS ledger_proposal_routes (
363
+ scope TEXT NOT NULL,
364
+ model TEXT NOT NULL,
365
+ model_digest TEXT NOT NULL,
366
+ predicate TEXT NOT NULL,
367
+ schema_version TEXT NOT NULL,
368
+ sample_count INTEGER NOT NULL CHECK (sample_count >= 0),
369
+ precision REAL NOT NULL,
370
+ safety_failures INTEGER NOT NULL CHECK (safety_failures >= 0),
371
+ evidence_json TEXT,
372
+ updated_at REAL NOT NULL,
373
+ PRIMARY KEY (scope, model, model_digest, predicate, schema_version)
374
+ );
375
+
376
+ CREATE TABLE IF NOT EXISTS ledger_proposal_applications (
377
+ run_id TEXT PRIMARY KEY NOT NULL
378
+ REFERENCES ledger_proposal_runs(run_id) ON DELETE CASCADE,
379
+ scope TEXT NOT NULL,
380
+ status TEXT NOT NULL CHECK (status IN ('applied', 'failed', 'closed')),
381
+ assertion_count INTEGER NOT NULL CHECK (assertion_count >= 0),
382
+ error TEXT,
383
+ updated_at REAL NOT NULL,
384
+ CHECK (status = 'applied' OR error IS NOT NULL)
385
+ );
386
+ `)
387
+
388
+ // Promotion is metadata on the existing candidate row, not a verdict rewrite:
389
+ // audit still sees the candidate, while metrics can tell it was injected.
390
+ const proposalColumns = new Set(
391
+ this.db.prepare("PRAGMA table_info('ledger_proposals')").all()
392
+ .map((column) => column.name),
393
+ )
394
+ if (!proposalColumns.has('promoted_at')) {
395
+ this.db.exec('ALTER TABLE ledger_proposals ADD COLUMN promoted_at REAL')
396
+ }
397
+ if (!proposalColumns.has('promoted_assertion_id')) {
398
+ this.db.exec('ALTER TABLE ledger_proposals ADD COLUMN promoted_assertion_id TEXT')
399
+ }
400
+
401
+ // In-place upgrade from the first revision of this journal, which had neither
402
+ // attempt/failure classification nor scope in the successful identity.
403
+ const runColumns = new Set(
404
+ this.db.prepare("PRAGMA table_info('ledger_proposal_runs')").all()
405
+ .map((column) => column.name),
406
+ )
407
+ if (!runColumns.has('attempt')) {
408
+ this.db.exec(
409
+ 'ALTER TABLE ledger_proposal_runs ADD COLUMN attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt >= 1)',
410
+ )
411
+ }
412
+ if (!runColumns.has('failure_kind')) {
413
+ this.db.exec(`ALTER TABLE ledger_proposal_runs ADD COLUMN failure_kind TEXT
414
+ CHECK (failure_kind IS NULL OR failure_kind IN ('transient', 'permanent', 'unknown'))`)
415
+ }
416
+ if (!runColumns.has('model_digest')) {
417
+ this.db.exec("ALTER TABLE ledger_proposal_runs ADD COLUMN model_digest TEXT NOT NULL DEFAULT ''")
418
+ }
419
+ if (!runColumns.has('evidence_keys_json')) {
420
+ this.db.exec(
421
+ "ALTER TABLE ledger_proposal_runs ADD COLUMN evidence_keys_json TEXT NOT NULL DEFAULT '[]'",
422
+ )
423
+ }
424
+
425
+ // Route identity includes the model digest. A pre-digest table cannot express
426
+ // that key, so it is rebuilt; legacy rows keep an empty digest and can never
427
+ // satisfy the read gate.
428
+ const routeColumns = new Set(
429
+ this.db.prepare("PRAGMA table_info('ledger_proposal_routes')").all()
430
+ .map((column) => column.name),
431
+ )
432
+ if (!routeColumns.has('model_digest')) {
433
+ this.db.exec(`
434
+ ALTER TABLE ledger_proposal_routes RENAME TO ledger_proposal_routes_pre_digest;
435
+ CREATE TABLE ledger_proposal_routes (
436
+ scope TEXT NOT NULL,
437
+ model TEXT NOT NULL,
438
+ model_digest TEXT NOT NULL,
439
+ predicate TEXT NOT NULL,
440
+ schema_version TEXT NOT NULL,
441
+ sample_count INTEGER NOT NULL CHECK (sample_count >= 0),
442
+ precision REAL NOT NULL,
443
+ safety_failures INTEGER NOT NULL CHECK (safety_failures >= 0),
444
+ evidence_json TEXT,
445
+ updated_at REAL NOT NULL,
446
+ PRIMARY KEY (scope, model, model_digest, predicate, schema_version)
447
+ );
448
+ INSERT INTO ledger_proposal_routes (
449
+ scope, model, model_digest, predicate, schema_version, sample_count,
450
+ precision, safety_failures, evidence_json, updated_at
451
+ )
452
+ SELECT scope, model, '', predicate, schema_version, sample_count, precision,
453
+ safety_failures, evidence_json, updated_at
454
+ FROM ledger_proposal_routes_pre_digest;
455
+ DROP TABLE ledger_proposal_routes_pre_digest;
456
+ `)
457
+ }
458
+
459
+ // `closed` is a permanent not-injectable outcome, distinct from a retryable
460
+ // application failure. Older tables have a CHECK that rejects it.
461
+ const applicationSql = this.db.prepare(
462
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ledger_proposal_applications'",
463
+ ).get()?.sql ?? ''
464
+ if (!applicationSql.includes("'closed'")) {
465
+ this.db.exec(`
466
+ ALTER TABLE ledger_proposal_applications RENAME TO ledger_proposal_applications_pre_closed;
467
+ CREATE TABLE ledger_proposal_applications (
468
+ run_id TEXT PRIMARY KEY NOT NULL
469
+ REFERENCES ledger_proposal_runs(run_id) ON DELETE CASCADE,
470
+ scope TEXT NOT NULL,
471
+ status TEXT NOT NULL CHECK (status IN ('applied', 'failed', 'closed')),
472
+ assertion_count INTEGER NOT NULL CHECK (assertion_count >= 0),
473
+ error TEXT,
474
+ updated_at REAL NOT NULL,
475
+ CHECK (status = 'applied' OR error IS NOT NULL)
476
+ );
477
+ INSERT INTO ledger_proposal_applications (
478
+ run_id, scope, status, assertion_count, error, updated_at
479
+ )
480
+ SELECT run_id, scope, status, assertion_count, error, updated_at
481
+ FROM ledger_proposal_applications_pre_closed;
482
+ DROP TABLE ledger_proposal_applications_pre_closed;
483
+ `)
484
+ }
485
+
486
+ // Scope, model, and model digest are part of the successful identity: identical
487
+ // counters from a different model build or scope are never a replay.
488
+ this.db.exec(`
489
+ DROP INDEX IF EXISTS ledger_proposal_runs_success_identity;
490
+ DROP INDEX IF EXISTS ledger_proposal_runs_success_scope_identity;
491
+ CREATE UNIQUE INDEX IF NOT EXISTS ledger_proposal_runs_success_scope_identity
492
+ ON ledger_proposal_runs(scope, input_hash, model, model_digest, schema_version)
493
+ WHERE status = 'succeeded';
494
+ CREATE INDEX IF NOT EXISTS ledger_proposal_runs_scope_time
495
+ ON ledger_proposal_runs(scope, created_at);
496
+ CREATE INDEX IF NOT EXISTS ledger_proposal_runs_evidence_identity
497
+ ON ledger_proposal_runs(scope, model, model_digest, schema_version, evidence_keys_json)
498
+ WHERE status = 'succeeded';
499
+ CREATE INDEX IF NOT EXISTS ledger_proposals_scope_verdict
500
+ ON ledger_proposals(scope, verdict);
501
+ CREATE INDEX IF NOT EXISTS ledger_proposal_routes_scope
502
+ ON ledger_proposal_routes(scope, predicate);
503
+ CREATE INDEX IF NOT EXISTS ledger_proposal_applications_scope
504
+ ON ledger_proposal_applications(scope, status);
505
+ `)
506
+ }
507
+
508
+ _transaction(write) {
509
+ if (this.db.isTransaction) return write()
510
+ this.db.exec('BEGIN IMMEDIATE')
511
+ try {
512
+ const result = write()
513
+ this.db.exec('COMMIT')
514
+ return result
515
+ } catch (error) {
516
+ this.db.exec('ROLLBACK')
517
+ throw error
518
+ }
519
+ }
520
+
521
+ _itemsForRun(runId) {
522
+ return this.db.prepare(
523
+ 'SELECT * FROM ledger_proposals WHERE run_id = ? ORDER BY item_index',
524
+ ).all(runId).map(decodeItem)
525
+ }
526
+
527
+ _successfulRun(scope, inputHash, model, modelDigest, schemaVersion) {
528
+ const row = this.db.prepare(`
529
+ SELECT * FROM ledger_proposal_runs
530
+ WHERE scope = ? AND input_hash = ? AND model = ? AND model_digest = ?
531
+ AND schema_version = ? AND status = 'succeeded'
532
+ LIMIT 1
533
+ `).get(scope, inputHash, model, modelDigest, schemaVersion) ?? null
534
+ if (!row) return null
535
+ // Defence in depth: even a future query change must not hand a foreign run
536
+ // to the caller.
537
+ if (row.scope !== scope) {
538
+ throw new ProposalScopeMismatchError(row.run_id, row.scope, scope)
539
+ }
540
+ return row
541
+ }
542
+
543
+ findRun({ scope, inputHash, model, modelDigest, schemaVersion } = {}) {
544
+ requireNonEmpty('scope', scope)
545
+ requireNonEmpty('inputHash', inputHash)
546
+ requireNonEmpty('model', model)
547
+ requireNonEmpty('modelDigest', modelDigest)
548
+ requireNonEmpty('schemaVersion', schemaVersion)
549
+ // A successful run is the semantic cache entry. Failed attempts are still
550
+ // observable when nothing succeeded, so callers can tell "never ran" from
551
+ // "ran and failed" instead of retrying blindly.
552
+ const row = this.db.prepare(`
553
+ SELECT * FROM ledger_proposal_runs
554
+ WHERE scope = ? AND input_hash = ? AND model = ? AND model_digest = ?
555
+ AND schema_version = ?
556
+ ORDER BY CASE status WHEN 'succeeded' THEN 0 ELSE 1 END, created_at DESC, run_id DESC
557
+ LIMIT 1
558
+ `).get(scope, inputHash, model, modelDigest, schemaVersion)
559
+ if (!row) return null
560
+ return { run: decodeRun(row), items: this._itemsForRun(row.run_id) }
561
+ }
562
+
563
+ // Evidence-based replay identity: scope, model, model digest, schema version, and
564
+ // the sorted archived event keys. It deliberately excludes active facts, so an
565
+ // old message cannot be re-analysed into a new fact after a user retraction. The
566
+ // earliest successful run for the set is the canonical answer.
567
+ findRunByEvidence({ scope, model, modelDigest, schemaVersion, evidenceEventKeys } = {}) {
568
+ requireNonEmpty('scope', scope)
569
+ requireNonEmpty('model', model)
570
+ requireNonEmpty('modelDigest', modelDigest)
571
+ requireNonEmpty('schemaVersion', schemaVersion)
572
+ if (!Array.isArray(evidenceEventKeys) ||
573
+ evidenceEventKeys.some((value) => typeof value !== 'string' || value.trim() === '')) {
574
+ throw new TypeError('findRunByEvidence requires an array of non-empty evidence keys')
575
+ }
576
+ const normalized = [...new Set(evidenceEventKeys)].sort()
577
+ if (normalized.length === 0) return null
578
+ const row = this.db.prepare(`
579
+ SELECT * FROM ledger_proposal_runs
580
+ WHERE scope = ? AND model = ? AND model_digest = ? AND schema_version = ?
581
+ AND status = 'succeeded' AND evidence_keys_json = ?
582
+ ORDER BY created_at, run_id
583
+ LIMIT 1
584
+ `).get(scope, model, modelDigest, schemaVersion, canonicalJson(normalized)) ?? null
585
+ if (!row) return null
586
+ return { run: decodeRun(row), items: this._itemsForRun(row.run_id) }
587
+ }
588
+
589
+ recordRun(input) {
590
+ const run = normalizeRun(input)
591
+ return this._transaction(() => {
592
+ // Only a successful attempt is cacheable evidence; failed attempts are
593
+ // always archived so retries and later successes remain observable.
594
+ if (run.status === 'succeeded') {
595
+ const successful = this._successfulRun(
596
+ run.scope, run.inputHash, run.model, run.modelDigest, run.schemaVersion,
597
+ )
598
+ if (successful) {
599
+ const stored = decodeRun(successful)
600
+ if (stored.scope !== run.scope) {
601
+ throw new ProposalScopeMismatchError(stored.runId, stored.scope, run.scope)
602
+ }
603
+ const items = this._itemsForRun(stored.runId)
604
+ if (stored.runId === run.runId &&
605
+ runSignature(stored, items) !== runSignature(run, run.items)) {
606
+ throw new Error(`successful proposal run is immutable: ${run.runId}`)
607
+ }
608
+ // A different run ID over a successful identity is a replay, not new
609
+ // evidence: the stored run wins and no rows are added.
610
+ return { run: stored, items, duplicate: true }
611
+ }
612
+ }
613
+
614
+ const sameId = this.db.prepare(
615
+ 'SELECT * FROM ledger_proposal_runs WHERE run_id = ?',
616
+ ).get(run.runId)
617
+ if (sameId) {
618
+ const stored = decodeRun(sameId)
619
+ if (stored.scope !== run.scope) {
620
+ throw new ProposalScopeMismatchError(stored.runId, stored.scope, run.scope)
621
+ }
622
+ const items = this._itemsForRun(stored.runId)
623
+ if (runSignature(stored, items) !== runSignature(run, run.items)) {
624
+ throw new Error(`proposal run cannot be mutated: ${run.runId}`)
625
+ }
626
+ return { run: stored, items, duplicate: true }
627
+ }
628
+
629
+ try {
630
+ this.db.prepare(`
631
+ INSERT INTO ledger_proposal_runs (
632
+ run_id, scope, input_hash, model, model_digest, schema_version, status,
633
+ attempt, failure_kind, raw_output, error, created_at, evidence_keys_json
634
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
635
+ `).run(
636
+ run.runId, run.scope, run.inputHash, run.model, run.modelDigest, run.schemaVersion,
637
+ run.status, run.attempt, run.failureKind, run.rawOutput, run.error, run.createdAt,
638
+ canonicalJson(run.evidenceEventKeys),
639
+ )
640
+ } catch (error) {
641
+ // The partial unique index is the real idempotency guard; if another
642
+ // connection won the race, return its successful run unchanged.
643
+ if (run.status === 'succeeded' && /UNIQUE constraint failed/i.test(String(error?.message))) {
644
+ const winner = this._successfulRun(
645
+ run.scope, run.inputHash, run.model, run.modelDigest, run.schemaVersion,
646
+ )
647
+ if (winner) {
648
+ const stored = decodeRun(winner)
649
+ return { run: stored, items: this._itemsForRun(stored.runId), duplicate: true }
650
+ }
651
+ }
652
+ throw error
653
+ }
654
+
655
+ const insertItem = this.db.prepare(`
656
+ INSERT INTO ledger_proposals (
657
+ proposal_id, run_id, scope, item_index, verdict, predicate,
658
+ payload_json, errors_json, reason
659
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
660
+ `)
661
+ for (const item of run.items) {
662
+ insertItem.run(
663
+ proposalIdFor(run, item.itemIndex), run.runId, run.scope, item.itemIndex,
664
+ item.verdict, item.predicate, canonicalJson(item.payload),
665
+ canonicalJson(item.errors), item.reason,
666
+ )
667
+ }
668
+
669
+ return {
670
+ run: {
671
+ runId: run.runId,
672
+ scope: run.scope,
673
+ inputHash: run.inputHash,
674
+ model: run.model,
675
+ modelDigest: run.modelDigest,
676
+ schemaVersion: run.schemaVersion,
677
+ status: run.status,
678
+ attempt: run.attempt,
679
+ failureKind: run.failureKind,
680
+ rawOutput: run.rawOutput,
681
+ error: run.error,
682
+ createdAt: run.createdAt,
683
+ evidenceEventKeys: run.evidenceEventKeys,
684
+ },
685
+ items: run.items.map((item) => ({
686
+ proposalId: proposalIdFor(run, item.itemIndex),
687
+ runId: run.runId,
688
+ scope: run.scope,
689
+ itemIndex: item.itemIndex,
690
+ verdict: item.verdict,
691
+ predicate: item.predicate,
692
+ payload: item.payload,
693
+ errors: item.errors,
694
+ reason: item.reason,
695
+ promotedAt: null,
696
+ promotedAssertionId: null,
697
+ })),
698
+ duplicate: false,
699
+ }
700
+ })
701
+ }
702
+
703
+ // The journal is written before accepted assertions reach the ledger. This row is
704
+ // how metrics and audit distinguish "analysed and applied" from "analysed but the
705
+ // append failed", instead of either lying about acceptance or losing the run.
706
+ getApplication(runId) {
707
+ requireNonEmpty('runId', runId)
708
+ const row = this.db.prepare(
709
+ 'SELECT * FROM ledger_proposal_applications WHERE run_id = ?',
710
+ ).get(runId)
711
+ if (!row) return null
712
+ return {
713
+ runId: row.run_id,
714
+ scope: row.scope,
715
+ status: row.status,
716
+ assertionCount: row.assertion_count,
717
+ error: row.error ?? null,
718
+ updatedAt: row.updated_at,
719
+ }
720
+ }
721
+
722
+ recordApplication(input) {
723
+ const application = normalizeApplication(input)
724
+ return this._transaction(() => {
725
+ const run = this.db.prepare(
726
+ 'SELECT scope FROM ledger_proposal_runs WHERE run_id = ?',
727
+ ).get(application.runId)
728
+ if (!run) {
729
+ throw new TypeError(`application requires an archived run: ${application.runId}`)
730
+ }
731
+ if (run.scope !== application.scope) {
732
+ throw new ProposalScopeMismatchError(
733
+ application.runId, run.scope, application.scope,
734
+ )
735
+ }
736
+ const existing = this.getApplication(application.runId)
737
+ if (existing) {
738
+ if (existing.status === application.status &&
739
+ existing.assertionCount === application.assertionCount &&
740
+ existing.error === application.error) {
741
+ return existing
742
+ }
743
+ // `applied` and `closed` are final. A failed attempt wrote no facts, so a
744
+ // later replay may refine its reason or complete it. Closed records never
745
+ // silently reopen: a route restored later does not turn a permanent closure
746
+ // into an injection without a new proposal run.
747
+ if (existing.status !== 'failed') {
748
+ throw new Error(`proposal application is immutable: ${application.runId}`)
749
+ }
750
+ this.db.prepare(`
751
+ UPDATE ledger_proposal_applications
752
+ SET status = ?, assertion_count = ?, error = ?, updated_at = ?
753
+ WHERE run_id = ?
754
+ `).run(
755
+ application.status, application.assertionCount, application.error,
756
+ application.updatedAt, application.runId,
757
+ )
758
+ return this.getApplication(application.runId)
759
+ }
760
+ this.db.prepare(`
761
+ INSERT INTO ledger_proposal_applications (
762
+ run_id, scope, status, assertion_count, error, updated_at
763
+ ) VALUES (?, ?, ?, ?, ?, ?)
764
+ `).run(
765
+ application.runId, application.scope, application.status,
766
+ application.assertionCount, application.error, application.updatedAt,
767
+ )
768
+ return this.getApplication(application.runId)
769
+ })
770
+ }
771
+
772
+ // Promotion is recorded on the candidate row itself. The verdict stays
773
+ // `candidate` for audit, while metrics stop counting an injected fact as an
774
+ // open candidate. COALESCE keeps the first promotion timestamp on idempotent
775
+ // re-promotion.
776
+ markPromoted({ proposalId, scope, promotedAt, promotedAssertionId } = {}) {
777
+ requireNonEmpty('proposalId', proposalId)
778
+ requireNonEmpty('scope', scope)
779
+ requireFinite('promotedAt', promotedAt)
780
+ requireNonEmpty('promotedAssertionId', promotedAssertionId)
781
+ return this._transaction(() => {
782
+ const row = this.db.prepare(
783
+ 'SELECT * FROM ledger_proposals WHERE proposal_id = ?',
784
+ ).get(proposalId)
785
+ if (!row) throw new TypeError(`unknown proposal: ${proposalId}`)
786
+ if (row.scope !== scope) {
787
+ throw new ProposalScopeMismatchError(row.run_id, row.scope, scope)
788
+ }
789
+ if (row.verdict !== 'candidate') {
790
+ throw new TypeError('promotion requires a candidate proposal')
791
+ }
792
+ this.db.prepare(`
793
+ UPDATE ledger_proposals
794
+ SET promoted_at = COALESCE(promoted_at, ?),
795
+ promoted_assertion_id = COALESCE(promoted_assertion_id, ?)
796
+ WHERE proposal_id = ? AND scope = ?
797
+ `).run(promotedAt, promotedAssertionId, proposalId, scope)
798
+ return decodeItem(this.db.prepare(
799
+ 'SELECT * FROM ledger_proposals WHERE proposal_id = ?',
800
+ ).get(proposalId))
801
+ })
802
+ }
803
+
804
+ listProposals(scope, { verdict } = {}) {
805
+ requireNonEmpty('scope', scope)
806
+ if (verdict !== undefined && verdict !== null && !ITEM_VERDICTS.has(verdict)) {
807
+ throw new TypeError('verdict must be accepted, candidate, or rejected')
808
+ }
809
+ const base = `
810
+ SELECT p.* FROM ledger_proposals p
811
+ JOIN ledger_proposal_runs r ON r.run_id = p.run_id
812
+ WHERE p.scope = ?
813
+ `
814
+ const order = ' ORDER BY r.created_at, p.run_id, p.item_index'
815
+ const rows = verdict === undefined || verdict === null
816
+ ? this.db.prepare(`${base}${order}`).all(scope)
817
+ : this.db.prepare(`${base} AND p.verdict = ?${order}`).all(scope, verdict)
818
+ return rows.map(decodeItem)
819
+ }
820
+
821
+ metrics(scope) {
822
+ requireNonEmpty('scope', scope)
823
+ const byStatus = { succeeded: 0, failed: 0 }
824
+ for (const row of this.db.prepare(
825
+ 'SELECT status, count(*) AS count FROM ledger_proposal_runs WHERE scope = ? GROUP BY status',
826
+ ).all(scope)) {
827
+ if (Object.hasOwn(byStatus, row.status)) byStatus[row.status] = row.count
828
+ }
829
+ const byVerdict = { accepted: 0, candidate: 0, rejected: 0, promoted: 0 }
830
+ for (const row of this.db.prepare(`
831
+ SELECT verdict, (promoted_assertion_id IS NOT NULL) AS promoted, count(*) AS count
832
+ FROM ledger_proposals WHERE scope = ? GROUP BY verdict, promoted
833
+ `).all(scope)) {
834
+ if (row.promoted === 1 && row.verdict === 'candidate') {
835
+ byVerdict.promoted += row.count
836
+ } else if (Object.hasOwn(byVerdict, row.verdict)) {
837
+ byVerdict[row.verdict] = row.count
838
+ }
839
+ }
840
+ const applications = { applied: 0, failed: 0, closed: 0 }
841
+ for (const row of this.db.prepare(`
842
+ SELECT status, count(*) AS count FROM ledger_proposal_applications
843
+ WHERE scope = ? GROUP BY status
844
+ `).all(scope)) {
845
+ if (Object.hasOwn(applications, row.status)) applications[row.status] = row.count
846
+ }
847
+
848
+ const modelRollups = new Map()
849
+ const modelEntry = (model) => {
850
+ if (!modelRollups.has(model)) {
851
+ modelRollups.set(model, {
852
+ runs: { total: 0, succeeded: 0, failed: 0 },
853
+ proposals: { total: 0, accepted: 0, candidate: 0, rejected: 0, promoted: 0 },
854
+ })
855
+ }
856
+ return modelRollups.get(model)
857
+ }
858
+ for (const row of this.db.prepare(`
859
+ SELECT model, status, count(*) AS count FROM ledger_proposal_runs
860
+ WHERE scope = ? GROUP BY model, status ORDER BY model, status
861
+ `).all(scope)) {
862
+ const entry = modelEntry(row.model)
863
+ entry.runs.total += row.count
864
+ if (Object.hasOwn(entry.runs, row.status)) entry.runs[row.status] = row.count
865
+ }
866
+ for (const row of this.db.prepare(`
867
+ SELECT r.model AS model, p.verdict AS verdict,
868
+ (p.promoted_assertion_id IS NOT NULL) AS promoted, count(*) AS count
869
+ FROM ledger_proposals p
870
+ JOIN ledger_proposal_runs r ON r.run_id = p.run_id
871
+ WHERE p.scope = ? GROUP BY r.model, p.verdict, promoted ORDER BY r.model, p.verdict
872
+ `).all(scope)) {
873
+ const entry = modelEntry(row.model)
874
+ entry.proposals.total += row.count
875
+ if (row.promoted === 1 && row.verdict === 'candidate') {
876
+ entry.proposals.promoted += row.count
877
+ } else if (Object.hasOwn(entry.proposals, row.verdict)) {
878
+ entry.proposals[row.verdict] = row.count
879
+ }
880
+ }
881
+
882
+ // Items may legally carry no predicate; keep that bucket explicit instead of
883
+ // collapsing it into an ordinary predicate name.
884
+ const predicateRollups = new Map()
885
+ for (const row of this.db.prepare(`
886
+ SELECT predicate, verdict, (promoted_assertion_id IS NOT NULL) AS promoted,
887
+ count(*) AS count FROM ledger_proposals
888
+ WHERE scope = ? GROUP BY predicate, verdict, promoted ORDER BY predicate, verdict
889
+ `).all(scope)) {
890
+ const key = row.predicate ?? '(none)'
891
+ if (!predicateRollups.has(key)) {
892
+ predicateRollups.set(key, {
893
+ predicate: row.predicate ?? null,
894
+ proposals: { total: 0, accepted: 0, candidate: 0, rejected: 0, promoted: 0 },
895
+ })
896
+ }
897
+ const entry = predicateRollups.get(key)
898
+ entry.proposals.total += row.count
899
+ if (row.promoted === 1 && row.verdict === 'candidate') {
900
+ entry.proposals.promoted += row.count
901
+ } else if (Object.hasOwn(entry.proposals, row.verdict)) {
902
+ entry.proposals[row.verdict] = row.count
903
+ }
904
+ }
905
+
906
+ const rejectionReasons = new Map()
907
+ for (const row of this.db.prepare(`
908
+ SELECT reason, count(*) AS count FROM ledger_proposals
909
+ WHERE scope = ? AND verdict = 'rejected' GROUP BY reason ORDER BY reason
910
+ `).all(scope)) {
911
+ rejectionReasons.set(row.reason ?? '(none)', row.count)
912
+ }
913
+
914
+ const runsTotal = byStatus.succeeded + byStatus.failed
915
+ const proposalsTotal = byVerdict.accepted + byVerdict.candidate +
916
+ byVerdict.rejected + byVerdict.promoted
917
+ // Precedence matters: successful evidence outranks failed attempts, an empty
918
+ // successful run is not the same thing as never having run at all, and the
919
+ // latest application outcome decides whether facts are missing. The state is
920
+ // derived, never sticky: a later successful application recovers it even while
921
+ // an older run's failure remains archived.
922
+ let state = byStatus.succeeded > 0
923
+ ? (proposalsTotal > 0 ? 'found' : 'ran-empty')
924
+ : (byStatus.failed > 0 ? 'failed' : 'not-run')
925
+ const latestApplication = this.db.prepare(`
926
+ SELECT status FROM ledger_proposal_applications
927
+ WHERE scope = ? ORDER BY updated_at DESC, rowid DESC LIMIT 1
928
+ `).get(scope)
929
+ // A permanent closure (closed target or evidence that no longer validates) is
930
+ // not an application failure: it never wrote facts and never will under this
931
+ // run, so the scope state stays derived from the runs instead of sticking at
932
+ // apply-failed. Only a retryable failure, including a temporarily revoked
933
+ // route, changes the state.
934
+ if (latestApplication?.status === 'failed') state = 'apply-failed'
935
+
936
+ const last = this.db.prepare(`
937
+ SELECT run_id, status, attempt, failure_kind, created_at FROM ledger_proposal_runs
938
+ WHERE scope = ? ORDER BY created_at DESC, run_id DESC LIMIT 1
939
+ `).get(scope)
940
+ const lastApplication = last
941
+ ? this.db.prepare(
942
+ 'SELECT status, error FROM ledger_proposal_applications WHERE run_id = ?',
943
+ ).get(last.run_id)
944
+ : null
945
+ const lastRun = last
946
+ ? {
947
+ runId: last.run_id,
948
+ status: last.status,
949
+ attempt: last.attempt,
950
+ failureKind: last.failure_kind ?? null,
951
+ createdAt: last.created_at,
952
+ proposalCount: this.db.prepare(
953
+ 'SELECT count(*) AS count FROM ledger_proposals WHERE run_id = ?',
954
+ ).get(last.run_id).count,
955
+ application: lastApplication
956
+ ? { status: lastApplication.status, error: lastApplication.error ?? null }
957
+ : null,
958
+ }
959
+ : null
960
+
961
+ return {
962
+ scope,
963
+ state,
964
+ runs: { total: runsTotal, succeeded: byStatus.succeeded, failed: byStatus.failed },
965
+ applications: {
966
+ applied: applications.applied,
967
+ failed: applications.failed,
968
+ closed: applications.closed,
969
+ },
970
+ proposals: {
971
+ total: proposalsTotal,
972
+ accepted: byVerdict.accepted,
973
+ candidate: byVerdict.candidate,
974
+ rejected: byVerdict.rejected,
975
+ promoted: byVerdict.promoted,
976
+ },
977
+ byModel: Object.fromEntries(modelRollups),
978
+ byPredicate: Object.fromEntries(predicateRollups),
979
+ rejectionReasons: Object.fromEntries(rejectionReasons),
980
+ lastRun,
981
+ }
982
+ }
983
+
984
+ // Trusted internal write path: only the offline evaluator may call setRoute.
985
+ // It is never reachable from model output, user input, or request handlers.
986
+ // Evidence counters are validated here, while enablement stays derived on read.
987
+ setRoute(input) {
988
+ const route = normalizeRoute(input)
989
+ this.db.prepare(`
990
+ INSERT INTO ledger_proposal_routes (
991
+ scope, model, model_digest, predicate, schema_version, sample_count, precision,
992
+ safety_failures, evidence_json, updated_at
993
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
994
+ ON CONFLICT(scope, model, model_digest, predicate, schema_version) DO UPDATE SET
995
+ sample_count = excluded.sample_count,
996
+ precision = excluded.precision,
997
+ safety_failures = excluded.safety_failures,
998
+ evidence_json = excluded.evidence_json,
999
+ updated_at = excluded.updated_at
1000
+ `).run(
1001
+ route.scope, route.model, route.modelDigest, route.predicate, route.schemaVersion,
1002
+ route.sampleCount, route.precision, route.safetyFailures,
1003
+ canonicalJson(route.evidence), route.updatedAt,
1004
+ )
1005
+ return this.getRoute({
1006
+ scope: route.scope,
1007
+ model: route.model,
1008
+ modelDigest: route.modelDigest,
1009
+ predicate: route.predicate,
1010
+ schemaVersion: route.schemaVersion,
1011
+ })
1012
+ }
1013
+
1014
+ getRoute({ scope, model, modelDigest, predicate, schemaVersion } = {}) {
1015
+ requireNonEmpty('scope', scope)
1016
+ requireNonEmpty('model', model)
1017
+ requireNonEmpty('modelDigest', modelDigest)
1018
+ requireNonEmpty('predicate', predicate)
1019
+ requireNonEmpty('schemaVersion', schemaVersion)
1020
+ const row = this.db.prepare(`
1021
+ SELECT * FROM ledger_proposal_routes
1022
+ WHERE scope = ? AND model = ? AND model_digest = ? AND predicate = ?
1023
+ AND schema_version = ?
1024
+ `).get(scope, model, modelDigest, predicate, schemaVersion)
1025
+ if (!row) return null
1026
+ const evidence = row.evidence_json === null ? null : JSON.parse(row.evidence_json)
1027
+ // Enablement is derived from a fully validated row. A hand-edited counter set,
1028
+ // a mismatched digest, a missing evidence row, or recall below the floor keeps
1029
+ // the route closed even if the stored integers look sufficient.
1030
+ const enabled = routeRowValid({
1031
+ sampleCount: row.sample_count,
1032
+ precision: row.precision,
1033
+ recall: evidence?.recall,
1034
+ safetyFailures: row.safety_failures,
1035
+ evidence,
1036
+ modelDigest: row.model_digest,
1037
+ })
1038
+ return {
1039
+ scope: row.scope,
1040
+ model: row.model,
1041
+ modelDigest: row.model_digest,
1042
+ predicate: row.predicate,
1043
+ schemaVersion: row.schema_version,
1044
+ sampleCount: row.sample_count,
1045
+ precision: row.precision,
1046
+ recall: evidence?.recall ?? null,
1047
+ safetyFailures: row.safety_failures,
1048
+ evidence,
1049
+ updatedAt: row.updated_at,
1050
+ enabled,
1051
+ status: enabled ? 'enabled' : 'disabled',
1052
+ }
1053
+ }
1054
+ }