Market Brain Native — Implementation Plan

Execution doc for the spec. Move the market agent's relational brain from Turso into its own Durable Object SQLite, vectors to Vectorize, using Flue/Cloudflare as intended. 2026-06-25

Goal: The market agent reads and writes its 3.1M-fact brain from its own DO's native SQLite (local, no hop), vectors live in Cloudflare Vectorize, cross-agent reads go over Durable Object RPC, and Turso leaves the market path — all on the stable north-fork-valley id, cut over in place behind a flag with the live agent never going dark.

Architecture: Port the injected-client data layer (store.ts/parcels.ts) from the async @libsql/client Client to the synchronous native SqlStorage handle (ctx.storage.sql.exec(sql,…b).toArray()). Vectors move to a [[vectorize]] binding. Brain durability rests on a stable id + an R2 backup: a poisoned session is recovered by recreating the agent (green-field sessions) and re-ingesting from R2 — disposable sessions, re-loadable brain (beta.5 has no in-place session-clear; see Phase A). The one-time ingest runs as a Flue scheduleEvery beat reading NDJSON parts from R2. Cross-agent reads (advisor scout, staff tools) call DO-RPC read methods added to the market DO via extend({base}) — bypassing the harness, so the read never wakes the market model. Markdown (wiki, SOUL.md, AGENT.md, JOURNAL.md) lives as files in the DO via the Workspace file API (Phase F). Convex stays the website's serving/reactive layer, untouched.

Tech: TypeScript · @flue/runtime (beta.5) on Cloudflare Workers/DO · Vectorize · R2 · vitest. Build subagent-driven, Opus on every agent.

Global Constraints — every task inherits these

Flue/CF idioms only. Native ctx.storage.sql (no libsql-impersonating adapter); Vectorize binding for vectors; scheduleEvery+this.state for the ingest; DO-RPC (extend({base}) methods + stub) for cross-agent reads; Workspace over ctx.storage.sql for markdown files. No hand-rolled equivalents of things the framework already does.
The live agent never goes dark. north-fork-valley (currently -v9) stays serving from Turso until native is proven; cutover is in place behind a read-source flag, NOT a version bump. No DO rename anywhere in this plan.
Keep source names. Column/value names stay (fact_value_json, bbox_minx/y/x/y where x=lon y=lat). No renaming 3.1M rows.
Convex mirror untouched. tools.ts:304-309,456-460 Convex writes stay; the website's serving path is unaffected.
Secrets & git. Never print/commit secret values. Branch feature/market-brain-native → PR → squash-merge; never commit to main. The final prod flip + TURSO_* secret drop wait behind Pete's PR review.
Tests. vitest. The native data layer is tested via a synchronous fakeSqlStorage() (backed by node:sqlite/better-sqlite3) that presents .exec(sql,…b).toArray(); existing :memory: tests adapt to it. Vectorize/RPC/R2 bindings are mocked.
Phases
  1. A — Recovery model: disposable sessions, re-loadable brain (superseded keystone)
  2. B — Port the data layer to native SQLite + Vectorize
  3. C — One-time ingest beat (R2 → DO SQLite)
  4. D — Cross-agent reads over DO RPC
  5. E — In-place cutover & cut Turso
  6. F — The native file system: wiki & identity (independent of the cutover)

▢ step✚ write failing test▷ run/verify✓ commit

Phase A — Recovery model (superseded keystone) Built, then demoted. The brain's safety comes from a stable id + R2 backup, not from clearing sessions.
Superseded — 2026-06-25. This phase was originally "clear a poisoned session in place so we never rename." We built it (commits e67d3a8…4d57ea0: clearSessionAction + POST /admin/clear-session, 190 tests green) and then hit a real beta.5 wall: an action runs in an isolated child harness with its own scoped sessions, so harness.sessions.delete('default') clears the action's session, not the live poisoned default — and beta.5 exposes no way to run a turn in a named session to reach it. The frame was wrong anyway. A poisoned session is disposable chat history; the brain is what matters, and it's protected by a stable id (never rename) + an R2 backup (Phase C) that makes a clean recreate cost a few-minute re-ingest, not data loss. So: recovery = recreate the agent + re-ingest from R2. The whole Phase-A machinery (adminActions.ts + the route + the agent wiring) was later removed (commit 7c83e51): it broke flue build --target cloudflare (its file sat in src/agents/ with a camelCase agent name) and couldn't clear the live default session. A2/A3 (version-brick fallback, proof-gate) are moot — nothing gates on in-place clear. Prevention (typed tool outputs + the dream beat) keeps poison rare. Pete: green-field on sessions is fine

A1 · Session-clear action + admin route

