Write Paths & Growth · v3.5.1

What vak writes, why, and how it holds up as history grows

Every turn leaves a trail on disk: the session ledger, a ring of workspace checkpoints, five small evidence ledgers, a search index, and a handful of atomically rewritten config files. This set answers three questions from the source alone — how much is written per turn and for what, which reads scale with history and which stay flat, and where the growth levers are that change no public signature. Figures marked measured come from the real data home on this machine; everything else is read straight out of the crates.

Append-only ledger Atomic rewrite (tmp + rename) Derived — safe to delete Grows without a bound today

Measured · one real home, 60 sessions, 1 386 turns

Per turn: about 13 ledger entries, 13 fsyncs, 66 KB — and 72% of the bytes are one audit record

All 60 ledgers under agents/vak/sessions/ were parsed by entry kind (18 187 entries, 92 MB). A turn is counted by its intent entry (1 386 of them); user-role messages number 3 132 because tool results travel as user-role entries. The shape is stable: one prompt, one intent, one goal update, one capability binding, about 2.3 assistant messages and receipts, and about three activities.

13.1
ledger entries per turn
each one a sync_data()
66 KB
ledger bytes per turn
mean over 1 386 turns
48 KB
turn_capabilities_bound
per entry, one per turn
134 MB
checkpoints — the largest artifact
41 files, max 19.8 MB each
<300 KB
all five side ledgers together
routing · cost · activity · intent · security

Where the ledger bytes go

Share of 92 MB across 18 187 entries, by entry kind · measured

turn_capabilities_bound
72.3%
message
17.3%
activity
3.2%
intent
3.0%
header
1.8%
receipt
1.7%
goal_update
0.5%
turn_card · presentation · compaction
<0.2%
Reading it: the conversation itself (user + assistant messages, tool results included) is 16 MB. The capability binding — system prompt, every tool schema, the deferred-tool index — is written in full on every turn at ~48 KB and accounts for 67 MB. It is audit-only: derive_messages() skips it, and its only reader is the server's presentation projection. Tool results ride inside user messages (1 563 blocks, 11.7 MB), which is why user messages average 3.7 KB and assistant messages 1.3 KB.

Where the disk goes

One agent home, 256 MB total · measured with du

checkpoints/
134 MB
sessions/ (60 ledgers)
90 MB
store.db + wal (cache)
11.4 MB
feeds.duckdb
0.5 MB
presentations.json
152 KB
cost-log.jsonl
136 KB
routing · activity · intent · security
84 KB
Checkpoints outweigh the ledgers they protect. A checkpoint is a full-content, base64-in-JSON snapshot of the workspace (≤ 8 MB per file, ≤ 64 MB total) taken at the start of any turn expected to have an effect; only the last 20 per session are kept, and nothing prunes the directories of sessions that have ended. The five evidence ledgers together are smaller than one checkpoint's rounding error.

The write map

Every durable artifact, who writes it, and how

The authoritative list is vak_core::state::REGISTRY — a test fails the build when a workspace run produces a file nobody declared. Each card below states the trigger, the write discipline, and the read pattern actually found in the code, because the read pattern is what decides whether the store stays fast as it grows.

Per-session · under sessions/<project-hash>/ (or agents/<id>/sessions/)
<session>.jsonlledger

The one rich original. Hash-chained (prev_hash), parent-pointer tree, never rewritten. Everything the model saw is reconstructable from it.

writer
SessionLog::append — vak-session
trigger
every message, intent, receipt, activity, presentation, turn card, work event, compaction
discipline
one writeln! + sync_data() per entry; exclusive try_lock for the handle's lifetime
read
full parse on open into Vec<Entry>; then in-memory, but several scans are O(entries) per request
bound
none on disk; in memory via compaction packets and MAX_LIVE_SESSIONS = 128
checkpoints/<session>/NNNN.jsonatomic file

Full workspace snapshot before a turn that may have an effect. Rewind restores content and deletes only files that were observed.

writer
checkpoints::store — vak-core
trigger
first turn of a session, and any turn where expects_effect
discipline
tmp + rename; no fsync; prunes to 20 newest per session
read
only on rewind / delta summary
bound
8 MB per file, 64 MB per snapshot, 20 per session; no dedupe between snapshots, no GC across sessions
sandbox/executions/<session>.jsonlunbounded

Every SandboxEvent the bash tool emits — start, each 8 KB stdout/stderr chunk, a telemetry row every 500 ms, artifacts, finish.

