tinkerlab.dev
/blog/from-one-machine-to-many...

From one machine to many

Part 3 ended on a hedge. The rebuild was tagged v0.0.1, the old logger was still running, and I wrote that I’d cut over “when the rebuild has earned it, and not before.” That was four weeks ago. It earned it on the 26th of August, and the month it took is more interesting than the moment.

”Every release after the first one was a bug found by using the thing, not by planning it.”

This is the part of a project nobody writes up, because “I shipped eleven point releases and deleted my old code” isn’t a narrative. But the eleven releases are where the design either survives contact or doesn’t, and one of them contains my favourite bug of the year. So: the build log, continued.

If you’re arriving here cold — Part 1 is the gap, Part 2 is the survey of the twelve projects already working on it, and Part 3 is what got built and why. The project is Claude Transcripts, and the docs — architecture, ADRs, the competitive survey — are at vredchenko.github.io/claude-transcripts.

Status, still plainly: early preview. No auth, no security model, no secret masking. It has now been exercised harder than it had been in Part 3, which is not the same as being ready. Read it, run it on a machine you trust, don’t put it anywhere hostile.

§1 · The cutover

For eight days two loggers ran side by side, writing the same sessions into two different databases. That was deliberate — you don’t retire the thing that holds your history on the strength of a version number — but it’s an uncomfortable way to live, because a two-writer arrangement has twice the failure surface and no clear source of truth.

On the 26th the old one came out. Its hook registration was removed, its 337 sessions had already been imported into the new corpus, its database and bucket were archived to cold storage and then dropped, and its viewer and DNS name went with it. What’s left is one logger, one viewer, one corpus.

Here is what that corpus holds, queried while writing this sentence:

Sessions433 — up from 345 in Part 3
Event documents47,109
Transcript bytes495 MB
Tokens accounted5.94 billion — of which 5.80 B cache reads, 26.8 M output
Hostnames2
Top toolsBash 19,739 · Edit 4,882 · Read 4,303 · Write 2,108

The cache-read share has, if anything, got worse since Part 3: 97.6% of all token traffic is an agent re-reading its own context. I keep putting that number in these posts because it’s the single most invisible cost in agent-assisted work, and the only reason I can see it is that something wrote it down.

But the row that changed the project is the one that says 2.

§2 · The hostname column

I didn’t plan a multi-machine deployment. I got one by being lazy.

The hook writes to CouchDB and S3 directly — that’s deliberate, and Part 3 explained why: recording a session must never depend on a web service being up. What the design assumed, without ever saying so, was that those stores are this machine’s, bound to localhost, a round trip not worth thinking about.

Then production moved to a different box, the stores moved with it, and the tailnet made a remote CouchDB feel close enough that I just… pointed the hook at it. It worked immediately. Then a laptop got the same treatment. And now there are two hostnames in one history, and neither machine knows the other exists.

That’s a nice accident and a slightly dishonest one, because it works by ignoring the boundary rather than crossing it. But it did surface a real design gap straight away: the same project name on two machines is two different working copies. A session list that shows tinkerlab.dev-homepage twelve times, six of them from a laptop checkout that’s three commits behind, is telling you something false by omission. So hostname joined the session table, the timeline card, and every projection — a one-column change that only became necessary because the corpus stopped being about one computer.

The 0.0.10 release also fixed the other number that was quietly lying. The session list showed wall-clock runtime, which means a session left open in tmux over a weekend reported three days of “runtime” and about twenty minutes of work. Runtime now splits into active and idle — the same distinction a timesheet makes — and it took a schema migration to afford it, because deriving active time per row from the existing view meant reading a fat aggregate document for every event of every session. The new view stores one timestamp per event and nothing else. One string per row is what makes a page of sessions cheap.

There’s a small honesty rule buried in that feature I’m fond of: unknown active time renders as —, never 0s. “Can’t say” and “ran and did nothing” are different claims, and a dashboard that conflates them is lying in a way that’s very hard to catch later.

§3 · Mirrors — the version of that trick that isn’t a hack

Pointing a hook at someone else’s database works, and it doesn’t generalise. It needs the stores to be network-reachable and credentialed, which is fine on a private tailnet between two machines I own and completely wrong as a shape for anything else. If the eventual goal is several people’s machines reporting into one shared history, “give everyone your database password” is not a plan.

So 0.0.10 shipped mirrors, which is the same idea done properly. A mirrors array in the hook’s config writes every session to a second instance as well as the local one, live.

The interesting part is why this had to be code rather than configuration. A mirror cannot be expressed as a second couch.url, because a remote instance’s CouchDB and S3 are bound to its localhost and aren’t reachable, and the read-only proxies the API exposes are, by design, read-only. The one write surface a remote instance offers is /api/ingest — which is the same door the import command restores a backup through. So a mirror is, precisely, a live incremental import, and the document shapes were already known to survive the trip.

