Skip to content

Architecture — the operator's map

This page is written for the moment something is wrong at 2am and you need to know which process to look at, which file to read, and why the system is shaped the way it is before you change anything. It is not a concept overview — that's concepts.md. This is the map.

Component inventory

Every process that touches ctxlake data, what it reads, and what it writes:

ComponentWhat it isReadsWritesNetwork?
ctxlake-hookA binary invoked once per hook event by the runtime (Claude Code, Cursor, Hermes). Exits after one event.The event payload on stdin; the local cache (for injection); the redactor's static tablesThe local spool (append one NDJSON line)Never. A hook that opens a socket is a bug — invariant 1.
ctxlake syncThe daemon. Long-running, one per host, started by ctxlake install or run under a supervisor as ctxlake sync --foreground. Runs as the sync subcommand of the ctxlake binary; the loops themselves live in the ctxlake-sync library crate, the same split ctxlake maint has between the ctxlake-maint crate and its own subcommand.The local spool; the object storeThe object store (bronze appends, live/ CAS updates); the local cacheYes — it is the only process on this list that is.
ctxlake maintA subcommand run periodically (cron, systemd timer, or manually). Compacts small Parquet files, runs the claims promotion gate, publishes snapshot/.sessions/, claims/events/, live/leases/sessions/ (compacted files), claims/fleet/, snapshot/Yes.
ctxlake-mcpThe MCP tool server, run as ctxlake mcp, a stdio child process spawned directly by the coding agent (Claude Code / Cursor's MCP client). One instance per session.The local cache onlyLocal spool (for memory_propose and similar write-shaped tool calls — never live/ or claims/fleet/ directly)Never — same discipline as the hook, for the same reason: it's on a path the agent is waiting on.
Local spool~/.ctxlake/spool/<runtime>/<session_id>.ndjsonAppended to by ctxlake-hook and ctxlake-mcp; drained by ctxlake syncn/a
Local cache~/.ctxlake/cache/<fleet_id>/{briefing,roster,leases}.jsonRefreshed by ctxlake sync's store→cache leg; read by ctxlake-hook and ctxlake-mcpn/a
live/Bucket prefix, control planeEveryone (roster/lease checks)ctxlake sync (CAS, on behalf of its own host's agent)
sessions/, claims/events/Bucket prefix, data planectxlake maint, any query enginectxlake sync (single-writer append, one writer per key)
snapshot/Bucket prefix, serving planeEvery ctxlake sync (store→cache leg)ctxlake maint only
claims/fleet/Bucket prefixEvery ctxlake sync (store→cache leg)ctxlake maint's promotion gate only — invariant 9

The spool is partitioned by runtime, not by fleet or agent. A hook knows its own runtime with certainty; fleet_id and agent_id come from the environment and may be unset, and a path built from an unset value is a path you cannot find later. Every envelope carries both fields anyway, so the daemon reads them from the content rather than inferring them from the directory.

The load-bearing line in that table is invariant 1, restated as a boundary: the hook and the MCP server never touch the network. Both talk exclusively to local files. Everything that touches the object store is either ctxlake sync or ctxlake maint — two processes an operator can restart, kill, or strace independently of whether an agent is mid-turn.

Write path

The spool is the durability boundary. A line is never removed from it until the corresponding store write has been confirmed, not merely attempted — a daemon crash mid-batch just means the same lines get retried on restart. This is also exactly why "spool growing" (see the failure table below) is diagnosed by asking what's stopping confirmation, not by assuming the daemon is broken.

Read path

Whatever an agent sees is only ever as fresh as the last completed cache refresh — see "what ctxlake does not guarantee" below. There is no read path that waits on the network, by the same invariant that governs writes.

Maintenance chain

ctxlake maint acquires a lease on itself before doing anything, using the exact same CAS mechanism a resource lease uses (see the state machine below) — the maintenance job itself is single-writer-at-a-time, which matters because more than one host in a fleet may have a cron entry for it. Without this, two maint processes racing to compact the same session files, or to publish two different briefings concurrently, would be the one place this whole design's "no cross-key atomicity" rule actually bites: a compaction and a publish both touch multiple keys, so they need to not be happening twice at once rather than being made atomic.

Lease state machine

Every arrow after bootstrap is the same primitive: a CAS write against a version you just read. The [*] --> Free arrow is the one exception — an unconditional PUT, not a CAS write, because there is nothing yet to hold a version to compare against (see storage.md for why that's still safe). Past that first write, there is no separate "lock" API — a lease is just a JSON object whose contents happen to mean something, and the whole reason the rest of the machine can be reasoned about this simply is that acquisition, renewal, and release never use put-if-absent (storage.md) or a local clock (invariant 6).

Where does my data actually go — a worked trace

You run an Edit on crates/foo/src/lib.rs in a Claude Code session. Fleet is myteam, your agent id is cc-01, the store is s3://my-bucket/ctxlake.

  1. Claude Code finishes the edit and fires its PostToolUse hook — the entry ctxlake install merged into .claude/settings.json runs ctxlake-hook with the tool-call JSON on stdin.
  2. ctxlake-hook (main.rs) builds one Envelope: event_type: ToolCall, tool.name: "Edit", tool.paths: ["crates/foo/src/lib.rs"], and a content_hash via hash::content_hash.
  3. Before anything touches disk, Redactor::scrub runs over the tool input and result. This edit is clean, so the content passes through unchanged — a secret here would instead be quarantined and never reach step 4.
  4. The hook writes one line — Envelope::to_ndjson(), no embedded newline, guaranteed by the envelope's own tests — appended to ~/.ctxlake/spool/claude_code/<session_id>.ndjson, then exits. Total time: under the 5ms budget. No socket was opened.
  5. ctxlake sync, already running on your laptop, tails that file on its poll interval. The session hasn't ended, so it buffers rather than sealing early.
  6. SessionEnd fires the same hook path with one final envelope. On its next poll, ctxlake sync seals the batch — encodes it as one Parquet row group and does a single-writer append PUT to s3://my-bucket/ctxlake/sessions/runtime=claude_code/agent=cc-01/date=2026-09-11/<session_id>.parquet. No CAS here: this key is only ever written by cc-01's own daemon.
  7. Because the edit touched a real path, the daemon also updates cc-01's own roster entry at s3://my-bucket/ctxlake/live/agents/cc-01.json — a PutMode::Update(version) CAS write reflecting the last-active path.
  8. Later, ctxlake maint runs on some host in the fleet, wins the maintenance lease, compacts small session files, runs the promotion gate over claims/events/, and republishes the briefing: a new blob at snapshot/briefing/<content-hash>.json, then a CAS swap of snapshot/briefing/current.json to point at it.
  9. Back on your laptop, ctxlake sync's store→cache leg notices the pointer changed (a conditional GET against current.json), fetches the new blob, and writes it to ~/.ctxlake/cache/<fleet_id>/briefing.json.
  10. Next time anyone in myteam starts a session, ctxlake-hook's SessionStart handler reads that exact file — never the store — and injects it as additional context.

Your keystroke reached Parquet, and came back out as a teammate's briefing, without either agent's hook process ever making a network call.

Failure modes

SymptomFirst thing to checkWhy
Empty briefing at session startIs ctxlake sync running (ctxlake status)? Then check the mtime of ~/.ctxlake/cache/<fleet_id>/briefing.jsonThe hook only ever reads the local cache. If the daemon isn't running or hasn't completed a first refresh yet, the file may not exist — the hook fails open (no briefing) rather than blocking the session.
Peers show up as invisible / roster looks emptyConfirm your own live/agents/<agent_id>.json exists and hasn't passed expires_at; check the cache's roster refresh timestampPresence is inferred from CAS objects under live/agents/, mirrored into the local cache on a poll cycle. A peer whose heartbeat hasn't renewed within its TTL is legitimately gone — and a stale local cache produces the identical symptom for a peer that's actually still there.
Local spool keeps growingctxlake status for the daemon; ctxlake doctor for store connectivity; spool directory sizeA spool line is only removed after its store write is confirmed. A crashed daemon, a network partition, or a store outage all present as unbounded spool growth, because deleting unacknowledged data would break the one durability guarantee this design has.
Hook adds noticeable latency to a tool callManually time ctxlake-hook against a captured payload; check whether the tool's output is unusually largeBudget is 5ms p99, and a hook that opens a socket is a bug (invariant 1). Slowness is almost always a huge tool-output payload interacting with MAX_SCAN_BYTES, a full local disk making the spool append block, or — as an actual bug — network I/O that snuck into the hook path.
412 Precondition Failed storms in the sync logAre the failures concentrated on one key, or spread across many?A 412 is CAS working as designed, not an error — the daemon retries with backoff. Failures concentrated on one key point at a genuinely hot object (see scaling.md); failures spread across many keys point at clock skew or a retry loop missing its backoff.
Claims never promote to fleet scopeIs ctxlake maint actually scheduled and running? Did it win the maintenance lease? Compare claims/events/ (pending) against claims/fleet/ (promoted)Promotion is invariant 9 — only the gate, inside ctxlake maint, ever writes claims/fleet/. A missing cron entry, a maint process losing the lease race, and a genuinely unmet promotion rule (independence gate, an unresolved contradiction) all look identical from outside. Check the maint log before assuming a bug.
Two agents edited the same fileThis is a git problem, not a ctxlake problemLeases are advisory (invariant 5) — ctxlake can warn, it cannot revoke a running agent's ability to write to disk. Git is the declared arbiter for code. A warning that was ignored, or that arrived late because of the poll interval's staleness window, is expected behavior.
ctxlake doctor reports a backend failing CASWhich primitive failed — put-if-absent or If-Match/ifGenerationMatch — and against which backendBackends genuinely differ (storage.md). MinIO rejecting If-None-Match: * is permanent vendor behavior, not a transient fault — doctor exists so you learn this before deploying, not mid-incident.
quarantine/ growing fastWhich rules_fired values dominate in recent entriesA flood of one rule (commonly high_entropy_run) usually means a false-positive source — a build emitting long hashes or minified output — not an actual leak. Look before tightening or loosening the redactor.

Every knob, its default, and its blast radius

KnobDefaultBlast radius
Hook budget5ms p99 (design target, not a runtime setting)Exceeding it makes every single tool call feel laggy — this is the number invariant 1 exists to protect.
MAX_SCAN_BYTES (redact.rs)256 KiBEntropy scanning only covers the first 256 KiB of a field. Literal marker matching still covers the whole field, but a secret positioned past the cutoff in an oversized tool-output blob, with no literal prefix, will not be caught by entropy alone.
ENTROPY_MIN_LEN / ENTROPY_THRESHOLD (redact.rs)32 chars / 4.5 bits/charToo low a threshold false-positives on ordinary long identifiers (git SHAs and ULIDs sit near entropy 4.0 and are meant to survive — see the redactor's own test suite); too high misses real base64/hex secrets, which sit near 6.0.
Lease TTL5 minutesToo short: an actively-working agent can appear expired after a scheduling hiccup, and get "acquired out from under it" (advisory, so this is a false-warning cost, not data loss). Too long: a genuinely crashed agent's lease looks held for longer than useful.
Lease renewal interval60s (1/5 of TTL)The 5:1 ratio is a standard safety margin — one missed renewal cycle doesn't expire the lease. Renewing more often directly costs money: PUTs price at 12.5x a GET (scaling.md).
Roster/live poll interval5sLower = fresher peer visibility, at O(N) to O(N²) request cost depending on discovery strategy (scaling.md). Higher = the "peers invisible" symptom above becomes more likely for a peer that just joined.
Cache refresh interval (store→cache leg)5–15sThis is the staleness floor for everything an agent's hook or MCP tools ever see — briefing, roster, leases. Nothing waits for a fresher read, by invariant 1, so this number is the freshness guarantee.
Spool flush / batch triggertime- or size-based (e.g. 2s or N events)Larger batches mean fewer, larger store writes (cheaper, per scaling.md's small-object-explosion section) at the cost of a bigger window of unconfirmed data sitting only in the local spool if the daemon crashes.
Maintenance schedule (ctxlake maint)operator-configured cron/systemd timerToo infrequent: claims sit unpromoted, briefings go stale, small files accumulate. Too frequent: maintenance-lease contention across hosts, and more LIST/PUT traffic for no benefit once a run finds nothing new to do.
doctor probe object lifecyclewritten under a scratch prefix, deleted on successA doctor run that crashes mid-probe can leave litter behind; a bucket lifecycle rule on the scratch prefix is cheap insurance, not required for correctness.
Agent id stabilityoperator-assigned, must be stable across restartsReusing the same agent_id from two different physical hosts makes them share one roster entry and one lease identity — CAS still prevents lost writes, but the human-facing "who is doing what" picture becomes wrong.

What ctxlake does NOT guarantee

Say these plainly, here, not in a footnote:

  • Leases are advisory, and the fencing-token limit is real. No backend here can reject a write from a process whose lease already expired — a stalled agent can wake up and write anyway. For code, git is the arbiter. For an irreversible external action, the target system needs its own idempotency key; ctxlake cannot supply one.
  • Snapshots are stale by construction. A briefing or roster view is only as fresh as the last completed cache refresh on your host. Under a network partition or a dead daemon, that can be minutes old, and nothing blocks work to wait for freshness — that would violate invariant 1.
  • There is no cross-key atomicity. A snapshot publish is two writes (blob, then pointer) — a crash between them leaves an orphaned blob (harmless) or simply means the pointer swap never happens (also harmless: readers keep seeing the old snapshot). But a claim's proposal and its later promotion are two separate keys with no transaction linking them, and nothing in this design changes that.
  • Redaction has edges. MAX_SCAN_BYTES caps entropy scanning at 256 KiB per field; a compromised host with valid store credentials can always write around the hook entirely, since redaction only protects data that actually flows through it.
  • There is an honest ceiling of roughly 50 concurrently active agents. Past that, CAS contention and O(N) request volume against live/ make plain object storage impractical at the polling intervals this design assumes — see the arithmetic in scaling.md. The documented path past that ceiling is moving live/ to DynamoDB or Redis while sessions/ and snapshot/ stay exactly as they are; ctxlake does not do this automatically.

Next steps

  • scaling.md — the cost arithmetic behind the poll-interval and TTL defaults above, and the ~50-agent ceiling
  • storage.md — the CAS capability matrix the lease state machine depends on
  • security.md — redaction, quarantine, and the IAM policy shape
  • coordination.md — roster, intents, and leases from the user's side

AGPL-3.0-or-later, with commercial licenses available. Pre-alpha.