writer
append_session_sandbox_event — vak-server
discipline
open + append + close per event; no fsync
read
whole file per /sandbox/executions request
bound
none — a long-running command writes 2 rows/s for its whole life
Per-home evidence ledgers · beside the sessions
routing-evidence.jsonlledger

One row per provider attempt: success / failure / unknown and latency. Feeds the route ladder's ordering function.

writer
EvidenceLedger::record_receipts after every run
discipline
append, no fsync
read
full scan on every turn — plan_route_ladder → snapshot(); TTL of 30 days applied at read time only
bound
none on disk
cost-log.jsonlledger · compacts

Estimated USD per settled dispatch, keyed by session and model. Budget caps are enforced from in-memory counters, not from re-reading this file.

writer
FinOpsLedger::append via CoreSpendGate
discipline
one metadata() stat per append; when > 5 MB, rewrites keeping 90 days (tmp + rename)
read
day baseline loaded once per day into DayBudget; digests and the admin chart stream it
bound
5 MB · 90 days
activity-log.jsonlunbounded

Duration and success of every hook run, tool call, and dispatch — the operational twin of the cost log.

writer
ActivityLedger::append from three closures in the run path
discipline
append, no fsync, no compaction (the FinOps ledger beside it has one)
read
all_rows() — whole file — for digests
bound
none
intent-evidence.jsonlledger

Did the reading hold? One row per turn from MisreadLedger::record, plus one when a sliced capability was later asked for.

discipline
best-effort append, no fsync
read
full scan, 30-day TTL at read time; not on the turn path
bound
none on disk
commitments.jsonlledger · locked

Commitment lifecycle events. Legality is checked at the write boundary under a create_new lock file.

writer
CommitmentLedger::append — vak-commit
discipline
lock → get() → append; lock broken after 2 s
read
every append replays the whole file to project one commitment; open() replays it again for the scheduler
bound
none
security-events.jsonl · inbox.jsonlledger

Denials and capability faults (39 call sites); every gateway push and its ack tombstone.

discipline
append, no fsync; acks are new lines, never edits
read
inbox reads the trailing MAX_SCAN = 10 000 lines only
bound
reads bounded; files are not
operations/ · jobs/ · flow-runs/ledger

Incident events and action receipts (≤ 1 update per incident per minute); one JSON file per delivery job; flow run records.

discipline
append for events; per-job file create/rewrite under an in-process mutex
read
outbox pending() reads and parses every job file in the directory
bound
delivered jobs are never removed
Memory and knowledge · human-editable
memory/<hash>/MEMORY.md · memory/user/USER.mdappend + amend

Plain markdown the remember tool appends to and humans edit; forget/amend rewrite atomically with sync_all.

discipline
append + sync_data; rewrite = tmp + sync_all + rename
read
whole file per turn that projects memory
bound
never age-pruned by design; only abandoned write artifacts are cleaned (24 h)
entities/…/ENTITIES.jsonlatomic rewrite

Typed entity graph per workspace and globally.

discipline
whole-file rewrite via tmp + rename on each change
read
whole file
Derived · rebuilt from the ledgers, safe to delete
cache/store.db (SQLite FTS5)derived

Structured + full-text index over every ledger. rebuild() drops and re-imports from JSONL at any time.

writer
Store::import_session — spawned after each run finishes and on session create/attach
discipline
WAL, synchronous=NORMAL; re-reads the whole JSONL, one SELECT per line to skip known ids, autocommit per insert
read
indexed queries — flat
bound
≈ 12% of ledger bytes (11.4 MB over 92 MB, measured)
recall cache (in-process)derived

Per-ledger message cache keyed by path, invalidated on mtime/length change.

bound
trailing MAX_SCAN_LINES = 4000 per ledger; MAX_CACHED_LEDGERS = 128
feeds.duckdbsidecar

Written by the Python feed pipeline, not by Rust. Bounded by max_items_per_feed = 500 and a 90-day dedup window in feeds.toml.

