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,836 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+ import {
3
+ canonicalJson,
4
+ INJECTABLE_VERIFICATIONS,
5
+ projectLedger,
6
+ projectionHash,
7
+ factsAt as selectFactsAt,
8
+ registryEntry,
9
+ renderFacts,
10
+ routeRowValid,
11
+ validateAssertionInput,
12
+ } from './memory-ledger-core.mjs'
13
+
14
+ const REDUCER_VERSION = 'r2'
15
+ const DEFAULT_MAX_PROJECTION_EVENTS = 5000
16
+ const DEFAULT_MAX_PROJECTION_MS = 30000
17
+
18
+ function jsonColumn(value) {
19
+ return value === undefined ? null : canonicalJson(value)
20
+ }
21
+
22
+ function decodeRow(row) {
23
+ return JSON.parse(row.fact_json)
24
+ }
25
+
26
+ // Archive reads are read-only by runtime enforcement, not convention: a caller
27
+ // holding the DTO cannot reach the archived row through a nested object either.
28
+ function deepFreeze(value) {
29
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
30
+ Object.freeze(value)
31
+ for (const key of Object.keys(value)) deepFreeze(value[key])
32
+ return value
33
+ }
34
+
35
+ function nonEmptyString(value) {
36
+ return typeof value === 'string' && value.trim() !== ''
37
+ }
38
+
39
+ export class MemoryLedger {
40
+ constructor(filename, {
41
+ reducer = projectLedger,
42
+ maxProjectionEvents = DEFAULT_MAX_PROJECTION_EVENTS,
43
+ maxProjectionMs = DEFAULT_MAX_PROJECTION_MS,
44
+ } = {}) {
45
+ if (typeof reducer !== 'function') throw new TypeError('reducer must be a function')
46
+ if (!Number.isInteger(maxProjectionEvents) || maxProjectionEvents <= 0) {
47
+ throw new TypeError('maxProjectionEvents must be a positive integer')
48
+ }
49
+ if (!Number.isFinite(maxProjectionMs) || maxProjectionMs <= 0) {
50
+ throw new TypeError('maxProjectionMs must be a positive number')
51
+ }
52
+ this.db = new DatabaseSync(filename)
53
+ this.reducer = reducer
54
+ this.maxProjectionEvents = maxProjectionEvents
55
+ this.maxProjectionMs = maxProjectionMs
56
+ this.db.exec('PRAGMA foreign_keys = ON')
57
+ this.db.exec(`
58
+ CREATE TABLE IF NOT EXISTS ledger_observations (
59
+ event_key TEXT PRIMARY KEY,
60
+ scope TEXT NOT NULL,
61
+ sid TEXT NOT NULL,
62
+ seq INTEGER NOT NULL,
63
+ valid_at REAL NOT NULL,
64
+ recorded_at REAL NOT NULL,
65
+ op TEXT NOT NULL,
66
+ outcome TEXT NOT NULL,
67
+ subject_entity_id TEXT NOT NULL,
68
+ target_entity_id TEXT,
69
+ value_json TEXT,
70
+ generation_hints_json TEXT,
71
+ raw_ref TEXT,
72
+ raw_payload_json TEXT,
73
+ source_json TEXT,
74
+ extractor_version TEXT,
75
+ projection_state TEXT NOT NULL CHECK (projection_state IN ('pending', 'applied', 'dead')),
76
+ projection_error TEXT,
77
+ full_input_json TEXT NOT NULL
78
+ );
79
+ CREATE INDEX IF NOT EXISTS ledger_observations_scope
80
+ ON ledger_observations(scope, valid_at, sid, seq, event_key);
81
+
82
+ CREATE TABLE IF NOT EXISTS ledger_assertions (
83
+ assertion_id TEXT PRIMARY KEY,
84
+ scope TEXT NOT NULL,
85
+ subject_entity_id TEXT NOT NULL,
86
+ sid TEXT NOT NULL,
87
+ seq INTEGER NOT NULL,
88
+ op TEXT NOT NULL,
89
+ target_assertion_id TEXT,
90
+ predicate TEXT NOT NULL,
91
+ value_json TEXT,
92
+ qualifiers_json TEXT,
93
+ valid_at REAL NOT NULL,
94
+ recorded_at REAL NOT NULL,
95
+ source_kind TEXT NOT NULL,
96
+ source_authority TEXT,
97
+ evidence_json TEXT,
98
+ verification TEXT NOT NULL,
99
+ reducer_version TEXT NOT NULL,
100
+ projection_state TEXT NOT NULL CHECK (projection_state IN ('pending', 'applied', 'dead')),
101
+ projection_error TEXT,
102
+ full_input_json TEXT NOT NULL
103
+ );
104
+ CREATE INDEX IF NOT EXISTS ledger_assertions_scope
105
+ ON ledger_assertions(scope, valid_at, sid, seq, assertion_id);
106
+
107
+ CREATE TABLE IF NOT EXISTS ledger_entities (
108
+ scope TEXT NOT NULL,
109
+ entity_id TEXT NOT NULL,
110
+ generation INTEGER NOT NULL,
111
+ presence INTEGER NOT NULL,
112
+ opened_by_json TEXT,
113
+ closed_by_json TEXT,
114
+ last_valid_at REAL,
115
+ last_recorded_at REAL,
116
+ PRIMARY KEY (scope, entity_id)
117
+ );
118
+
119
+ CREATE TABLE IF NOT EXISTS ledger_fact_versions (
120
+ fact_key TEXT NOT NULL,
121
+ scope TEXT NOT NULL,
122
+ subject_entity_id TEXT NOT NULL,
123
+ subject_display TEXT,
124
+ generation INTEGER,
125
+ predicate TEXT NOT NULL,
126
+ value_json TEXT,
127
+ qualifiers_json TEXT,
128
+ assertion_id TEXT NOT NULL,
129
+ evidence_json TEXT,
130
+ causal_order TEXT NOT NULL,
131
+ valid_from REAL NOT NULL,
132
+ valid_to REAL,
133
+ recorded_at REAL NOT NULL,
134
+ retired_at REAL,
135
+ status TEXT NOT NULL,
136
+ verification TEXT,
137
+ conflict_json TEXT,
138
+ op TEXT,
139
+ target_assertion_id TEXT,
140
+ sid TEXT,
141
+ seq INTEGER,
142
+ reducer_version TEXT,
143
+ source_kind TEXT,
144
+ source_authority TEXT,
145
+ fact_json TEXT NOT NULL
146
+ );
147
+ CREATE INDEX IF NOT EXISTS ledger_fact_versions_current
148
+ ON ledger_fact_versions(scope, subject_entity_id, generation, retired_at, valid_to, status);
149
+ CREATE INDEX IF NOT EXISTS ledger_fact_versions_history
150
+ ON ledger_fact_versions(scope, valid_from, valid_to, recorded_at, retired_at);
151
+
152
+ CREATE TABLE IF NOT EXISTS ledger_observation_placements (
153
+ event_key TEXT NOT NULL,
154
+ scope TEXT NOT NULL,
155
+ entity_id TEXT NOT NULL,
156
+ role TEXT NOT NULL,
157
+ generation INTEGER,
158
+ unplaced INTEGER NOT NULL,
159
+ reason TEXT,
160
+ causal_order TEXT NOT NULL
161
+ );
162
+ CREATE INDEX IF NOT EXISTS ledger_observation_placements_scope
163
+ ON ledger_observation_placements(scope, event_key);
164
+
165
+ CREATE TABLE IF NOT EXISTS ledger_assertion_placements (
166
+ assertion_id TEXT NOT NULL,
167
+ scope TEXT NOT NULL,
168
+ entity_id TEXT NOT NULL,
169
+ generation INTEGER,
170
+ unplaced INTEGER NOT NULL,
171
+ reason TEXT,
172
+ causal_order TEXT NOT NULL
173
+ );
174
+ CREATE INDEX IF NOT EXISTS ledger_assertion_placements_scope
175
+ ON ledger_assertion_placements(scope, assertion_id);
176
+
177
+ CREATE TABLE IF NOT EXISTS ledger_meta (
178
+ scope TEXT PRIMARY KEY,
179
+ reducer_version TEXT NOT NULL,
180
+ projection_hash TEXT,
181
+ status TEXT NOT NULL DEFAULT 'applied',
182
+ projection_error TEXT,
183
+ failing_record TEXT
184
+ );
185
+ `)
186
+ this._ensureMetaColumns()
187
+ }
188
+
189
+ _ensureMetaColumns() {
190
+ const columns = new Set(this.db.prepare('PRAGMA table_info(ledger_meta)').all()
191
+ .map((column) => column.name))
192
+ for (const [name, definition] of [
193
+ ['status', "TEXT NOT NULL DEFAULT 'applied'"],
194
+ ['projection_error', 'TEXT'],
195
+ ['failing_record', 'TEXT'],
196
+ ]) {
197
+ if (!columns.has(name)) {
198
+ this.db.exec(`ALTER TABLE ledger_meta ADD COLUMN ${name} ${definition}`)
199
+ }
200
+ }
201
+ }
202
+
203
+ _transaction(write) {
204
+ this.db.exec('BEGIN IMMEDIATE')
205
+ try {
206
+ const result = write()
207
+ this.db.exec('COMMIT')
208
+ return result
209
+ } catch (error) {
210
+ this.db.exec('ROLLBACK')
211
+ throw error
212
+ }
213
+ }
214
+
215
+ _storedHash(scope) {
216
+ const hash = this.db.prepare(
217
+ 'SELECT projection_hash FROM ledger_meta WHERE scope = ?',
218
+ ).get(scope)?.projection_hash ?? null
219
+ return hash === '' ? null : hash
220
+ }
221
+
222
+ appendObservation(input) {
223
+ const hashes = this._appendBatch({ observations: [input] })
224
+ return hashes[input?.scope] ?? null
225
+ }
226
+
227
+ appendAssertion(input) {
228
+ const hashes = this._appendBatch({ assertions: [input] })
229
+ return hashes[input?.scope] ?? null
230
+ }
231
+
232
+ // Batch archive (one transaction) followed by exactly one projection per affected
233
+ // scope. This is the audit path: 158 individual rebuilds collapsed to one pass.
234
+ appendObservations(inputs) {
235
+ return this._appendBatch({ observations: inputs })
236
+ }
237
+
238
+ appendAssertions(inputs) {
239
+ return this._appendBatch({ assertions: inputs })
240
+ }
241
+
242
+ // Mixed batch used when a proposal run needs to open its project entity and
243
+ // append its accepted assertions under exactly one projection.
244
+ appendEvents({ observations = [], assertions = [] } = {}) {
245
+ if (!Array.isArray(observations) || !Array.isArray(assertions)) {
246
+ throw new TypeError('appendEvents requires observation and assertion arrays')
247
+ }
248
+ return this._appendBatch({ observations, assertions })
249
+ }
250
+
251
+ // Read-only archive lookup. Each call re-parses the canonical archive row into a
252
+ // frozen copy, so a caller mutating the returned object can never change what was
253
+ // archived. An optional `scope` turns a foreign record into the same `null` a
254
+ // missing record yields: callers cannot probe other scopes.
255
+ getObservation(eventKey, { scope } = {}) {
256
+ if (typeof eventKey !== 'string' || eventKey.trim() === '') {
257
+ throw new TypeError('getObservation requires a non-empty event key')
258
+ }
259
+ if (scope !== undefined && !nonEmptyString(scope)) {
260
+ throw new TypeError('getObservation scope must be a non-empty string when provided')
261
+ }
262
+ const row = this.db.prepare(
263
+ 'SELECT full_input_json, projection_state, projection_error FROM ledger_observations WHERE event_key = ?',
264
+ ).get(eventKey)
265
+ if (!row) return null
266
+ const archived = JSON.parse(row.full_input_json)
267
+ if (scope !== undefined && archived.scope !== scope) return null
268
+ return deepFreeze({
269
+ ...archived,
270
+ eventKey,
271
+ projectionState: row.projection_state,
272
+ projectionError: row.projection_error ?? null,
273
+ })
274
+ }
275
+
276
+ // Read-only assertion lookup, used by application replay to decide whether an
277
+ // accepted proposal really reached the archive. Like getObservation, a foreign
278
+ // record is indistinguishable from a missing one when a scope is supplied.
279
+ getAssertion(assertionId, { scope } = {}) {
280
+ if (typeof assertionId !== 'string' || assertionId.trim() === '') {
281
+ throw new TypeError('getAssertion requires a non-empty assertion id')
282
+ }
283
+ if (scope !== undefined && !nonEmptyString(scope)) {
284
+ throw new TypeError('getAssertion scope must be a non-empty string when provided')
285
+ }
286
+ const row = this.db.prepare(
287
+ 'SELECT full_input_json, projection_state, projection_error FROM ledger_assertions WHERE assertion_id = ?',
288
+ ).get(assertionId)
289
+ if (!row) return null
290
+ const archived = JSON.parse(row.full_input_json)
291
+ if (scope !== undefined && archived.scope !== scope) return null
292
+ return deepFreeze({
293
+ ...archived,
294
+ assertionId,
295
+ projectionState: row.projection_state,
296
+ projectionError: row.projection_error ?? null,
297
+ })
298
+ }
299
+
300
+ _insertObservation(input, fullInput) {
301
+ this.db.prepare(`
302
+ INSERT INTO ledger_observations (
303
+ event_key, scope, sid, seq, valid_at, recorded_at, op, outcome,
304
+ subject_entity_id, target_entity_id, value_json, generation_hints_json,
305
+ raw_ref, raw_payload_json, source_json, extractor_version,
306
+ projection_state, projection_error, full_input_json
307
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)
308
+ `).run(
309
+ input.eventKey, input.scope, input.sid, input.seq, input.validAt, input.recordedAt,
310
+ input.op, input.outcome, input.subjectEntityId, input.targetEntityId ?? null,
311
+ jsonColumn(input.value), jsonColumn(input.generationHints), input.rawRef ?? null,
312
+ jsonColumn(input.rawPayload), jsonColumn(input.source), input.extractorVersion ?? null,
313
+ fullInput,
314
+ )
315
+ }
316
+
317
+ _insertAssertion(input, fullInput) {
318
+ this.db.prepare(`
319
+ INSERT INTO ledger_assertions (
320
+ assertion_id, scope, subject_entity_id, sid, seq, op, target_assertion_id,
321
+ predicate, value_json, qualifiers_json, valid_at, recorded_at,
322
+ source_kind, source_authority, evidence_json, verification, reducer_version,
323
+ projection_state, projection_error, full_input_json
324
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)
325
+ `).run(
326
+ input.assertionId, input.scope, input.subjectEntityId, input.sid, input.seq,
327
+ input.op, input.targetAssertionId ?? null, input.predicate, jsonColumn(input.value),
328
+ jsonColumn(input.qualifiers), input.validAt, input.recordedAt, input.sourceKind,
329
+ input.sourceAuthority ?? null, jsonColumn(input.evidence), input.verification,
330
+ REDUCER_VERSION, fullInput,
331
+ )
332
+ }
333
+
334
+ _appendBatch({ observations = [], assertions = [] } = {}) {
335
+ if (!Array.isArray(observations) || !Array.isArray(assertions)) {
336
+ throw new TypeError('batch append requires arrays')
337
+ }
338
+ const scopes = new Set()
339
+ const newScopes = new Set()
340
+ const pendingObservations = []
341
+ const pendingAssertions = []
342
+ const seenObservations = new Map()
343
+ const seenAssertions = new Map()
344
+
345
+ for (const input of observations) {
346
+ if (typeof input?.eventKey !== 'string' || input.eventKey === '' ||
347
+ typeof input?.scope !== 'string' || input.scope === '') {
348
+ throw new TypeError('observation requires a non-empty eventKey and scope')
349
+ }
350
+ const fullInput = canonicalJson(input)
351
+ if (seenObservations.has(input.eventKey)) {
352
+ if (seenObservations.get(input.eventKey) !== fullInput) {
353
+ throw new Error(`eventKey appears twice with different input: ${input.eventKey}`)
354
+ }
355
+ continue
356
+ }
357
+ seenObservations.set(input.eventKey, fullInput)
358
+ const existing = this.db.prepare(
359
+ 'SELECT scope, full_input_json FROM ledger_observations WHERE event_key = ?',
360
+ ).get(input.eventKey)
361
+ if (existing) {
362
+ if (existing.full_input_json !== fullInput) {
363
+ throw new Error(`eventKey already archived with different input: ${input.eventKey}`)
364
+ }
365
+ scopes.add(existing.scope)
366
+ continue
367
+ }
368
+ pendingObservations.push({ input, fullInput })
369
+ scopes.add(input.scope)
370
+ newScopes.add(input.scope)
371
+ }
372
+
373
+ for (const input of assertions) {
374
+ const validation = validateAssertionInput(input)
375
+ if (!validation.valid) {
376
+ throw new TypeError(
377
+ `invalid Assertion input (${input?.assertionId ?? 'unknown'}): ${validation.errors.join(',')}`,
378
+ )
379
+ }
380
+ const fullInput = canonicalJson(input)
381
+ if (seenAssertions.has(input.assertionId)) {
382
+ if (seenAssertions.get(input.assertionId) !== fullInput) {
383
+ throw new Error(`assertionId appears twice with different input: ${input.assertionId}`)
384
+ }
385
+ continue
386
+ }
387
+ seenAssertions.set(input.assertionId, fullInput)
388
+ const existing = this.db.prepare(
389
+ 'SELECT scope, full_input_json FROM ledger_assertions WHERE assertion_id = ?',
390
+ ).get(input.assertionId)
391
+ if (existing) {
392
+ if (existing.full_input_json !== fullInput) {
393
+ throw new Error(`assertionId already archived with different input: ${input.assertionId}`)
394
+ }
395
+ scopes.add(existing.scope)
396
+ continue
397
+ }
398
+ pendingAssertions.push({ input, fullInput })
399
+ scopes.add(input.scope)
400
+ newScopes.add(input.scope)
401
+ }
402
+
403
+ if (pendingObservations.length > 0 || pendingAssertions.length > 0) {
404
+ this._transaction(() => {
405
+ for (const entry of pendingObservations) {
406
+ this._insertObservation(entry.input, entry.fullInput)
407
+ }
408
+ for (const entry of pendingAssertions) {
409
+ this._insertAssertion(entry.input, entry.fullInput)
410
+ }
411
+ })
412
+ }
413
+
414
+ const hashes = {}
415
+ for (const scope of scopes) hashes[scope] = this._storedHash(scope)
416
+ for (const scope of newScopes) hashes[scope] = this.rebuild(scope)
417
+ return hashes
418
+ }
419
+
420
+ _archive(scope) {
421
+ const observations = this.db.prepare(`
422
+ SELECT full_input_json FROM ledger_observations WHERE scope = ?
423
+ ORDER BY valid_at, sid, seq, event_key
424
+ `).all(scope).map((row) => JSON.parse(row.full_input_json))
425
+ const assertions = this.db.prepare(`
426
+ SELECT full_input_json FROM ledger_assertions WHERE scope = ?
427
+ ORDER BY valid_at, sid, seq, assertion_id
428
+ `).all(scope).map((row) => JSON.parse(row.full_input_json))
429
+ return { observations, assertions }
430
+ }
431
+
432
+ _clearDerived(scope) {
433
+ this.db.prepare('DELETE FROM ledger_fact_versions WHERE scope = ?').run(scope)
434
+ this.db.prepare('DELETE FROM ledger_observation_placements WHERE scope = ?').run(scope)
435
+ this.db.prepare('DELETE FROM ledger_assertion_placements WHERE scope = ?').run(scope)
436
+ this.db.prepare('DELETE FROM ledger_entities WHERE scope = ?').run(scope)
437
+ this.db.prepare('DELETE FROM ledger_meta WHERE scope = ?').run(scope)
438
+ }
439
+
440
+ _projectionFailed(scope, error) {
441
+ const text = String(error?.stack ?? error?.message ?? error)
442
+ this._transaction(() => {
443
+ const pendingObservation = this.db.prepare(`
444
+ SELECT event_key FROM ledger_observations
445
+ WHERE scope = ? AND projection_state = 'pending'
446
+ ORDER BY recorded_at, event_key LIMIT 1
447
+ `).get(scope)
448
+ const pendingAssertion = this.db.prepare(`
449
+ SELECT assertion_id FROM ledger_assertions
450
+ WHERE scope = ? AND projection_state = 'pending'
451
+ ORDER BY recorded_at, assertion_id LIMIT 1
452
+ `).get(scope)
453
+ const latestObservation = this.db.prepare(`
454
+ SELECT event_key FROM ledger_observations
455
+ WHERE scope = ? ORDER BY recorded_at DESC, event_key DESC LIMIT 1
456
+ `).get(scope)
457
+ const latestAssertion = this.db.prepare(`
458
+ SELECT assertion_id FROM ledger_assertions
459
+ WHERE scope = ? ORDER BY recorded_at DESC, assertion_id DESC LIMIT 1
460
+ `).get(scope)
461
+ const failingRecord = pendingObservation?.event_key ?? pendingAssertion?.assertion_id ??
462
+ latestObservation?.event_key ?? latestAssertion?.assertion_id ?? null
463
+
464
+ // Only unprojected input becomes dead. Every previously derived row, fact
465
+ // version, and the last good projection hash stay intact so a single poison
466
+ // record can never blank an otherwise valid scope.
467
+ this.db.prepare(`
468
+ UPDATE ledger_observations SET projection_state = 'dead', projection_error = ?
469
+ WHERE scope = ? AND projection_state = 'pending'
470
+ `).run(text, scope)
471
+ this.db.prepare(`
472
+ UPDATE ledger_assertions SET projection_state = 'dead', projection_error = ?
473
+ WHERE scope = ? AND projection_state = 'pending'
474
+ `).run(text, scope)
475
+
476
+ this.db.prepare(`
477
+ INSERT INTO ledger_meta (
478
+ scope, reducer_version, projection_hash, status, projection_error, failing_record
479
+ ) VALUES (?, ?, ?, 'failed', ?, ?)
480
+ ON CONFLICT(scope) DO UPDATE SET
481
+ reducer_version = excluded.reducer_version,
482
+ status = 'failed',
483
+ projection_error = excluded.projection_error,
484
+ failing_record = excluded.failing_record
485
+ `).run(scope, REDUCER_VERSION, this._storedHash(scope) ?? '', text, failingRecord)
486
+ })
487
+ return null
488
+ }
489
+
490
+ projectionStatus(scope) {
491
+ const row = this.db.prepare(`
492
+ SELECT reducer_version, projection_hash, status, projection_error, failing_record
493
+ FROM ledger_meta WHERE scope = ?
494
+ `).get(scope)
495
+ if (!row) {
496
+ return {
497
+ status: 'empty', reducerVersion: null, projectionHash: null,
498
+ reason: null, failingRecord: null,
499
+ }
500
+ }
501
+ return {
502
+ status: row.status ?? 'applied',
503
+ reducerVersion: row.reducer_version,
504
+ projectionHash: row.projection_hash === '' ? null : (row.projection_hash ?? null),
505
+ reason: row.projection_error ?? null,
506
+ failingRecord: row.failing_record ?? null,
507
+ }
508
+ }
509
+
510
+ rebuild(scope) {
511
+ const { observations, assertions } = this._archive(scope)
512
+ const eventCount = observations.length + assertions.length
513
+ if (eventCount > this.maxProjectionEvents) {
514
+ return this._projectionFailed(scope, new Error(
515
+ `projection-event-limit-exceeded: ${eventCount} > ${this.maxProjectionEvents}`,
516
+ ))
517
+ }
518
+ const started = process.hrtime.bigint()
519
+ let projection
520
+ let hash
521
+ let prepared
522
+ try {
523
+ // Reduction and canonical serialization are pure and happen before BEGIN IMMEDIATE.
524
+ projection = this.reducer({ observations, assertions, reducerVersion: REDUCER_VERSION })
525
+ hash = projectionHash(projection)
526
+ const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6
527
+ if (elapsedMs > this.maxProjectionMs) {
528
+ throw new Error(
529
+ `projection-time-limit-exceeded: ${elapsedMs.toFixed(0)}ms > ${this.maxProjectionMs}ms`,
530
+ )
531
+ }
532
+ prepared = {
533
+ entities: projection.entities.map((entity) => [
534
+ entity.scope, entity.entityId, entity.generation, entity.presence ? 1 : 0,
535
+ jsonColumn(entity.openedBy), jsonColumn(entity.closedBy),
536
+ entity.lastValidAt, entity.lastRecordedAt,
537
+ ]),
538
+ observationPlacements: projection.observationPlacements.map((placement) => [
539
+ placement.eventKey, placement.scope, placement.entityId, placement.role,
540
+ placement.generation, placement.unplaced ? 1 : 0, placement.reason,
541
+ canonicalJson(placement.causalOrder),
542
+ ]),
543
+ assertionPlacements: projection.assertionPlacements.map((placement) => [
544
+ placement.assertionId, placement.scope, placement.entityId, placement.generation,
545
+ placement.unplaced ? 1 : 0, placement.reason, canonicalJson(placement.causalOrder),
546
+ ]),
547
+ facts: projection.factVersions.map((fact) => [
548
+ fact.factKey, fact.scope, fact.subjectEntityId, fact.subjectDisplay ?? null,
549
+ fact.generation, fact.predicate, jsonColumn(fact.value), jsonColumn(fact.qualifiers),
550
+ fact.assertionId, jsonColumn(fact.evidence), canonicalJson(fact.causalOrder),
551
+ fact.validFrom, fact.validTo, fact.recordedAt, fact.retiredAt, fact.status,
552
+ fact.verification ?? null, jsonColumn(fact.conflict), fact.op ?? null,
553
+ fact.targetAssertionId ?? null, fact.sid ?? null, fact.seq ?? null,
554
+ fact.reducerVersion ?? null, fact.sourceKind ?? null, fact.sourceAuthority ?? null,
555
+ canonicalJson(fact),
556
+ ]),
557
+ }
558
+ } catch (error) {
559
+ return this._projectionFailed(scope, error)
560
+ }
561
+
562
+ return this._transaction(() => {
563
+ this._clearDerived(scope)
564
+ const insertEntity = this.db.prepare(`
565
+ INSERT INTO ledger_entities (
566
+ scope, entity_id, generation, presence, opened_by_json, closed_by_json,
567
+ last_valid_at, last_recorded_at
568
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
569
+ `)
570
+ for (const values of prepared.entities) insertEntity.run(...values)
571
+
572
+ const insertObservationPlacement = this.db.prepare(`
573
+ INSERT INTO ledger_observation_placements (
574
+ event_key, scope, entity_id, role, generation, unplaced, reason, causal_order
575
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
576
+ `)
577
+ for (const values of prepared.observationPlacements) insertObservationPlacement.run(...values)
578
+
579
+ const insertAssertionPlacement = this.db.prepare(`
580
+ INSERT INTO ledger_assertion_placements (
581
+ assertion_id, scope, entity_id, generation, unplaced, reason, causal_order
582
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
583
+ `)
584
+ for (const values of prepared.assertionPlacements) insertAssertionPlacement.run(...values)
585
+
586
+ const insertFact = this.db.prepare(`
587
+ INSERT INTO ledger_fact_versions (
588
+ fact_key, scope, subject_entity_id, subject_display, generation, predicate,
589
+ value_json, qualifiers_json, assertion_id, evidence_json, causal_order,
590
+ valid_from, valid_to, recorded_at, retired_at, status, verification,
591
+ conflict_json, op, target_assertion_id, sid, seq, reducer_version,
592
+ source_kind, source_authority, fact_json
593
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
594
+ `)
595
+ for (const values of prepared.facts) insertFact.run(...values)
596
+
597
+ this.db.prepare(`
598
+ INSERT INTO ledger_meta (
599
+ scope, reducer_version, projection_hash, status, projection_error, failing_record
600
+ ) VALUES (?, ?, ?, 'applied', NULL, NULL)
601
+ ON CONFLICT(scope) DO UPDATE SET
602
+ reducer_version = excluded.reducer_version,
603
+ projection_hash = excluded.projection_hash,
604
+ status = 'applied',
605
+ projection_error = NULL,
606
+ failing_record = NULL
607
+ `).run(scope, REDUCER_VERSION, hash)
608
+ this.db.prepare(`
609
+ UPDATE ledger_observations SET projection_state = 'applied', projection_error = NULL WHERE scope = ?
610
+ `).run(scope)
611
+ this.db.prepare(`
612
+ UPDATE ledger_assertions SET projection_state = 'applied', projection_error = NULL WHERE scope = ?
613
+ `).run(scope)
614
+ const markDead = this.db.prepare(`
615
+ UPDATE ledger_assertions SET projection_state = 'dead', projection_error = ? WHERE assertion_id = ?
616
+ `)
617
+ for (const entry of projection.dead) markDead.run(entry.reason, entry.assertionId)
618
+ return hash
619
+ })
620
+ }
621
+
622
+ // Injection is re-gated on every read, so revoking a route immediately stops an
623
+ // already accepted LLM fact from rendering. A missing route table (the journal was
624
+ // never opened) is the default-closed state, not an error. The stored counters are
625
+ // re-validated against their evaluation evidence; a hand-edited row cannot open.
626
+ _llmRouteEnabled(fact) {
627
+ if (fact.sourceKind !== 'llm') return true
628
+ const evidence = fact.evidence
629
+ const model = evidence?.model
630
+ const modelDigest = evidence?.modelDigest
631
+ const schemaVersion = evidence?.schemaVersion
632
+ if (!nonEmptyString(model) || !nonEmptyString(modelDigest) ||
633
+ !nonEmptyString(schemaVersion) ||
634
+ !nonEmptyString(fact.predicate) || !nonEmptyString(fact.scope)) {
635
+ return false
636
+ }
637
+ let row
638
+ try {
639
+ row = this.db.prepare(`
640
+ SELECT sample_count, precision, safety_failures, evidence_json, model_digest
641
+ FROM ledger_proposal_routes
642
+ WHERE scope = ? AND model = ? AND model_digest = ? AND predicate = ?
643
+ AND schema_version = ?
644
+ `).get(fact.scope, model, modelDigest, fact.predicate, schemaVersion)
645
+ } catch {
646
+ return false
647
+ }
648
+ if (!row || row.model_digest !== modelDigest) return false
649
+ let routeEvidence
650
+ try {
651
+ routeEvidence = row.evidence_json === null ? null : JSON.parse(row.evidence_json)
652
+ } catch {
653
+ return false
654
+ }
655
+ return routeRowValid({
656
+ sampleCount: row.sample_count,
657
+ precision: row.precision,
658
+ recall: routeEvidence?.recall,
659
+ safetyFailures: row.safety_failures,
660
+ evidence: routeEvidence,
661
+ modelDigest: row.model_digest,
662
+ })
663
+ }
664
+
665
+ // Read-time proposal/span resolution. An LLM fact renders only when:
666
+ // - its proposalId resolves to the same scope as an accepted proposal whose
667
+ // payload names this assertion, or to a candidate promoted to this assertion;
668
+ // - the run identity matches the evidence model/digest/schema version;
669
+ // - the quoted span still resolves exactly against the archived observation's
670
+ // rawPayload.text.
671
+ // A missing proposal table, row, run, or span suppresses the fact. Hand-written
672
+ // `sourceKind:'llm'` facts therefore remain audit-only even with an open route.
673
+ _llmProposalResolved(fact) {
674
+ if (fact.sourceKind !== 'llm') return true
675
+ const evidence = fact.evidence
676
+ if (!evidence || typeof evidence !== 'object') return false
677
+ const proposalId = evidence.proposalId
678
+ const eventKey = evidence.eventKey
679
+ const quote = evidence.quote
680
+ if (!nonEmptyString(proposalId) || !nonEmptyString(eventKey) ||
681
+ !nonEmptyString(quote) || !nonEmptyString(fact.assertionId) ||
682
+ !nonEmptyString(fact.scope)) {
683
+ return false
684
+ }
685
+ if (!Number.isInteger(evidence.start) || !Number.isInteger(evidence.end) ||
686
+ evidence.start < 0 || evidence.end <= evidence.start) {
687
+ return false
688
+ }
689
+ let proposal
690
+ try {
691
+ proposal = this.db.prepare(`
692
+ SELECT p.verdict, p.promoted_assertion_id, p.payload_json,
693
+ r.status AS run_status, r.model AS run_model,
694
+ r.model_digest AS run_digest, r.schema_version AS run_schema
695
+ FROM ledger_proposals AS p
696
+ JOIN ledger_proposal_runs AS r ON r.run_id = p.run_id
697
+ WHERE p.proposal_id = ? AND p.scope = ? AND r.scope = ?
698
+ `).get(proposalId, fact.scope, fact.scope)
699
+ } catch {
700
+ return false
701
+ }
702
+ if (!proposal || proposal.run_status !== 'succeeded' ||
703
+ proposal.run_model !== evidence.model ||
704
+ proposal.run_digest !== evidence.modelDigest ||
705
+ proposal.run_schema !== evidence.schemaVersion) {
706
+ return false
707
+ }
708
+ if (proposal.verdict === 'accepted') {
709
+ let payload
710
+ try {
711
+ payload = proposal.payload_json === null ? null : JSON.parse(proposal.payload_json)
712
+ } catch {
713
+ return false
714
+ }
715
+ if (!payload || payload.assertionId !== fact.assertionId) return false
716
+ } else if (proposal.verdict === 'candidate') {
717
+ if (proposal.promoted_assertion_id !== fact.assertionId) return false
718
+ } else {
719
+ return false
720
+ }
721
+ let observation
722
+ try {
723
+ observation = this.db.prepare(
724
+ 'SELECT full_input_json FROM ledger_observations WHERE event_key = ?',
725
+ ).get(eventKey)
726
+ } catch {
727
+ return false
728
+ }
729
+ if (!observation) return false
730
+ const archived = JSON.parse(observation.full_input_json)
731
+ if (archived.scope !== fact.scope || archived.op !== 'user_message' ||
732
+ archived.source?.kind !== 'user') {
733
+ return false
734
+ }
735
+ const text = archived.rawPayload?.text
736
+ if (typeof text !== 'string' || text === '') return false
737
+ return text.slice(evidence.start, evidence.end) === quote
738
+ }
739
+
740
+ currentFacts(scope, { injectableOnly = false } = {}) {
741
+ const rows = this.db.prepare(`
742
+ SELECT f.fact_json
743
+ FROM ledger_fact_versions AS f
744
+ JOIN ledger_entities AS e
745
+ ON e.scope = f.scope AND e.entity_id = f.subject_entity_id AND e.generation = f.generation
746
+ WHERE f.scope = ? AND f.valid_to IS NULL AND f.retired_at IS NULL
747
+ AND f.status IN ('active', 'conflicted')
748
+ ORDER BY f.fact_key, f.assertion_id
749
+ `).all(scope).map(decodeRow)
750
+ if (!injectableOnly) return rows
751
+ // `llm` is no longer suppressed by source kind: the verification gate admits
752
+ // only quote-derived evidence, the route gate re-checks the model build's
753
+ // current capability, and every LLM fact must resolve to its journaled proposal
754
+ // and archived span at read time.
755
+ return rows.filter((fact) => {
756
+ const predicate = registryEntry(fact.predicate)
757
+ return fact.status === 'active' && fact.conflict == null &&
758
+ predicate?.injectable === true &&
759
+ fact.sourceKind !== 'legacy' &&
760
+ INJECTABLE_VERIFICATIONS.has(fact.verification) &&
761
+ this._llmRouteEnabled(fact) &&
762
+ this._llmProposalResolved(fact)
763
+ })
764
+ }
765
+
766
+ factsAt(scope, query) {
767
+ if (!query || !Number.isFinite(query.validAt) || !Number.isFinite(query.recordedAt)) {
768
+ throw new TypeError('factsAt requires finite validAt and recordedAt')
769
+ }
770
+ const factVersions = this.db.prepare(`
771
+ SELECT fact_json FROM ledger_fact_versions WHERE scope = ?
772
+ ORDER BY valid_from, recorded_at, fact_key, assertion_id
773
+ `).all(scope).map(decodeRow)
774
+ return selectFactsAt({ factVersions }, { validAt: query.validAt, recordedAt: query.recordedAt })
775
+ }
776
+
777
+ renderCurrent(scope, budget) {
778
+ return renderFacts(this.currentFacts(scope, { injectableOnly: true }), budget)
779
+ }
780
+
781
+ _withProjectionState(scope, state) {
782
+ const observations = this.db.prepare(`
783
+ SELECT full_input_json, projection_state, projection_error
784
+ FROM ledger_observations WHERE scope = ? AND projection_state = ?
785
+ `).all(scope, state)
786
+ const assertions = this.db.prepare(`
787
+ SELECT full_input_json, projection_state, projection_error
788
+ FROM ledger_assertions WHERE scope = ? AND projection_state = ?
789
+ `).all(scope, state)
790
+ return [...observations, ...assertions].map((row) => ({
791
+ ...JSON.parse(row.full_input_json),
792
+ projectionState: row.projection_state,
793
+ projectionError: row.projection_error,
794
+ reason: row.projection_error,
795
+ })).sort((a, b) => {
796
+ const left = a.eventKey ?? a.assertionId
797
+ const right = b.eventKey ?? b.assertionId
798
+ return left < right ? -1 : left > right ? 1 : 0
799
+ })
800
+ }
801
+
802
+ pending(scope) {
803
+ return this._withProjectionState(scope, 'pending')
804
+ }
805
+
806
+ dead(scope) {
807
+ return this._withProjectionState(scope, 'dead')
808
+ }
809
+
810
+ retryDead(eventKey) {
811
+ const observation = this.db.prepare(
812
+ 'SELECT scope, projection_state FROM ledger_observations WHERE event_key = ?',
813
+ ).get(eventKey)
814
+ if (observation) {
815
+ if (observation.projection_state !== 'dead') return this._storedHash(observation.scope)
816
+ this.db.prepare(`
817
+ UPDATE ledger_observations SET projection_state = 'pending', projection_error = NULL WHERE event_key = ?
818
+ `).run(eventKey)
819
+ return this.rebuild(observation.scope)
820
+ }
821
+
822
+ const assertion = this.db.prepare(
823
+ 'SELECT scope, projection_state FROM ledger_assertions WHERE assertion_id = ?',
824
+ ).get(eventKey)
825
+ if (!assertion) throw new Error(`unknown archived event or assertion: ${eventKey}`)
826
+ if (assertion.projection_state !== 'dead') return this._storedHash(assertion.scope)
827
+ this.db.prepare(`
828
+ UPDATE ledger_assertions SET projection_state = 'pending', projection_error = NULL WHERE assertion_id = ?
829
+ `).run(eventKey)
830
+ return this.rebuild(assertion.scope)
831
+ }
832
+
833
+ close() {
834
+ this.db.close()
835
+ }
836
+ }