Parent:#240
Supersedes:#243 (the literal-port path; PR #253 closed in favor of this)
Context
#243 specified a faithful port of @relayburn/ledger — JSONL + hash sidecars + SQLite archive — so a Rust holder and a TS holder could interoperate against the same on-disk state. With the 2.0 cutover making Rust the source of truth, that constraint goes away, and several pieces of the TS design are workarounds for "I needed fast dedup but didn't have a database handy" rather than first-principles choices.
This issue replaces #243 with a Rust-idiomatic redesign.
Storage model
Two SQLite databases under RELAYBURN_HOME. No JSONL files, no hash sidecars, no file-lock module.
~/.relayburn/burn.sqlite — events, stamps, sessions, archive state. Hot DB; analytic queries run here.~/.relayburn/content.sqlite — content blobs + FTS5 index. Big and cold; pruneable on retention without touching the events DB.
Both bundled (rusqlitefeatures = ["bundled"]) so the burn binary stays a single static artifact.
Two classes of table inside burn.sqlite
The DB holds two kinds of data, and burn state rebuild treats them differently:
| Class | Tables | Source | burn state rebuild behavior |
|---|
| Derivable | turns, compactions, relationships, tool_result_events, user_turns, sessions | Upstream session files (Claude Code / codex / opencode session stores) | Drop and re-ingest from upstream |
| First-party | stamps, archive_state | Generated by burn stamp / harness wrapper / ingest cursors | Preserved across rebuild |
Backup story collapses to: cp burn.sqlite content.sqlite /your/backup/. One transaction model, one connection model, one fsync story end to end.
Why two SQLite DBs (not one, not per-session JSONL caches)
- Lifecycle asymmetry — events are kept ~forever and queried analytically; content is TTL-pruneable and read on demand. One DB means analytic queries pay VACUUM tax every time we prune content. Splitting isolates that.
- Page cache isolation — content reads don't evict event-index pages and vice versa.
- Independent WAL / checkpoint cadence — bulk content writes don't pause event writes.
ATTACH DATABASE gives cross-DB joins when we genuinely need them (e.g. fetching a turn's tool-result body) without coupling the schemas.- Mountable on cheaper/bigger storage via
RELAYBURN_CONTENT_PATH.
The cost is two schemas / two migration paths and the absence of a cross-DB transaction (a crash between event commit and content commit can leave an event row pointing at no content). Acceptable: content is derivable from upstream files, so a burn state rebuild reconciles drift.
Why stamps live inside burn.sqlite, not in a separate file
Earlier drafts proposed stamps.jsonl for plaintext durability. Dropped because:
- WAL is more durable than
O_APPEND, not less — atomic commits, no torn-write windows. JSONL gives "bytes there or not" but a partial line on hard crash is malformed and the parser has to skip it. - Stamps aren't routinely human-inspected — they're metadata the harness writes automatically. The "cat | jq | git add" ergonomic argument was real but rarely-used;
burn stamps export covers the niche case. - One DB beats three files: one backup target, one transaction model, one fsync story. Reclassify can apply a stamp + write a turn atomically when needed.
- Eliminates the file-lock module entirely from the new design — SQLite WAL handles all serialization.
The "burn.sqlite is rebuildable" framing becomes "burn.sqlite holds everything; burn state rebuild only nukes the derivable tables." Stamps and ingest cursors are protected by code, not by living in a separate file.
Dedup: two layers, both shipped from day one
- Primary —
UNIQUE(source, session_id, message_id). "Already ingested this exact turn." - Secondary —
content_fingerprint TEXT column with a non-unique index. Fingerprint = sha256(ts | model | input+output | cacheRead | cacheCreate5m+1h | firstToolArgsHash[..4])[..16]. Catches "same logical turn under a different messageId" — happens when upstream agents reformat session logs, when sessions fork/compact and re-emit turns under new IDs, when we observe the same turn under a different source label, or when a parser bugfix changes messageId derivation. Without this layer, every such event double-counts in burn summary.
The TS implementation gates layer 2 by a 10k-entry rolling-window sidecar file because indexing JSONL isn't an option there. With SQLite as cache we drop the rolling window — the column is a regular indexed column, ~16 bytes per turn, queryable.
Full-text search on content (FTS5, day one)
content.sqlite ships with an FTS5 virtual table over content.body from 2.0. Adding FTS5 later forces every existing user through a multi-minute one-time index rebuild over historical content, so we pay the cost up front:
- Storage: ~2–3× the content table size for the inverted index. Acceptable given content is the prune-on-retention table.
- Writes: tokenization on every insert. Negligible at session-ingest write rates.
- Bundled: no new dependency — FTS5 is part of the same
rusqlite features = ["bundled"] build.
Enables burn search "out of memory" + future MCP / SDK queries like "find sessions where a tool returned 'permission denied'", and unlocks BM25-ranked + snippet output.
Append-only enforcement: not needed
Earlier drafts proposed SQLite triggers blocking UPDATE/DELETE on event tables. Dropped: the events tables are a cache, not source of truth. UPDATE / DELETE / reclassify in place is fine — the correctness invariant is "after burn state rebuild, the cache reproduces from upstream files." Stamps are protected by API surface (no update_stamp function exists) + code review, the same way the events tables get protected from accidental writes outside the writer.
What goes away vs the TS design
ledger.jsonl — gone. Events live as cache rows in burn.sqlite (rebuildable from upstream); first-party stamps are a regular table in the same DB.ledger.idx / ledger.content.idx — id-hash and content-fingerprint sidecars. Replaced by indexes on the events tables.archive.sqlite as a separate "derived, rebuildable" file — folded into burn.sqlite.content/<session_id>.jsonl per-session files — replaced by content.sqlite.- The
withLock("ledger") / withLock("ledger-index") / withLock("archive") / withLock("content.<id>") file-lock dance — SQLite WAL handles all writer serialization. The file-lock module is removed from the new design entirely.
What stays
RELAYBURN_HOME layout; RELAYBURN_SQLITE_PATH + a new RELAYBURN_CONTENT_PATH override.- Bundled SQLite (with FTS5) — single static binary.
What's new
burn export ledger --format jsonl — emits the event stream as JSONL on demand for jq-style debugging and audit-trail consumers.burn stamps export — emits the stamps table as JSONL for backup / version control.burn search <query> — FTS5 over content bodies, returns ranked hits with snippets.burn state rebuild — drops the derivable tables in burn.sqlite and the entire content.sqlite, then re-ingests from upstream session files. Stamps and ingest cursors are preserved.
Schema sketch
-- burn.sqliteCREATETABLEturns (
source TEXTNOT NULL,
session_id TEXTNOT NULL,
message_id TEXTNOT NULL,
ts TEXTNOT NULL,
-- ... typed columns once relayburn-reader (#242) lands ...
record_json TEXTNOT NULL, -- raw record blob until typed
content_fingerprint TEXTNOT NULL, -- layer-2 dedupPRIMARY KEY (source, session_id, message_id)
) STRICT;
CREATEINDEXidx_turns_content_fingerprintON turns(content_fingerprint);
CREATETABLEstamps (
-- first-party data; preserved across `burn state rebuild`
source TEXTNOT NULL,
session_id TEXT,
ts TEXTNOT NULL,
selector_json TEXTNOT NULL,
enrichment_json TEXTNOT NULL,
written_at TEXTNOT NULL, -- monotonic write order for the application layerPRIMARY KEY (source, session_id, ts, written_at)
);
CREATETABLEcompactions (...);
CREATETABLErelationships (...);
CREATETABLEtool_result_events (...);
CREATETABLEuser_turns (...);
CREATETABLEsessions (...); -- materialized read modelCREATETABLEarchive_state (
-- first-party data; preserved across `burn state rebuild`
id INTEGERPRIMARY KEYCHECK (id =1),
schema_version INTEGERNOT NULL,
upstream_cursors_json TEXTNOT NULL, -- per-source ingest cursors
last_built_at TEXT,
last_rebuild_at TEXT
);
-- content.sqliteCREATETABLEcontent (
session_id TEXTNOT NULL,
message_id TEXTNOT NULL,
content_hash TEXTNOT NULL,
body BLOB NOT NULL,
byte_length INTEGERNOT NULL,
created_at TEXTNOT NULL,
PRIMARY KEY (session_id, message_id, content_hash)
) STRICT;
CREATEINDEXidx_content_sessionON content(session_id);
-- FTS5 inverted index over content.body. `content=` + `content_rowid=`-- bind it as an external-content table so we don't double-store the body;-- contentless wouldn't let us return snippets.
CREATE VIRTUAL TABLE content_fts USING fts5(
body,
content='content',
content_rowid='rowid',
tokenize='porter unicode61'
);
-- Triggers keep the FTS index in sync with the content table.CREATETRIGGERcontent_fts_ai AFTER INSERT ON content BEGININSERT INTO content_fts(rowid, body) VALUES (new.rowid, new.body);
END;
CREATETRIGGERcontent_fts_ad AFTER DELETEON content BEGININSERT INTO content_fts(content_fts, rowid, body) VALUES('delete', old.rowid, old.body);
END;Migration
No migration. 2.0 users start fresh. TS-built archive.sqlite / ledger.jsonl / content/*.jsonl from the 1.x line are not migrated; the RELAYBURN_HOME layout under 2.0 is incompatible by design. Document this clearly in the 2.0 release notes.
Open questions
- Stamps pinning policy: what happens when a user wants to retain a session's events past upstream cleanup (Claude Code rotates session files, user
rms a project)? Out of scope here, but flag for a follow-up: do we add a burn export ledger --pin <sessionId> that snapshots derived rows back into a plaintext archive, or rely on burn export ledger --format jsonl for full-DB backup?
Files this replaces (TS source-of-truth, until cutover)
packages/ledger/src/adapters/file-adapter.ts — JSONL append looppackages/ledger/src/adapters/sqlite-adapter.ts — useful reference; the new design is closer to this than to file-adapterpackages/ledger/src/index-sidecar.ts — dedup sidecars (gone, hashing logic moves into the writer)packages/ledger/src/archive.ts — derived archive (folded into burn.sqlite)packages/ledger/src/content.ts — per-session JSONL content sidecar (replaced by content.sqlite)packages/ledger/src/lock.ts + packages/ledger/src/adapters/file-lock.ts — removed entirely; nothing in the new design needs itpackages/ledger/src/schema.ts — LedgerLine types (no longer needed; events live as SQL columns)packages/ledger/src/reclassify.ts — becomes a normal UPDATE on the cache, no special handling
Acceptance
Parent:#240
Supersedes:#243 (the literal-port path; PR #253 closed in favor of this)
Context
#243 specified a faithful port of
@relayburn/ledger— JSONL + hash sidecars + SQLite archive — so a Rust holder and a TS holder could interoperate against the same on-disk state. With the 2.0 cutover making Rust the source of truth, that constraint goes away, and several pieces of the TS design are workarounds for "I needed fast dedup but didn't have a database handy" rather than first-principles choices.This issue replaces #243 with a Rust-idiomatic redesign.
Storage model
Two SQLite databases under
RELAYBURN_HOME. No JSONL files, no hash sidecars, no file-lock module.~/.relayburn/burn.sqlite— events, stamps, sessions, archive state. Hot DB; analytic queries run here.~/.relayburn/content.sqlite— content blobs + FTS5 index. Big and cold; pruneable on retention without touching the events DB.Both bundled (
rusqlitefeatures = ["bundled"]) so theburnbinary stays a single static artifact.Two classes of table inside
burn.sqliteThe DB holds two kinds of data, and
burn state rebuildtreats them differently:burn state rebuildbehaviorturns,compactions,relationships,tool_result_events,user_turns,sessionsstamps,archive_stateburn stamp/ harness wrapper / ingest cursorsBackup story collapses to:
cp burn.sqlite content.sqlite /your/backup/. One transaction model, one connection model, one fsync story end to end.Why two SQLite DBs (not one, not per-session JSONL caches)
ATTACH DATABASEgives cross-DB joins when we genuinely need them (e.g. fetching a turn's tool-result body) without coupling the schemas.RELAYBURN_CONTENT_PATH.The cost is two schemas / two migration paths and the absence of a cross-DB transaction (a crash between event commit and content commit can leave an event row pointing at no content). Acceptable: content is derivable from upstream files, so a
burn state rebuildreconciles drift.Why stamps live inside
burn.sqlite, not in a separate fileEarlier drafts proposed
stamps.jsonlfor plaintext durability. Dropped because:O_APPEND, not less — atomic commits, no torn-write windows. JSONL gives "bytes there or not" but a partial line on hard crash is malformed and the parser has to skip it.burn stamps exportcovers the niche case.The "burn.sqlite is rebuildable" framing becomes "burn.sqlite holds everything;
burn state rebuildonly nukes the derivable tables." Stamps and ingest cursors are protected by code, not by living in a separate file.Dedup: two layers, both shipped from day one
UNIQUE(source, session_id, message_id). "Already ingested this exact turn."content_fingerprint TEXTcolumn with a non-unique index. Fingerprint =sha256(ts | model | input+output | cacheRead | cacheCreate5m+1h | firstToolArgsHash[..4])[..16]. Catches "same logical turn under a different messageId" — happens when upstream agents reformat session logs, when sessions fork/compact and re-emit turns under new IDs, when we observe the same turn under a different source label, or when a parser bugfix changes messageId derivation. Without this layer, every such event double-counts inburn summary.The TS implementation gates layer 2 by a 10k-entry rolling-window sidecar file because indexing JSONL isn't an option there. With SQLite as cache we drop the rolling window — the column is a regular indexed column, ~16 bytes per turn, queryable.
Full-text search on content (FTS5, day one)
content.sqliteships with an FTS5 virtual table overcontent.bodyfrom 2.0. Adding FTS5 later forces every existing user through a multi-minute one-time index rebuild over historical content, so we pay the cost up front:rusqlite features = ["bundled"]build.Enables
burn search "out of memory"+ future MCP / SDK queries like "find sessions where a tool returned 'permission denied'", and unlocks BM25-ranked + snippet output.Append-only enforcement: not needed
Earlier drafts proposed SQLite triggers blocking UPDATE/DELETE on event tables. Dropped: the events tables are a cache, not source of truth. UPDATE / DELETE / reclassify in place is fine — the correctness invariant is "after
burn state rebuild, the cache reproduces from upstream files." Stamps are protected by API surface (noupdate_stampfunction exists) + code review, the same way the events tables get protected from accidental writes outside the writer.What goes away vs the TS design
ledger.jsonl— gone. Events live as cache rows inburn.sqlite(rebuildable from upstream); first-party stamps are a regular table in the same DB.ledger.idx/ledger.content.idx— id-hash and content-fingerprint sidecars. Replaced by indexes on the events tables.archive.sqliteas a separate "derived, rebuildable" file — folded intoburn.sqlite.content/<session_id>.jsonlper-session files — replaced bycontent.sqlite.withLock("ledger")/withLock("ledger-index")/withLock("archive")/withLock("content.<id>")file-lock dance — SQLite WAL handles all writer serialization. The file-lock module is removed from the new design entirely.What stays
RELAYBURN_HOMElayout;RELAYBURN_SQLITE_PATH+ a newRELAYBURN_CONTENT_PATHoverride.What's new
burn export ledger --format jsonl— emits the event stream as JSONL on demand forjq-style debugging and audit-trail consumers.burn stamps export— emits thestampstable as JSONL for backup / version control.burn search <query>— FTS5 over content bodies, returns ranked hits with snippets.burn state rebuild— drops the derivable tables inburn.sqliteand the entirecontent.sqlite, then re-ingests from upstream session files. Stamps and ingest cursors are preserved.Schema sketch
Migration
No migration. 2.0 users start fresh. TS-built
archive.sqlite/ledger.jsonl/content/*.jsonlfrom the 1.x line are not migrated; theRELAYBURN_HOMElayout under 2.0 is incompatible by design. Document this clearly in the 2.0 release notes.Open questions
rms a project)? Out of scope here, but flag for a follow-up: do we add aburn export ledger --pin <sessionId>that snapshots derived rows back into a plaintext archive, or rely onburn export ledger --format jsonlfor full-DB backup?Files this replaces (TS source-of-truth, until cutover)
packages/ledger/src/adapters/file-adapter.ts— JSONL append looppackages/ledger/src/adapters/sqlite-adapter.ts— useful reference; the new design is closer to this than to file-adapterpackages/ledger/src/index-sidecar.ts— dedup sidecars (gone, hashing logic moves into the writer)packages/ledger/src/archive.ts— derived archive (folded intoburn.sqlite)packages/ledger/src/content.ts— per-session JSONL content sidecar (replaced bycontent.sqlite)packages/ledger/src/lock.ts+packages/ledger/src/adapters/file-lock.ts— removed entirely; nothing in the new design needs itpackages/ledger/src/schema.ts—LedgerLinetypes (no longer needed; events live as SQL columns)packages/ledger/src/reclassify.ts— becomes a normal UPDATE on the cache, no special handlingAcceptance
cargo test -p relayburn-ledgergreen.RELAYBURN_HOMElayout:burn.sqlite+content.sqlite. Noledger.jsonl/ledger.idx/ledger.content.idx/archive.sqlite/content/*.jsonl/stamps.jsonl/*.lock.cargo build -p relayburn-ledgerproduces a binary that doesn't reference any user-space lockfile path.UNIQUE(source, session_id, message_id)) and layer-2 dedup (content_fingerprintindex lookup) both covered by tests; second-ingest of a turn under a fresh messageId but identical shape produces no row.burn stampsurviveburn state rebuildunchanged; ingest cursors inarchive_statelikewise survive.burn state rebuildregenerates the derivable tables inburn.sqliteand the entirecontent.sqlite(including the FTS index) from upstream session stores; integration test compares row-by-row against a freshly-ingested DB and asserts byte-equivalence (modulolast_built_attimestamps).content.sqlitewith multi-word queries, phrase queries, and boolean operators.MATCHresults before and afterburn state rebuildare identical.burn export ledger --format jsonlround-trips a populated DB to a JSONL stream byte-equivalent to the events ingested.burn stamps exportround-trips thestampstable to JSONL for backup / version-control.cargo build --release -p relayburn-cliproduces a binary with nolibsqlite3.soruntime dep.burn.sqlitekeep running.