Config · additive-only across updates
config.toml · tasks.json · workspaces.json · gateway/*.json · credentials.enc · …atomic rewrite

Settings, schedules, bindings, allowlist, trust markers, presentation prefs. Written on operator action, never per turn.

discipline
tmp + rename; tasks.json also sync_alls the file and directory
read
fingerprinted — effective_route() re-reads only when the config files' fingerprint changes
growth
size follows configuration, not usage

One turn, every write

The write sequence of a single ordinary turn

Traced through Core::run and the agent loop, in the order the code performs them. The right column says whether the write forces a disk barrier, and what it measured at per turn. Writes marked conditional happen only when the turn takes that path.

1

Workspace checkpoint checkpoints::capture → store

Walk the workspace honouring .gitignore, skip .git target node_modules .vak dist and secrets, snapshot file contents. Taken on the session's first turn and any turn that expects_effect — an execution act, the General tier, or a reading below accept confidence.

tmp + rename3.3 MB mean · 19.8 MB maxconditional
2

Goal update append_goal_update

Relationship between this request and the active goal. Audit-only.

fsync0.3 KB
3

Intent evidence row MisreadLedger::record

Which act and stakes were read, so a later contradiction can be scored against it.

append~0.14 KBintent-evidence.jsonl
4

Intent entry append_intent

The resolved reading and the exact note the engagement contributes. Model-visible, so it is logged before dispatch (invariant 1).

fsync2.0 KB
5

Capability binding append_turn_capabilities

Epoch, selected and excluded capability ids, the full system prefix, every core tool schema, the deferred-tool index and domain labels. Makes the provider request reconstructable after live capabilities move. Written whole on every turn even when nothing changed since the previous one.

fsync48 KB · 72% of all bytes
6

User message append_message

The prompt, with its request id for idempotent retries — has_request_admission scans the whole in-memory ledger to answer that.

fsync3.7 KB meanincl. later tool results
7

Dispatch → assistant message(s) append_message

Each provider response becomes an assistant entry; tool calls it makes become Activity(ToolCall) entries and, on return, tool-result blocks inside a new user-role entry. Each activity-log closure also appends a row for the dispatch, every hook, and every tool.

fsync ×n1.3 KB / message · 2.3 per turn+ ~0.16 KB / activity-log row
8

Receipts append_receipt

One WorkReceipt per unit of provider work, with every attempt and its settlement. Never model-visible.

fsync0.5 KB · 2.3 per turn
9

Cost row CoreSpendGate → FinOpsLedger::append

Estimated USD from the receipt's usage. In-memory run and day counters are updated first, so caps hold even if the file write fails.

append~0.36 KBcost-log.jsonl
10

Routing evidence EvidenceLedger::record_receipts

One row per attempt leg, attributed to the leg that actually served.

append~0.13 KB × attemptsrouting-evidence.jsonl
11

Presentation + turn card append_presentation · append_turn_card

The validated presentation and the turn's closing card — what a follow-up turn sees instead of the raw history.

fsync ×20.8 KB + 1.4 KBconditional
12

Incremental compaction append_incremental_compaction

Only when the working-set plan needs a packet no Compaction entry covers: summarise the cards (never raw history), append one entry, re-plan. Measured: 1 in 18 187 entries.

fsync1.1 KBrare
13

Search index index_session_later → import_session

After RunFinished, a spawned task reads the whole JSONL again, checks every entry id against entries, and inserts the new ones into entries + entries_fts.

backgroundO(ledger) readsstore.db · WAL
14

Sandbox events append_session_sandbox_event

For every bash execution: start, each output chunk, telemetry every 500 ms, finish — written as they stream to the Workbench.

unbounded2 rows / s while runningconditional
Barrier count

About thirteen sync_data() calls per turn, measured — one per ledger entry; a turn with no tool calls is closer to six, one with several is past twenty. Nothing else on the turn path fsyncs: the side ledgers rely on the page cache, and the checkpoint relies on rename atomicity without a data barrier. The ledger is the only artifact whose tail must survive power loss, and it is the only one that pays for it.

How reads scale

Which per-turn reads grow with history — and which were designed not to

Write volume is small. What decides latency as the home ages is what each turn has to read back. Below, every read that sits on or near the turn path, classified by what it scales with. flat is bounded by a constant, session grows with the current session's length, home grows with the whole home's history.

Read on the turn pathWhereScales withWhat bounds it todayClass
SessionLog::open full parse + hash verifyvak-session log.rsentries in this sessionOnce per attach; handle then held in memory (128 live handles max)session
has_request_admission linear scanvak-sessionentries in this sessionNothing — every request scans entries for its request_idsession
TurnIndex::from_log (34 call sites)vak-session turns.rsentries in this sessionRebuilt from the entry vector per request; in-memory, no I/Osession
derive_with_plan working setvak-context + vak-sessionturns kept at Full/Card/Packet fidelityMeasured capacity budget; compaction packets collapse old turns to cardsflat
EvidenceLedger::snapshotvak-core routing.rsall rows ever written to the home30-day TTL is applied while scanning; file never shrinkshome
CommitmentLedger::get / openvak-commitall commitment events in the homeNothing — full replay per append and per scheduler passhome
DayBudget baselinevak-core finops.rscost rows for todayStreamed once per calendar day, then counters in memory; file compacts at 5 MBflat
effective_route config reloadvak-core—Fingerprint of config files; re-read only on changeflat
Memory projection MEMORY.md · USER.mdvak-core memory.rsnotes the person has keptHuman-curated; never auto-prunedflat*
recall over past ledgersvak-session index.rs—Trailing 4 000 lines per ledger, 128 ledgers cached, mtime-invalidatedflat
Store::import_session (background)vak-storeentries in this sessionWhole JSONL re-read + one SELECT per line, after every runsession
Outbox::pendingvak-deliveryall delivery jobs ever enqueuedNothing — read_dir + parse every recordhome
inbox::listvak-core—Trailing MAX_SCAN = 10 000 linesflat
CorePool · sessions mapvak-server—8 cores, 1 800 s idle; 128 live session handles, LRU-evicted when unreferencedflat

Per-turn read work as a home ages

Shape only — two classes of read, relative cost against turns accumulated in the home

Per-turn read work versus accumulated turns A flat line for reads bounded by constants — DayBudget, recall cache, working-set plan, config fingerprint — and a rising line for reads that scan the whole home, chiefly routing-evidence snapshot and commitment replay. 0 10 k 20 k 30 k 40 k turns 0 ↑ read work home-scan reads routing-evidence · commitments · outbox bounded reads day budget · recall · working set · config Session-scoped reads (open, admission scan, TurnIndex) reset to zero at every new session and are held in memory after open — they grow within a conversation, not across the home.
scans the whole home each turnbounded by a constant or a fingerprint
Why the rising line is still shallow in practice: routing-evidence rows are ~130 bytes and about 2.3 per turn, so 40 000 turns is roughly 12 MB parsed per turn — measurable, not yet painful. It becomes painful earlier for the commitment ledger, which is replayed on every append under a lock, and for any home shared by many agents. The line's slope is the reason to fix these; the intercept is why nobody has noticed.

Projection · from measured per-turn constants

What one home looks like at 1 000, 10 000 and 100 000 turns

Straight multiplication of the measured per-turn numbers, with the bounds the code enforces applied. This is a model, not telemetry: it assumes today's mix (about 2.3 provider attempts and 1.1 tool calls per turn) and today's 48 KB capability record.

ArtifactPer-turn constant1 k turns10 k turns100 k turnsBound applied
sessions/*.jsonl66 KB66 MB660 MB6.6 GBnone — of which ~4.8 GB is capability bindings
  ↳ without repeated bindings18 KB18 MB180 MB1.8 GBif a binding were written once per epoch
store.db (cache)≈ 12% of ledger8 MB80 MB800 MBrebuildable; FTS indexes text only
checkpoints/0–64 MB per effect turn20 × workspace size × open sessions — independent of turn countring of 20 per session; no cross-session GC
routing-evidence.jsonl~0.13 KB × 2.3 attempts0.3 MB3 MB30 MBnone on disk; scanned every turn
activity-log.jsonl~0.16 KB × 30.5 MB5 MB48 MBnone
cost-log.jsonl~0.36 KB0.4 MB3.6 MB≤ 5 MBcompacts at 5 MB, keeps 90 days
intent-evidence.jsonl~0.14 KB0.1 MB1.4 MB14 MBnone on disk; not on the turn path
sandbox/executions/2 rows/s of command timeproportional to seconds of bash, not turnsnone
Speed, not space

Disk is not the constraint — 6.6 GB for 100 000 turns is small on any machine vak runs on. The constraints are the per-turn barrier count (fixed, ~6–20 fsyncs regardless of history), the per-request O(session) scans that are cheap only because sessions are short, and the two O(home) scans that sit on the turn path. Keep those three flat and the system's speed does not depend on its age.

As messages grow · as-is

What happens today at each scale — inside one conversation, and across all of them

Two independent axes grow. S, the length of one conversation, drives the live handle's memory, the open time, and every per-request scan. N, the number of sessions in the home, drives launch, because the session list is built by opening every file. Turn count across the home drives the side ledgers. The staircase uses the measured constants (66 KB and 13 entries per turn; ~5 KB per entry; ~401 entries parsed per file when listing).

One conversation · S = 100 turns
6.6 MB
  • RAM for the handle: ~20–35 MB — every entry is held as a deserialised Entry; tool schemas become serde_json::Value trees, several times their text size
  • Open: SHA-256 + parse of 1 300 lines — under 100 ms
  • Per request: 1 300-entry scans for admission and TurnIndex — negligible
  • Model context: flat — packets and cards, not history
S = 1 000 turns
66 MB
  • RAM: ~200–350 MB for this one handle, resident for as long as it is live
  • Open / resume: ~0.5–1 s of hashing and parsing before the first turn can run
  • Hydrate: the client receives the whole derived transcript (~12 MB of messages) in one response
  • Felt: a pause on resume, a heavy tab, more GC churn per request
S = 10 000 turns
660 MB
  • RAM: multi-GB for one session — the handle cannot be evicted while a client is attached
  • Open: ~10 s; the client's transcript request is ~120 MB
  • Felt: resume feels hung; the browser tab is at risk; 13 000 linear scans per request
  • Not felt: the model — context stays bounded regardless
Across the home · N sessions
N × 2 MB parsed
  • Every GET /sessions: opens all N files and parses ≤ 401 entries each — 60 files ≈ 120 MB parsed; 1 000 files ≈ 2 GB
  • Called: on client mount, after every create/delete, when a session id is not in the list
  • Checkpoints: up to 20 × workspace per session, never collected — already 134 MB here for 17 sessions
  • Felt: launch and sidebar refresh slow down linearly with the number of sessions ever created

Launch and first turn · as-is

What a launch actually does, step by step

1 · process
Server starts

Opens store.db (WAL, no rebuild), loads config, tasks, gateway state.

flat
2 · client mount
GET /sessions

Walks sessions/ and every agents/*/sessions/; parses up to 401 entries per file for title and header.