Create: src/agents/adminActions.ts · Modify: src/agents/market.ts (register action + handle the dispatch input), src/app.ts (admin route) · Test: test/agents/adminActions.test.ts
Produces: clearSessionAction (a defineAction); POST /admin/clear-session/:agent/:id (auth'd) that dispatch()es {kind:'session-clear', target?} into the instance.

A2 · Version-brick fallback (honest)

Modify: src/app.ts · Doc: the spec's "rough edge"
Reality: Flue beta.5 exposes NO public DO-surgery surface, and a PersistedSchemaVersionError throws at harness init before any action can run. So an in-place wipe of flue_* on a wedged-at-boot DO isn't cleanly reachable today.

A3 · Proof-gate — clear in place, brain intact

Test: test/agents/sessionClear.proof.test.ts (integration; vitest-pool-workers if available, else a harnessed DO sim)
Gate: the whole native migration is blocked until this passes.
Phase B — Port the data layer to native SQLite + Vectorize store.ts + parcels.ts speak ctx.storage.sql directly. Vectors → Vectorize. parcel_embeddings leaves the schema.

B1 · The SqlStorage type + fakeSqlStorage() test helper

Create: src/market/sqlh.ts (the handle type + tiny helpers), test/helpers/fakeSqlStorage.ts
Produces: type Sql = { exec(q:string, ...b:unknown[]): { toArray(): Array<Record<string,unknown>> } } (matches Flue's SqlStorage, sql-storage-*.d.mts:1-14). fakeSqlStorage(): Sql over a synchronous sqlite (node:sqlite DatabaseSync, else better-sqlite3).

B2 · Port store.ts (listings/reports) to native SQL

Modify: src/market/store.ts · Test: test/market/store.test.ts
Mechanical transform, every data fn: signature (c: Client, …) → (sql: Sql, …); body await c.execute({sql,args}) → r.rows → sql.exec(query, ...args).toArray(). Functions stay async (call sites unchanged). Drop getTursoClient from this file's hot path (kept only behind the cutover flag — Phase E). Affected: ensureSchema, upsertListing, getListing, getListingBySlug, writeReport, listListings, markBriefed (store.ts:66-277).

B3 · Port parcels.ts reads; remove parcel_embeddings

Modify: src/market/parcels.ts · Test: test/market/parcels.test.ts
Port ensureParcelSchema, getParcel, getParcelFacts, parcelForListing, parcelsByBbox (same transform as B2). Remove the parcel_embeddings CREATE + parcel_emb_idx from ensureParcelSchema (parcels.ts:139-146). Vector fns move to B4.

B4 · Vector path → Vectorize binding

Create: src/market/vectors.ts (the Vectorize fns — keeps parcels.ts pure native-SQL) · Modify: src/market/parcels.ts (remove the vector fns + parcel_embeddings), wrangler.jsonc (add [[vectorize]]) · Test: test/market/vector.test.ts — shipped: vector fns landed in a NEW vectors.ts, cleaner than in-place, functionally identical
New signatures: upsertParcelEmbedding(vx: VectorizeIndex, rec:{parcelId,embedding:number[],kind,model?,text?}): Promise<void> → vx.upsert([{id:parcelId, values:embedding, metadata:{kind,market}}]). vectorSearchParcels(vx, sql, queryVec, k, {kind}): Promise<ParcelMatch[]> → vx.query(queryVec,{topK:k,filter:{kind}}) → ids → join to local facts via sql. Same ParcelMatch shape.

B5 · Market agent + its own tools use the native handle

Modify: src/market/tools.ts (sweep/drain/metrics), src/agents/market.ts
Inside the market DO, tools get the handle via getCloudflareContext().storage.sql instead of await store.getTursoClient(). Gate behind the read-source flag (Phase E) so prod still works pre-cutover. Convex mirror calls (tools.ts:304-309,456-460) unchanged.
Phase C — One-time ingest beat (R2 → DO SQLite) Flue scheduleEvery + this.state progress, reading NDJSON parts from R2. Resumable, self-stopping. No bulk-import button — this is the substitute.

C1 · Export the brain to R2 as NDJSON parts

Create: scripts/export-brain-ndjson.mjs (ops, not shipped) · R2: market-brain/north-fork-valley/{parcels,parcel_facts,parcel_listings,pending_resolution}/part-NNNN.ndjson
Source: the cleaned local hz-import.db (or current prod Turso). Each table → line-delimited JSON, chunked (e.g. 20k rows/part). Parsing a 764 MB sqlite binary inside a Worker is impractical; NDJSON range-reads are Worker-native.

C2 · The ingest beat (extend + scheduleEvery)

Create/Modify: the market agent's cloudflare = extend({base}) block (in src/agents/market.ts or a new src/agents/market.cf.ts), wrangler.jsonc (R2 binding) · Test: test/market/ingest.test.ts
Method async ingestChunk(): read this.state.cursor (table + part index), pull that part from env.BRAIN_BUCKET, INSERT OR IGNORE its rows into getCloudflareContext().storage.sql, advance the cursor in this.state, await this.schedule(3,'ingestChunk') until all tables/parts done, then mark done. Idempotent (re-runnable). Triggered by an auth'd POST /admin/ingest/:id that dispatches the first beat.

C3 · Count-verify endpoint

Modify: the extend block (add async counts() RPC) or an auth'd read route
Phase D — Cross-agent reads over DO RPC Flue has no agent-RPC abstraction; the CF-native mechanism is DO RPC. Market DO exposes read methods; advisor + staff call the stub. Convex untouched.
First step resolves the stub mechanism: the implementer confirms how to obtain a callable stub to the Flue-generated FlueMarketAgent DO from another agent (a declared durable_objects.bindings entry for the class + getByName/idFromName). If a direct stub proves awkward in Flue beta.5, fall back to an auth'd read route on the market agent that runs the query inside the DO and returns JSON. Either way the query executes inside the market DO against its own storage.sql.

D1 · Market DO read methods (RPC)

Modify: the market extend({base}) block, wrangler.jsonc (DO binding for cross-agent calls) · Test: test/market/rpc.test.ts
Methods: async listListingsRpc(limit=60) → store.listListings(getCloudflareContext().storage.sql, limit); async parcelBriefRpc(idOrZpid) → the parcel-brief assembly (parcels.* against the native handle, PII-filtered). Run inside the DO.

D2 · Re-point staff tools to the stub

Modify: src/staff/marketTools.ts (list_listings, parcel_brief) · Test: test/staff/marketTools.test.ts
Was: const c = await store.getTursoClient(); … store.listListings(c,…). Now: resolve the market DO stub for marketSlug and await stub.listListingsRpc(…) / stub.parcelBriefRpc(…). The toolset shape (the 9-name list the factory test pins) is unchanged.

D3 · Re-point advisor scout to the stub

Modify: src/advisor/marketRead.ts (readActiveListings + callers) · Test: test/advisor/marketRead.test.ts
Was: readActiveListings(c: Client,…) over shared env.TURSO_DATABASE_URL. Now: a market-DO-stub read (add a listForSaleRpc(limit) method in D1 if the existing listListingsRpc doesn't carry the FOR_SALE filter). The advisor stops binding Turso entirely.
Phase E — In-place cutover & cut Turso Same stable id, read-source flag, ingest-then-flip. Final flip + secret drop gated on Pete's PR review. The live agent never goes dark.

E1 · Read-source flag

Modify: src/market/tools.ts, src/market/store.ts client acquisition · Test: exists in B5
MARKET_STORE=turso|native (Worker var). turso → old getTursoClient() path; native → getCloudflareContext().storage.sql. Default turso at deploy so prod is unchanged until we flip.

E2 · Deploy native code (flag=turso) + run ingest into the live DO

E3 · Flip to native + validate

E4 · Cut Turso

Modify: store.ts (remove getTursoClient + the transition shim), remove @libsql/client from the market path, wrangler.jsonc
Phase F — The native file system: wiki & identity A DO has no POSIX disk; its SQLite is the disk. The Workspace file API gives real file I/O over it. Independent of the Turso cutover — buildable any time.
Scope, honestly. This phase stands up the file home only. The rich wiki authoring (interlinked briefs / comps engine) and the dream beat's JOURNAL.md rewrite are existing deferred work — they now have a native place to live, but they are not built here and are not needed for the migration.

F1 · Stand up the Workspace file layer in the market agent

Modify: src/agents/market.ts (or a small src/market/files.ts helper), wrangler.jsonc (the worker_loaders binding only if the shell sandbox is used) · Test: test/market/files.test.ts
Produces: a marketFiles(): WorkspaceFileSystem helper resolving new Workspace({ sql: getCloudflareContext().storage.sql }) (mirror getDefaultWorkspace() in src/sandboxes/cloudflare-shell.ts:232). File ops: read(path), write(path, content), list(prefix) over wiki/*.md, SOUL.md, AGENT.md, JOURNAL.md.

F2 · Prove durability live (deploy-time, with E)

Self-review checklist

Spec requirementCovered by
Brain in DO SQLite, native API (no libsql shim)B1–B3, B5
Vectors in Vectorize, none in the DOB3 (drop table), B4
Sessions disposable; brain re-loadable (stable id + R2), never renameA (recovery model), C (R2 backup), E (stable id, in-place flip)
Markdown (wiki, SOUL/AGENT/JOURNAL) lives as DO files via WorkspaceF1–F2
Cross-agent reads via DO RPC; Convex untouchedD1–D3; Convex calls untouched in B5
One-time ingest, Flue scheduleEvery, R2C1–C3
Live agent never dark; in-place cutover; Pete-gated flipE1–E4
Keep source column/value namesB2–B3 (SQL verbatim)

Execution: subagent-driven, Opus on every agent, fresh subagent per task with a review between. Branch feature/market-brain-native → PR for Pete. Phases A–D + E1/E4-code are branch work; E2/E3 (ingest into prod, the flip) + the secret drop are deploy-time ops gated on Pete's review. Companion to the spec. · 2026-06-25