Move the market agent's brain off Turso and into its own Durable Object's SQLite — the way Flue and Cloudflare intend. Decision + build spec · 2026-06-25
In one breath: The market agent is a Durable Object, and a DO reads its own SQLite locally — microseconds, no network hop. Turso only ever earned its place by offering in-database vector search; the moment we moved embeddings on-the-fly (tiny live vector set), that advantage evaporated and all that's left of Turso is a per-query network hop to an external vendor, against the framework grain. So the brain comes home: the 3.1M-fact relational spine moves into the DO's native ctx.storage.sql. The lone thing that ever made this unsafe — the team renaming the DO (-v8→-v9) to escape poisoned sessions, which orphans the data — is fixed by wiring Flue's surgical session-clear (harness.sessions.delete()) to an admin route, so we keep the same DO id forever. Vectors live in Cloudflare Vectorize — a native-CF binding, not the DO (SQLite can hold a blob but can't search it); only the relational facts go in the DO. The 10 GB cap is deferred (13× headroom, tier to R2 when a hot market nears it).
Turso was adopted for the market brain for exactly one reason that no longer holds, plus one that was never really a benefit:
| Turso's claimed advantage | Status now |
|---|---|
In-database native vector (vector_top_k, F32_BLOB ANN) | gone The native-CF home for vectors is Vectorize (a Worker binding, not an external vendor). Vectors live there keyed by parcel_id; the DO holds only the relational facts. So Turso's one-store vector trick buys us nothing the platform doesn't already offer natively. |
| Survives DO recreation (external store) | moot Only needed because we recreate DOs to clear poison. Fix that (§3) and the DO never gets thrown away — so nothing needs to survive it. |
Bulk import path (turso db import) | one-time Mattered for the legacy Delta load (done). The other 19 launch markets scrape in fresh — they never import a legacy file. |
Strip those and what remains is pure cost: a network round-trip on every query (and the agent reads its brain constantly), a second vendor to provision/secret/rotate across 20 markets, and code that fights Flue's grain (the framework's native storage is the DO's own SQLite; the market agent bypasses it to reach Turso over HTTP). Going native deletes all three.
The whole decision rests on one fact that's worth stating with zero ambiguity, because it was muddied earlier by sloppy "the DO gets thrown away" language.
The proof is Flue itself: Flue persists its sessions in DO SQLite (flue_sessions, flue_session_entries, …). If DOs were throwaway, you'd lose conversation history every time the instance slept — Flue's entire session design would be impossible. It works because DO storage is durable. Flue betting its sessions on it is the evidence.
So what was losing the brain? Not the platform — us. DO data only disappears three ways: you call deleteAll(), you delete the class, or you address a different id. The -v8→-v9 move was the third — not deleting anything, just pointing the agent at a new, empty object (the old one's data is still sitting there). We did it because a poisoned session had no clean reset, and renaming was the only hammer we had. The native design removes the cost of that hammer: the brain lives on a stable id (so it never moves) and is backed up to R2 (so even a deliberate recreate costs a few-minute re-ingest, not data loss). Sessions are disposable; the brain is durable and re-loadable. That's the whole game.
An earlier draft chased a scalpel: a surgical "clear just the poisoned session, keep the object" route. We built it and hit a real Flue beta.5 wall — an action runs in an isolated child harness with its own scoped sessions, so harness.sessions.delete('default') from inside the agent clears the action's session, not the live poisoned one, and beta.5 exposes no way to run a turn in a named session to reach it. The clean in-place clear isn't reachable today.
It doesn't matter — and chasing it was the wrong frame. A poisoned session is just chat history; there's nothing in it worth preserving. The thing worth protecting is the brain, and the native design protects it two ways:
north-fork-valley (we stop bumping the version). Same id → same brain → forever. It never moves.So the recovery path for a poisoned session is simply: recreate the agent (green-field sessions), re-load the brain from R2. Disposable sessions, durable brain. No scalpel required.
JOURNAL.md rewrite that consolidates and breaks stuck loops). The Phase-A admin/clear-session machinery was later removed entirely — it broke flue build (its adminActions.ts sat in the agents dir with a non-kebab agent name) and couldn't clear the live default session anyway.ctx.storage.sql, wrapped as Workspace).observed_at is the prune seam.parcel_brief stop attaching to Turso directly and read through the market DO (RPC). Periodic cadence → the hop is free.One durable storage, two interfaces, by data shape. A Durable Object has no POSIX disk — its SQLite is the disk. Over that one storage Flue gives you both a SQL interface (tables/rows — for the millions of structured facts you query and join) and a file interface (@cloudflare/shell's Workspace / WorkspaceFileSystem, already wired here as getDefaultWorkspace() — real paths, read / write / edit / grep, for the markdown the agent reads and cross-links every beat). The wiki and the soul / agent / journal files want the file interface; the parcels and facts want SQL. Same disk, each fit to its data — not "files shoved into a table," a genuine file API on the only durable local medium a DO has.
Outside the DO, three things in two roles. R2 and Vectorize are the brain's outboard storage — they exist only because a DO can't hold blobs-at-scale or search vectors itself. Convex is a different animal: the app surface, where the brain publishes what the website shows; it is never a path to the brain. And when one agent needs another's brain, it touches none of the three — it calls the other DO directly over RPC, reading that DO's storage without waking its model.
The SQL port is mechanical: every data fn in store.ts / parcels.ts already takes an injected client, so the swap is one-for-one — libSQL's client.execute({sql,args}) becomes the native ctx.storage.sql.exec(sql, ...args).toArray() directly. No impersonating adapter: Flue's native handle is the API, and using it as-is is the point. The only logic that changes is the vector path — libSQL vector_top_k → a Vectorize binding query (returns parcel_ids → join to local facts) — and the parcel_embeddings BLOB column leaves the schema entirely.
admin_clear_session action + route were removed (commit 7c83e51) — superseded, non-functional for the live session, and breaking flue build.JOURNAL.md rewrite that breaks stuck loops). Prevention over surgery.fakeSqlStorage() test helperSql type — { exec(q, ...binds): { toArray(): Row[] } }, matching Flue's SqlStorage — and a synchronous-sqlite fake for tests. No libSQL, no impersonating adapter.store.ts + parcels.ts to the native handle(c: Client, …) → (sql: Sql, …); body await c.execute({sql,args}) → sql.exec(query, ...args).toArray(). SQL text and source column names preserved verbatim. Existing tests re-point to fakeSqlStorage(); green = behavior-preserving.vector_top_k call with a Vectorize query() (returns parcel_ids + scores → join to local facts). Remove parcel_embeddings from the DO schema. Stand up the empty index + [[vectorize]] binding now; writes land with the on-the-fly embed path (Phase-2 comps). Same ParcelMatch return shape.getCloudflareContext().storage.sql (Flue's ambient accessor) instead of getTursoClient(). Convex mirror writes untouched.hz-import.db to R2 — the source of truth for native ingest (and any future re-ingest). Do this before touching Turso.POST /admin/ingest streams rows from the R2 dump and batch-inserts into ctx.storage.sql (INSERT OR IGNORE, key-paginated, resumable across alarm beats — there is no DO bulk file-import).parcel_brief (zpid 103871512 → parcel 292329201006).parcelBrief, listListings, scout reads. These run the store fns with the DO's own adapter.parcel_brief calls the market DO. Validate: parcel_brief in Slack, advisor scout's nightly read.@libsql/client / TURSO_* in the market path.TURSO_DATABASE_URL/TURSO_AUTH_TOKEN from the Worker. Full suite green; deploy.getDefaultWorkspace() (@cloudflare/shell Workspace over ctx.storage.sql) into the market agent so it has real file I/O — read / write / edit / grep over paths — for wiki/*.md, SOUL.md, AGENT.md, JOURNAL.md. Seed the identity files; prove a read/write round-trips and survives a respawn.JOURNAL.md rewrite are existing deferred work that now has a native place to live. Not required for the Turso cutover — buildable any time.wrangler d1 import, but a binding hop (not local) and still no native vector — only wins if we needed shared access without re-plumbing.@flue/runtime)flue_sessions, flue_session_entries, flue_agent_submissions, flue_agent_turn_journals, flue_image_chunks, flue_meta.harness.sessions.delete() exists but in beta.5 is only reachable from inside an action, whose child harness scopes it to the action's own session — it can't reach the live poisoned default (no API to run a turn in a named session). So in-place session-clear isn't available; recovery is recreate + R2 re-ingest instead.delete_sqlite_classes / new id drops it.parcel_id; search returns ids → join to local facts. The on-the-fly writes ride with the Phase-2 comps build; the index + binding stand up now, empty..md + SOUL.md / AGENT.md / JOURNAL.md live as files in the DO's own storage through @cloudflare/shell's Workspace (a real read/write/edit file API over ctx.storage.sql) — local, fast, transactional with the brain. R2 holds only the big rendered bytes (briefings, flyers, images, PDFs) and the brain backup. SQL for structured rows; files for markdown; same disk, two interfaces.onRequest()/the harness, so the market agent's model never wakes (no tokens). Convex is never the route to the brain.| Risk | Mitigation |
|---|---|
| A session poisons and can't be cleared in place | Recreate the agent (green-field sessions) + re-ingest the brain from the R2 backup — a few minutes, no data loss. Prevention (typed outputs + dream beat) keeps it rare. |
| One-time ingest of 3.1M rows is slow (no bulk import) | Resumable batch-insert across alarm beats from the R2 dump; one-time, bounded, idempotent. |
| Re-plumb breaks an advisor/staff read | RPC methods mirror the existing store fns 1:1; validate parcel_brief + scout before cutting Turso. |
| Sync DO SQL API vs async libSQL | Data fns keep their async signatures; the sync ctx.storage.sql.exec().toArray() drops straight in. The injected-client design means call sites don't change. |