O(N × 401 entries)
3 · hydrate
SessionLog::open

Reads the whole ledger, SHA-256s every line to verify the chain, deserialises every entry into RAM.

O(S) time + RAM
4 · hydrate
derive_transcript

Full transcript to the client in one JSON body; the client renders it.

O(S) payload
5 · first turn
Checkpoint capture

Walks the workspace, snapshots content ≤ 64 MB.

O(workspace)
6 · first turn
Route ladder

EvidenceLedger::snapshot parses the whole routing-evidence file.

O(home turns)
7 · first turn
Commitments

CommitmentLedger::open replays every event in the home.

O(home events)
8 · first turn
Bind + write

48 KB capability entry, intent, goal update — 3 fsyncs before the model is even called.

fixed

As-is → to-be · same files, same signatures

The structure that stays fast — what changes underneath each existing function

Nothing moves, nothing is renamed, no entry type is removed. Each row is one function whose body changes; the right-hand column is the cost class after the change.

As-is

cost per operation
sessions/<id>.jsonl
13 entries / 66 KB per turn; 48 KB of it a repeated capability binding. Whole ledger resident per live handle.
66 KB · O(S) RAM
GET /sessions
Open every file, parse ≤ 401 entries each.
O(N × 401)
SessionLog::open
Hash + parse every line, hold every entry.
O(S)
has_request_admission · TurnIndex::from_log
Linear scans of the in-memory vector per request; index rebuilt at 34 sites.
O(S) per request
EvidenceLedger::snapshot
Full file parse every turn; TTL only at read time.
O(home) per turn
CommitmentLedger::append / open
Full replay per append under lock, and per scheduler pass.
O(home) per append
Store::import_session
Re-read whole JSONL after each run; SELECT per line; commit per insert.
O(S) per run
checkpoints/
Full content each snapshot; 20 per session; no GC after a session ends.
20 × workspace × N
activity-log · intent-evidence · security · sandbox · outbox
Append forever; sandbox at 2 rows/s during commands; outbox parses every job.
unbounded
cost-log · inbox · recall · config · CorePool · live handles
Already bounded by constants or fingerprints.
flat

