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.
each one a
sync_data()mean over 1 386 turns
per entry, one per turn
41 files, max 19.8 MB each
routing · cost · activity · intent · security
Where the ledger bytes go
Share of 92 MB across 18 187 entries, by entry kind · measured
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
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.
sessions/<project-hash>/ (or agents/<id>/sessions/)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; exclusivetry_lockfor 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
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
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/executionsrequest - bound
- none — a long-running command writes 2 rows/s for its whole life
One row per provider attempt: success / failure / unknown and latency. Feeds the route ladder's ordering function.
- writer
EvidenceLedger::record_receiptsafter 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
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::appendviaCoreSpendGate- 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
Duration and success of every hook run, tool call, and dispatch — the operational twin of the cost log.
- writer
ActivityLedger::appendfrom 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
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
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
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 000lines only - bound
- reads bounded; files are not
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
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)
Typed entity graph per workspace and globally.
- discipline
- whole-file rewrite via tmp + rename on each change
- read
- whole file
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, oneSELECTper line to skip known ids, autocommit per insert - read
- indexed queries — flat
- bound
- ≈ 12% of ledger bytes (11.4 MB over 92 MB, measured)
Per-ledger message cache keyed by path, invalidated on mtime/length change.
- bound
- trailing
MAX_SCAN_LINES = 4000per ledger;MAX_CACHED_LEDGERS = 128
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.
Settings, schedules, bindings, allowlist, trust markers, presentation prefs. Written on operator action, never per turn.
- discipline
- tmp + rename;
tasks.jsonalsosync_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.
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.
Goal update append_goal_update
Relationship between this request and the active goal. Audit-only.
Intent evidence row MisreadLedger::record
Which act and stakes were read, so a later contradiction can be scored against it.
Intent entry append_intent
The resolved reading and the exact note the engagement contributes. Model-visible, so it is logged before dispatch (invariant 1).
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.
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.
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.
Receipts append_receipt
One WorkReceipt per unit of provider work, with every attempt and its settlement. Never model-visible.
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.
Routing evidence EvidenceLedger::record_receipts
One row per attempt leg, attributed to the leg that actually served.
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.
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.
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.
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.
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 path | Where | Scales with | What bounds it today | Class |
|---|---|---|---|---|
| SessionLog::open full parse + hash verify | vak-session log.rs | entries in this session | Once per attach; handle then held in memory (128 live handles max) | session |
| has_request_admission linear scan | vak-session | entries in this session | Nothing — every request scans entries for its request_id | session |
| TurnIndex::from_log (34 call sites) | vak-session turns.rs | entries in this session | Rebuilt from the entry vector per request; in-memory, no I/O | session |
| derive_with_plan working set | vak-context + vak-session | turns kept at Full/Card/Packet fidelity | Measured capacity budget; compaction packets collapse old turns to cards | flat |
| EvidenceLedger::snapshot | vak-core routing.rs | all rows ever written to the home | 30-day TTL is applied while scanning; file never shrinks | home |
| CommitmentLedger::get / open | vak-commit | all commitment events in the home | Nothing — full replay per append and per scheduler pass | home |
| DayBudget baseline | vak-core finops.rs | cost rows for today | Streamed once per calendar day, then counters in memory; file compacts at 5 MB | flat |
| effective_route config reload | vak-core | — | Fingerprint of config files; re-read only on change | flat |
| Memory projection MEMORY.md · USER.md | vak-core memory.rs | notes the person has kept | Human-curated; never auto-pruned | flat* |
| recall over past ledgers | vak-session index.rs | — | Trailing 4 000 lines per ledger, 128 ledgers cached, mtime-invalidated | flat |
| Store::import_session (background) | vak-store | entries in this session | Whole JSONL re-read + one SELECT per line, after every run | session |
| Outbox::pending | vak-delivery | all delivery jobs ever enqueued | Nothing — read_dir + parse every record | home |
| inbox::list | vak-core | — | Trailing MAX_SCAN = 10 000 lines | flat |
| CorePool · sessions map | vak-server | — | 8 cores, 1 800 s idle; 128 live session handles, LRU-evicted when unreferenced | flat |
Per-turn read work as a home ages
Shape only — two classes of read, relative cost against turns accumulated in the home
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.
| Artifact | Per-turn constant | 1 k turns | 10 k turns | 100 k turns | Bound applied |
|---|---|---|---|---|---|
| sessions/*.jsonl | 66 KB | 66 MB | 660 MB | 6.6 GB | none — of which ~4.8 GB is capability bindings |
| ↳ without repeated bindings | 18 KB | 18 MB | 180 MB | 1.8 GB | if a binding were written once per epoch |
| store.db (cache) | ≈ 12% of ledger | 8 MB | 80 MB | 800 MB | rebuildable; FTS indexes text only |
| checkpoints/ | 0–64 MB per effect turn | 20 × workspace size × open sessions — independent of turn count | ring of 20 per session; no cross-session GC | ||
| routing-evidence.jsonl | ~0.13 KB × 2.3 attempts | 0.3 MB | 3 MB | 30 MB | none on disk; scanned every turn |
| activity-log.jsonl | ~0.16 KB × 3 | 0.5 MB | 5 MB | 48 MB | none |
| cost-log.jsonl | ~0.36 KB | 0.4 MB | 3.6 MB | ≤ 5 MB | compacts at 5 MB, keeps 90 days |
| intent-evidence.jsonl | ~0.14 KB | 0.1 MB | 1.4 MB | 14 MB | none on disk; not on the turn path |
| sandbox/executions/ | 2 rows/s of command time | proportional to seconds of bash, not turns | none | ||
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).
- RAM for the handle: ~20–35 MB — every entry is held as a deserialised
Entry; tool schemas becomeserde_json::Valuetrees, 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
- 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
- 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
- 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
Opens store.db (WAL, no rebuild), loads config, tasks, gateway state.
GET /sessionsWalks sessions/ and every agents/*/sessions/; parses up to 401 entries per file for title and header.
SessionLog::openReads the whole ledger, SHA-256s every line to verify the chain, deserialises every entry into RAM.
derive_transcriptFull transcript to the client in one JSON body; the client renders it.
Walks the workspace, snapshots content ≤ 64 MB.
EvidenceLedger::snapshot parses the whole routing-evidence file.
CommitmentLedger::open replays every event in the home.
48 KB capability entry, intent, goal update — 3 fsyncs before the model is even called.
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 operationTo-be
cost per operationbody_digest (additive field). Same entry kinds, same chain.store.db — it already indexes header, timestamp and first user message per session; file scan only as fallback when the store is absent.request_ids set maintained in append; TurnIndex cached and invalidated by entries.len().compact_if_large on append (cost-log pattern); snapshot cached in CoreInner by (mtime, len).meta; seek, no per-line SELECT, one transaction per import.Launch and first turn · to-be
The same launch after the levers
Unchanged.
GET /sessionsOne query against store.db; no ledger file is opened.
SessionLog::openSame verification, a quarter of the bytes.
derive_transcriptUnchanged in this set — the transcript is what the person asked to see.
Walk unchanged; only changed content is written.
Cached snapshot; one stat.
Cached projection; replay only new events.
Binding by digest: ~0.3 KB instead of 48 KB. Same three fsyncs.
| Scale point | As-is | To-be | What the person notices |
|---|---|---|---|
| 1 000-turn conversation, resume | 66 MB read + hashed; ~200–350 MB RAM; ~0.5–1 s | ~18 MB; ~60–100 MB RAM; ~0.2 s | Resume stops being a visible pause |
| 10 000-turn conversation, resume | 660 MB; multi-GB RAM; ~10 s | ~180 MB; ~1 GB RAM; ~3 s | Survivable 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 refresh | One indexed query | Launch time no longer depends on history |
| Every turn, 40 000 turns of home history | ~12 MB routing-evidence parse + full commitment replay | Two stat calls | Turn latency stops drifting upward with age |
| Disk after 100 000 turns | 6.6 GB ledgers + 0.8 GB index + uncollected checkpoints | 1.8 GB + 0.2 GB + ~1 workspace copy | Backups and vak doctor stay quick |
| Durability per turn | 13 sync_data() | 13 sync_data() | Nothing — the ledger promise is unchanged |
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 = 128with conservative LRU eviction;CorePool8 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_datanotsync_allon 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::openand 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.
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
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
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
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
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
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
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
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.