Market Brain Goes Native

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).

Contents
  1. Why move — what changed
  2. The durability model (the foundation)
  3. The unblock — session reset in place
  4. What we keep, lose, and defer
  5. Target architecture
  6. The build — phased
  7. Evidence (the two investigations)
  8. Risks & decisions

1 · Why move — what changed

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 advantageStatus 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.

2 · The durability model — the foundation

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.

A Durable Object is NOT throwaway. It has two halves, and only one is ephemeral:

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.

3 · The recovery model — disposable sessions, a re-loadable brain Pete, 2026-06-25

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:

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.

poisoned session ──▶ recreate the agent (fresh, empty sessions) │ ├─ re-ingest brain from R2 backup (Phase C, a few minutes) │ ▼ clean sessions, brain restored byte-for-byte ──▶ agent resumes
Prevention still does the heavy lifting. Recreate-and-re-ingest is the break-glass, not the daily tool. Poison is kept rare by the guards already shipped: typed tool outputs (the model can't loop on a parse error) and the market's dream beat (the periodic 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.

4 · What we keep, lose, and defer

✓ Keep / Gain

  • Local microsecond reads — the agent reads its own SQLite synchronously, no network hop.
  • Zero extra vendor — no Turso account, no per-market DB to provision, no token to rotate.
  • With the grain — Flue's native storage is exactly this (ctx.storage.sql, wrapped as Workspace).
  • Clean ownership — the market agent becomes the sole gatekeeper of its own brain.

↺ Lose / Defer

  • native Vectors → Cloudflare Vectorize (a Worker binding, not the DO — SQLite can't search blobs). Committed as the vector home; writes ride with the on-the-fly embed path (Phase-2 comps). The DO keeps zero vectors — relational facts only; search returns ids → join to local facts.
  • defer 10 GB / DO cap → 13× headroom today (764 MB). Tier cold facts to R2 / external when a hot market nears it; the EAV's observed_at is the prune seam.
  • change Direct shared access → advisor-scout & staff parcel_brief stop attaching to Turso directly and read through the market DO (RPC). Periodic cadence → the hop is free.

5 · Target architecture — the full storage topology

┌─ INSIDE the market DO ──────────────────────────────────────────┐ │ id: north-fork-valley (stable — never renamed again) │ │ │ │ native storage — ONE durable SQLite, two interfaces over it: │ │ ├─ SQL rows parcels 18,970 · facts 3.1M · parcel_listings · │ │ │ pending · listings · market_reports │ │ │ — the structured brain (query + join) │ │ ├─ Files wiki/*.md · SOUL.md · AGENT.md · JOURNAL.md │ │ │ via the Workspace file API (read/write/edit) │ │ └─ Sessions flue_* (chat history — disposable) │ └───────┬──────────────────────────────────────┬─────────────────┘ advisor│ staff ── DO RPC (reads the brain, │ │ no model wake) ─────────────┘ ┌─ OUTSIDE the DO ──────────────────────────────────────────────────┐ │ brain's outboard storage │ the app surface │ │ ───────────────────────── │ ─────────────── │ │ R2 blobs + brain backup │ Convex board · /home · │ │ (briefings, flyers, │ advisor state · │ │ images, PDFs; the │ Clerk glue — the │ │ re-ingest source) │ storefront window, │ │ Vectorize vector search; the DO │ NEVER a route to │ │ can't. ids → local facts │ the brain │ └────────────────────────────────────┴──────────────────────────────┘

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.

6 · The build — phased

Phase A · Recovery model — no keystone needed
  1. Sessions are disposable
    No surgical session-clear (beta.5 doesn't expose it — see §3). Recovery from a poisoned session = recreate the agent + re-ingest the brain from R2 (Phase C). The Phase-A admin_clear_session action + route were removed (commit 7c83e51) — superseded, non-functional for the live session, and breaking flue build.
  2. Keep poison rare
    Lean on the shipped guards: typed tool outputs (no parse-loop) + the dream beat (JOURNAL.md rewrite that breaks stuck loops). Prevention over surgery.
Phase B · Port the store to native DO SQLite
  1. Native handle + fakeSqlStorage() test helper
    A tiny Sql type — { exec(q, ...binds): { toArray(): Row[] } }, matching Flue's SqlStorage — and a synchronous-sqlite fake for tests. No libSQL, no impersonating adapter.
  2. Port store.ts + parcels.ts to the native handle
    Mechanical: each data fn's signature (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.
  3. Vector path → Vectorize binding; drop the BLOB column
    Replace the libSQL 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.
  4. Market agent + its tools read the native handle
    Inside the DO, tools resolve the handle via getCloudflareContext().storage.sql (Flue's ambient accessor) instead of getTursoClient(). Convex mirror writes untouched.
Phase C · Ingest the Delta brain into the DO (one-time)
  1. Archive the cleaned dump to R2
    Push the 764 MB cleaned hz-import.db to R2 — the source of truth for native ingest (and any future re-ingest). Do this before touching Turso.
  2. Resumable ingest route
    Auth'd 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).
  3. Verify counts
    Assert exact match: parcels 18,970 · parcel_facts 3,114,247 · parcel_listings 482 · pending_resolution 387. Spot-check parcel_brief (zpid 103871512 → parcel 292329201006).
Phase D · Re-plumb the consumers
  1. Expose read RPCs on the market DO
    Market DO methods for the cross-agent reads: parcelBrief, listListings, scout reads. These run the store fns with the DO's own adapter.
  2. Point advisor-scout & staff tools at the RPCs
    Advisor scout stops reading Turso via shared env; staff parcel_brief calls the market DO. Validate: parcel_brief in Slack, advisor scout's nightly read.
Phase E · Cut Turso from the market path
  1. Remove the libSQL client + Turso refs
    Market path reads only the native handle. Grep-clean of @libsql/client / TURSO_* in the market path.
  2. Drop the secrets last
    After native is proven in practice, remove TURSO_DATABASE_URL/TURSO_AUTH_TOKEN from the Worker. Full suite green; deploy.
Phase F · The native file system — wiki & identity independent of the cutover
  1. Stand up the Workspace file layer
    Wire 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.
  2. Foundation for the deferred wiki/dream beats
    This phase only stands up the home. The rich wiki authoring (interlinked briefs, the comps engine) and the dream beat's JOURNAL.md rewrite are existing deferred work that now has a native place to live. Not required for the Turso cutover — buildable any time.

7 · Evidence — the two investigations 2026-06-25, code-verified

10 GBDO SQLite cap / object (Workers Paid) — 13× delta's 764 MB
localDO reads its own SQLite synchronously — no network hop
re-loadablebrain backs up to R2 — recreate costs a re-ingest, not data loss

Cloudflare limits (confirmed against docs)

Flue internals (confirmed in @flue/runtime)

8 · Risks & decisions

Decided — brain goes fully native (DO SQLite). Pete, 2026-06-25. The network hop matters because the agent reads its brain constantly; native is local, zero-vendor, with the grain. D1 considered and passed (still a hop); Turso retired from the market path.
Decided — vectors live in Cloudflare Vectorize, never the DO. Pete, 2026-06-25. SQLite can hold a blob but can't search it, and Vectorize is a native-CF binding (not an external vendor), so it's the right home and it's with the grain. Vectors keyed by 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.
Decided — Convex stays; it is NOT replaced by D1. Pete, 2026-06-25. Convex is the reactive app + serving layer (the board, /home, advisor state, Clerk glue) — its real-time query subscriptions are load-bearing UX. D1 is request/response SQL with no reactivity; replacing Convex would mean rebuilding live-update infra (DO WebSockets + pub/sub) just to lose a capability we use. Open-source/self-hostable defuses the lock-in worry. Unlike Turso (a feature we stopped needing), Convex's value is real and Cloudflare has no equivalent — so the "go native" logic does not transfer. The native migration is the agent's private brain; Convex is the app's layer. Different concerns.
Decided — files live in the DO via the Workspace API; R2 for blobs. Pete, 2026-06-25. A DO has no POSIX disk, so the wiki .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.
Decided — sessions are disposable; green-field is fine. Pete, 2026-06-25. No surgical in-place session-clear (beta.5 doesn't expose it). A poisoned session is recovered by recreating the agent and re-loading the brain from R2 — disposable chat history, durable re-loadable brain. Prevention (typed outputs + dream beat) keeps poison rare.
Decided — cross-agent brain reads go over DO RPC. Pete, 2026-06-25. Advisor & staff read the market DO's storage through an RPC method on the DO — which bypasses onRequest()/the harness, so the market agent's model never wakes (no tokens). Convex is never the route to the brain.
RiskMitigation
A session poisons and can't be cleared in placeRecreate 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 readRPC methods mirror the existing store fns 1:1; validate parcel_brief + scout before cutting Turso.
Sync DO SQL API vs async libSQLData 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.

Companion to Market Data Migration (Phase 0, shipped). This spec is the native-storage revision: the relational brain moves from Turso into the market agent's own Durable Object. Build via subagent-driven development, Opus on every delegated agent, branch → PR → squash-merge. · 2026-06-25