To-be

cost per operation
sessions/<id>.jsonl
Binding written once per epoch, later turns carry body_digest (additive field). Same entry kinds, same chain.
18 KB · RAM ÷ 3–4
GET /sessions
Served from store.db — it already indexes header, timestamp and first user message per session; file scan only as fallback when the store is absent.
one indexed query
SessionLog::open
Unchanged contract; 3–4× fewer bytes to hash and parse because bindings are no longer repeated.
O(S), ÷ 3–4
has_request_admission · TurnIndex::from_log
Private request_ids set maintained in append; TurnIndex cached and invalidated by entries.len().
O(1) · O(new entries)
EvidenceLedger::snapshot
compact_if_large on append (cost-log pattern); snapshot cached in CoreInner by (mtime, len).
one stat per turn
CommitmentLedger::append / open
Per-process projection cache invalidated by file length; same lock, same closure check.
O(new events)
Store::import_session
Byte offset per session in meta; seek, no per-line SELECT, one transaction per import.
O(new entries)
checkpoints/
Content stored once per hash, later snapshots reference it; directories of idle, closed sessions collected after a retention window.
≈ 1 × workspace + deltas
activity-log · intent-evidence · security · sandbox · outbox
Stat-then-compact guard on each; per-execution cap on the sandbox log; delivered jobs swept after a window.
bounded
cost-log · inbox · recall · config · CorePool · live handles
Unchanged.
flat