Three properties I’d defend:

  • The fan-out starts together, not in sequence. The local store is never queued behind a remote one. An event costs max(local, mirror), not the sum. This matters more than it sounds: PostToolUse fires on every single tool call inside a five-second hook budget, so a mirror that has gone away must give up well inside it. The default timeout is two seconds.
  • It’s a composite client, not a change to the handlers. “Write to two places” stays a fact about configuration instead of something each of the six event handlers has to remember. Handlers that remember things are handlers that eventually forget.
  • There is no retry and no queue, and the documentation says so in bold. A write that fails while the mirror is down is lost to the mirror — not deferred. The local copy is untouched and complete, so nothing is lost outright, but the mirror holds a hole covering the outage. The remedy is an export --since / import backfill, which is idempotent, so you widen the range and stop worrying.

I want to be clear that the last one is a limitation and not a design flourish. A retry queue is real work — durable spooling, ordering, back-pressure, a failure mode where the spool itself fills the disk — and shipping a fake one that drops writes under load would be worse than shipping none and saying so. It’s written down as a gap, not implied to work.

One further thing had to be true for mirrors to be usable at all, and it’s the sort of bug that only exists because two code paths drifted: setup and install were silently deleting the mirrors config. The runtime config file is generated from the repo config plus .env and rewritten whole whenever either changes — and nothing in the repo config knows what a mirror is. So mirroring would stop, with no error, and the hook’s swallow-everything contract guaranteed nothing would ever mention it. Machine-owned keys now survive the rewrite. hook status prints the mirrors it’s writing to, because a feature that’s configured by hand, writes somewhere else, and fails silently by design needs something that says it’s on.

§4 · The same release, deployed privately

Every public release also goes somewhere private: a single-node k3s cluster on a home server, reachable only over Tailscale, running the identical image from the identical tag. There’s no fork of the application — the private side is a deployment, not a variant.

That deployment is doing three jobs.

It’s the first real exercise of the external-backends path. Part 3 listed “pointing it at a CouchDB, S3 store or Meilisearch you already run” as designed-for but unverified, marked TODO in the docs rather than quietly implied to work. The private deploy is that path — shared CouchDB, shared Garage, both already running for other things — so the TODO is now being tested by the only user who can’t file an angry issue about it.

It’s where the multi-user shape gets to be wrong cheaply. The tier plan has always ended at multiplayer: replication, auth, a real security model. But the honest way to find out what a shared history needs is to run a shared history, badly, among machines you already own, and notice what hurts. Two hostnames in one corpus already produced the hostname column and the active/idle split. A third machine belonging to somebody else would produce the next three findings, and I’d rather learn them from a colleague’s laptop on a private network than from a design document. That’s the staging ground: not a product, a place where the questions arrive in the right order.

It’s a source of operational bugs the single-machine case never generates. Two examples from the last upgrade, both worth stealing:

A rolling update runs two followers at once, and the old one wins the last write. Both pods are alive for a few seconds, so anything the new version migrates in the database can be undone by the old pod on its way out. Concretely: the new pod migrated a checkpoint document and deleted the old one; the still-terminating old pod recreated it 51 milliseconds later. The document was then orphaned — frozen, unread, and still holding the revision tree that compaction needed to reclaim. After any upgrade that migrates database state, re-check that the migration actually stuck once the old pod is gone, rather than trusting the new pod’s boot log.

And fixing a write loop reclaims nothing on its own. CouchDB keeps the revision tree until you compact it. Compaction took that database from 177 MB to 57 MB against 57 MB of live data, and dropped the database pod from 976m CPU to 34m. Which brings me to what it was doing with all that CPU.

This is the good one.

Search is kept current by a follower on CouchDB’s change feed: ask for changes since offset N, index whatever arrives, record the new offset, ask again. Standard. The follower stored its resume point as a document in the database it was following.

Read that again slowly. Writing the checkpoint is a change. So the long-poll returned immediately with a one-document batch, the batch contained nothing indexable, the handler checkpointed again, and the only thing throttling the loop was CouchDB’s round-trip time.

The reason this survived every release that shipped search is that nothing was ever wrong with the results. Indexing worked. Coverage was complete. Search was fast and correct. There was no failing test to write, no error in a log, no user-visible symptom of any kind. What it cost was invisible: a permanent background burn on every instance with search enabled. One deployment held 5.1 million revisions of a single document, roughly 9.5 writes per second on a completely idle box, a pinned CPU core, and a 158 MB database file for 89 MB of real data.