Launch and first turn · to-be

The same launch after the levers

1 · process
Server starts

Unchanged.

flat
2 · client mount
GET /sessions

One query against store.db; no ledger file is opened.

flat
3 · hydrate
SessionLog::open

Same verification, a quarter of the bytes.

O(S) ÷ 3–4
4 · hydrate
derive_transcript

Unchanged in this set — the transcript is what the person asked to see.

O(S) payload
5 · first turn
Checkpoint capture

Walk unchanged; only changed content is written.

O(changed files)
6 · first turn
Route ladder

Cached snapshot; one stat.

flat
7 · first turn
Commitments

Cached projection; replay only new events.

flat
8 · first turn
Bind + write

Binding by digest: ~0.3 KB instead of 48 KB. Same three fsyncs.

fixed, 48 KB lighter
Scale pointAs-isTo-beWhat the person notices
1 000-turn conversation, resume66 MB read + hashed; ~200–350 MB RAM; ~0.5–1 s~18 MB; ~60–100 MB RAM; ~0.2 sResume stops being a visible pause
10 000-turn conversation, resume660 MB; multi-GB RAM; ~10 s~180 MB; ~1 GB RAM; ~3 sSurvivable rather than hung; still the case for starting a new session
Launch with 1 000 sessions in the home~2 GB parsed for the sidebar, every refreshOne indexed queryLaunch time no longer depends on history
Every turn, 40 000 turns of home history~12 MB routing-evidence parse + full commitment replayTwo stat callsTurn latency stops drifting upward with age
Disk after 100 000 turns6.6 GB ledgers + 0.8 GB index + uncollected checkpoints1.8 GB + 0.2 GB + ~1 workspace copyBackups and vak doctor stay quick
Durability per turn13 sync_data()13 sync_data()Nothing — the ledger promise is unchanged
What does not change at any scale

What the model sees. The working set is sized by measured capacity and compaction packets, so a 10 000-turn session and a 10-turn session send the same order of tokens. Growth is entirely a host-side problem — RAM, launch, and two ledger scans — and every fix above is host-side too. The one long-conversation cost this set leaves alone is the full transcript sent on hydrate; paging it is a client contract change, not an internal one.

Balance sheet

What already absorbs growth, and what does not yet

Designed to stay flat

found in the code, with the constant that enforces it

  • Compaction packets — the working set is sized by measured capacity, not by ledger length; old turns ride along as cards or one packet.
  • Cost ledger self-compacts at 5 MB, keeping 90 days; budget alerts at 1 MB / 2 000 rows; the day baseline is read once per day.
  • Checkpoint ring — 20 per session, 8 MB per file, 64 MB per snapshot; ignored dirs and secrets never enter it.
  • Live-handle cap — MAX_LIVE_SESSIONS = 128 with conservative LRU eviction; CorePool 8 cores / 30 min idle; evicted sessions re-open from disk.
  • Bounded scans — recall reads the trailing 4 000 lines; inbox the trailing 10 000; both tolerate torn lines.
  • Config is fingerprinted — no per-turn re-parse unless a file changed.
  • Index is derived — WAL + synchronous=NORMAL, droppable and rebuildable; the ledger never depends on it.
  • Right barrier — sync_data not sync_all on the ledger; atomic tmp + rename for every config file.