It also quietly poisoned something else: any resource measurement taken against a running instance was meaningless, because a core was always busy. I’d been looking at those numbers for weeks.

The fix is a one-line category change rather than a tuning: the checkpoint became a local document, which CouchDB excludes from the change feed by design. The feedback edge is gone at the source instead of damped. I’d initially reached for the cheaper stopgap — only checkpoint when a batch actually indexed something — and it would have left the loop latent, because a genuine batch still writes a checkpoint that wakes the feed one more time. Damping a feedback loop leaves you a feedback loop.

Two things fell out of it for free. Local documents aren’t replicated, which is what a checkpoint always was — an offset into one instance’s change feed, meaningless anywhere else. And they have no revision tree, so the write stopped needing to read its own revision first: one round trip per batch instead of two.

The lesson I’m taking is not “be careful with change feeds.” It’s that a bug with no wrong output has no natural discovery path, and the only reason I found this one is that I went looking at resource usage for an unrelated reason. Correctness tests would never have caught it. They all passed. They’d have kept passing forever.

§6 · Where this sits, briefly

Part 2 surveyed twelve projects in this space properly, and I’m not going to re-run it. But the month sharpened the one sentence that distinguishes this from all of them.

Almost every AI-memory tool stores distilled facts — summaries, extracted claims, embeddings, graph nodes — and discards the transcript that produced them. That’s a defensible trade when the goal is recall, and it’s fatal when the goal is provenance, because you cannot re-derive a better summary from a summary. This project keeps the byte-faithful transcript as ground truth and treats every clever layer as optional and rebuildable on top of it.

What replication adds is that the ground truth is now a thing that can travel — mirrored live, exported as a bundle, restored into a clean install, carried across machines with its schema version attached so the receiving end knows whether the restore is sound. A distilled memory can’t do that honestly: reconciled facts from two machines don’t merge, they contradict, and there’s nothing underneath to adjudicate with. Append-only immutable documents from two machines just… concatenate.

The ideas I’m still stealing haven’t changed and still haven’t been built: Mem0’s ADD/UPDATE/DELETE reconciliation, Letta’s background self-reflection, Graphiti’s bi-temporal validity. All correct, all derived layers, all deliberately waiting. The discipline the whole series argues for is that the record underneath is the part you can’t recreate later if you skip it — and I’d rather ship the boring layer eleven times than the clever one once.

§7 · The ledger, updated

What was missing in Part 3, and where it stands:

  • No auth. Still none. Unchanged and unapologetic for the single-machine case; it is the reason the private deployment is tailnet-only rather than merely password-protected. Anything network-facing is a Tier-3 concern and Tier 3 isn’t built.
  • No secret masking. Still. A faithful transcript is faithful about the API key you pasted at 1 a.m. This remains the single most important unbuilt thing, and the fact that the corpus now spans machines makes it more urgent rather than less.
  • The recall loop is still unbuilt. Search over the corpus works and is genuinely useful — Meilisearch over both metadata and conversation content, with a session detail view that now reads as a dialogue rather than a wall of tool calls. An agent querying its own history during a session is still Tier 2, still an issue rather than code. It is also still the entire point of Part 1.
  • Mirrors have no retry queue. New gap, documented rather than hidden. Backfill is the remedy.
  • The private deployment has no CI gate on its image line. Every image movement there is a manual command, passing through none of the vulnerability scanning the public release does. That’s a real hole and I know it.
  • Scale is still untested. 433 sessions and 47,000 documents is comfortable. I still don’t know where CouchDB views stop being comfortable, and I still haven’t gone looking.

Eleven releases in four weeks, and one of them — 0.0.2 — was never published at all: tagged, then abandoned, because the image build failed on a broken internal documentation link and the tag had already shipped CLI binaries pinned to an image that didn’t exist. The tag is still there, marked and superseded, because deleting the evidence of a bad release is how you get a second one.

Which is the whole series in one habit, really. The record is only worth keeping if you keep the parts that don’t flatter you.


Part 5 is where this stops being about my machines. Everything so far has been infrastructure — capture the record, keep it honest, make it travel. The question underneath was always what a durable record of AI-assisted work does to a team: what happens to project-specific expertise when it stops evaporating at the end of a session, what a handover looks like when the artifact remembers more than the person leaving, and whether onboarding changes shape when a new starter can read how the decisions were actually made rather than just what was merged. That’s the next one. Stay tuned.


This is Part 4 of The Session Record. Start at Part 1 for the gap, Part 2 for the field, or Part 3 for the build. The project is github.com/vredchenko/claude-transcripts — design docs, ADRs and all, at vredchenko.github.io/claude-transcripts.