Grows with history today

ordered by how soon it would be felt

  • Capability binding written whole every turn — 48 KB, 72% of ledger bytes, and every one of them parsed again on SessionLog::open and re-indexed by the store.
  • routing-evidence scanned every turn with no on-disk compaction — TTL only at read time.
  • Commitment ledger replayed on every append under a lock file, and again by the scheduler.
  • Store import re-reads the whole session after every run and probes every id with a SELECT, each insert its own commit.
  • Checkpoints have no cross-session GC and no dedupe between snapshots — 17 finished sessions here still hold 134 MB.
  • activity-log, intent-evidence, security-events, sandbox executions append forever; the sandbox file grows at 2 rows/s during any command.
  • Outbox never forgets delivered jobs; pending() parses all of them.
  • Session-scoped linear scans (has_request_admission, TurnIndex::from_log) — fine while a session is hundreds of entries, not thousands.

Growth levers · no signature changes

Where speed is protected as the system grows — each behind an existing function

Every lever below is an implementation change inside a function whose public signature stays exactly as it is: SessionLog::append, EvidenceLedger::snapshot, Store::import_session, CommitmentLedger::append, checkpoints::store. Callers, entry types, and on-disk readers of older files are unaffected. None of these is implemented by this document; they are the map.

1

Write the capability binding once per epoch, not once per turn

TurnCapabilitiesBound carries the same system prompt and tool schemas turn after turn until cap_set.epoch changes. Inside Core::run, compare the digest of the body to the last one written in this session; when equal, write the entry with the ids, epoch and a body_digest and omit the schemas. Readers (projection.rs) resolve a digest to the last full body in the same ledger. Removes ~48 KB and ~72% of the bytes from every turn; the reconstructability guarantee holds because the full body is still in the same append-only file.

signature-neutral · additive serde field with #[serde(default)]; older ledgers parse unchanged

2

Compact routing-evidence on append and cache its snapshot

Give EvidenceLedger::append the same compact_if_large the cost ledger already has (stat per call, rewrite past the TTL when over a threshold). Then hold the last EvidenceSnapshot in CoreInner keyed by the file's (mtime, len) — the pattern route_fingerprint already uses for config. The per-turn read becomes one stat.

signature-neutral · snapshot() and append() unchanged

3

Tail-import the search index inside one transaction

Record the byte offset reached per session file in the meta table; import_file seeks there instead of read_to_string and skips the per-line SELECT. Wrap the loop in a single transaction so a 30-entry run is one WAL commit rather than sixty.

signature-neutral · import_session / rebuild unchanged; the index stays rebuildable

4

Project commitments incrementally

CommitmentLedger::append replays every event to project one commitment. Keep a per-process projection cache invalidated by file length, or scan only lines whose commitment_id matches before deserialising the rest. The lock-file protocol and the closure check stay as they are.

signature-neutral

5

Deduplicate checkpoints and collect the ones nobody can rewind to

In checkpoints::capture, hash each file and store content only when it differs from the previous sequence's; a later snapshot references the earlier blob. In store, or on Core start, remove checkpoint directories of sessions with no live handle and no ledger activity for a retention window. The 134 MB measured here would collapse to roughly one workspace copy plus deltas.

format change inside the checkpoint file, not an API change — capture, store, restore keep their signatures; the JSON gains a reference form

6

Give every append-only sidecar a size bound

ActivityLedger, MisreadLedger, security events and the sandbox execution log each need the one-stat-then-rewrite guard the cost ledger has, and the outbox needs a sweep that removes Delivered records older than a window. The sandbox log additionally wants a per-execution cap so a long-running command cannot write 7 000 telemetry rows an hour.

signature-neutral · same pattern as FinOpsLedger::compact_if_large

7

Index the in-memory ledger for the two scans every request performs

SessionLog already keeps by_id; a request_ids: HashSet maintained in append turns has_request_admission from O(entries) to O(1), and a cached TurnIndex invalidated by entries.len() stops rebuilding it at 34 call sites. Both are private fields.

signature-neutral

Deliberately not a lever

Batching the per-entry sync_data(). Six barriers a turn is the cost of "the ledger tail survives power loss," and the comment in append records that the alternative was tried and lost data. Group commit within a turn would change what is promised, not just how fast it runs — so it is a product decision, not an optimisation, and it is left out of this set.