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.
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.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.fact_value_json, bbox_minx/y/x/y where x=lon y=lat). No renaming 3.1M rows.tools.ts:304-309,456-460 Convex writes stay; the website's serving path is unaffected.feature/market-brain-native → PR → squash-merge; never commit to main. The final prod flip + TURSO_* secret drop wait behind Pete's PR review.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.▢ step✚ write failing test▷ run/verify✓ commit
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 finesrc/agents/adminActions.ts · Modify: src/agents/market.ts (register action + handle the dispatch input), src/app.ts (admin route) · Test: test/agents/adminActions.test.tsclearSessionAction (a defineAction); POST /admin/clear-session/:agent/:id (auth'd) that dispatch()es {kind:'session-clear', target?} into the instance.{kind:'session-clear'} routes to the action and calls harness.sessions.delete('default') (mock harness; assert the delete call + that the action runs in a non-default session).clearSessionAction = defineAction({ name:'admin_clear_session', async run({harness,input}){ await harness.sessions.delete(input?.target ?? 'default'); return {ok:true}; } }).market.ts: register the action and wire the agent so a dispatched kind:'session-clear' input invokes it deterministically (not via the model), running in an admin session so it isn't deleting its own session. Confirm the exact dispatch→action wiring against @flue/runtime (the implementer reads the action/dispatch docs).app.ts (before app.route('/', flue())): add the auth-gated route that dispatch({agent,id,input:{kind:'session-clear',target}}) and returns 202. Copy the AUTH_PASSWORD gate from market.ts:17-29.npx vitest run test/agents/adminActions.test.ts → green.src/app.ts · Doc: the spec's "rough edge"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.flue_* session rows are at issue.test/agents/sessionClear.proof.test.ts (integration; vitest-pool-workers if available, else a harnessed DO sim)CREATE TABLE probe; INSERT) in the DO's SQLite; seed/poison a default session; invoke the clear path; assert (1) the session is gone, (2) probe is byte-for-byte intact, (3) the DO id is unchanged.SessionBusyError fires, adjust which session the clear runs in until it's reliably non-busy, then re-run.ctx.storage.sql directly. Vectors → Vectorize. parcel_embeddings leaves the schema.
SqlStorage type + fakeSqlStorage() test helpersrc/market/sqlh.ts (the handle type + tiny helpers), test/helpers/fakeSqlStorage.tstype 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).fakeSqlStorage() round-trips exec("CREATE…"); exec("INSERT… VALUES (?)", x); exec("SELECT…").toArray() with correct binding + row shape..exec().toArray() facade). No libsql.npx vitest run test/helpers → green.store.ts (listings/reports) to native SQLsrc/market/store.ts · Test: test/market/store.test.ts(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).store.test.ts: beforeEach uses fakeSqlStorage() instead of createClient({url:':memory:'}); assertions unchanged.npx vitest run test/market/store.test.ts → green.parcels.ts reads; remove parcel_embeddingssrc/market/parcels.ts · Test: test/market/parcels.test.tsensureParcelSchema, 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.parcels.test.ts to fakeSqlStorage(); drop the parcel_embeddings schema assertion; keep parcels/facts/bbox/link tests.npx vitest run test/market/parcels.test.ts → green.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 identicalupsertParcelEmbedding(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.upsert/query); assert vectorSearchParcels queries Vectorize then joins returned ids to fakeSqlStorage() facts, in score order."vectorize":[{"binding":"VECTORIZE","index_name":"market-embeddings"}] to wrangler.jsonc.npx vitest run test/market/vector.test.ts → green. (No writes yet — index stands up empty; on-the-fly writes ride the Phase-2 comps build.)src/market/tools.ts (sweep/drain/metrics), src/agents/market.tsgetCloudflareContext().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.getCloudflareContext); Convex mirror calls still fire.store.*/parcels.* call (signatures now take sql).npx vitest run test/market → green; npx tsc --noEmit clean.scheduleEvery + this.state progress, reading NDJSON parts from R2. Resumable, self-stopping. No bulk-import button — this is the substitute.
scripts/export-brain-ndjson.mjs (ops, not shipped) · R2: market-brain/north-fork-valley/{parcels,parcel_facts,parcel_listings,pending_resolution}/part-NNNN.ndjsonhz-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.sqlite3 -json, writes parts, uploads to R2 with wrangler r2 object put or the R2 API). Record part counts.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.tsasync 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.fakeSqlStorage(); run the beat to completion; assert rows inserted, cursor advances, re-running is a no-op (OR IGNORE), done set."r2_buckets":[{"binding":"BRAIN_BUCKET","bucket_name":"market-brain"}]) + the kickoff route.npx vitest run test/market/ingest.test.ts → green.extend block (add async counts() RPC) or an auth'd read routeFlueMarketAgent 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.extend({base}) block, wrangler.jsonc (DO binding for cross-agent calls) · Test: test/market/rpc.test.tsasync 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.fakeSqlStorage(); assert listings/brief shapes match today's tool output (PII filtered).extend; declare the cross-agent DO binding.npx vitest run test/market/rpc.test.ts → green.src/staff/marketTools.ts (list_listings, parcel_brief) · Test: test/staff/marketTools.test.tsconst 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.list_listings/parcel_brief call the RPCs and format identically; the factory tool-name list still equals the pinned 9.npx vitest run test/staff → green.src/advisor/marketRead.ts (readActiveListings + callers) · Test: test/advisor/marketRead.test.tsreadActiveListings(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.ScoutListing[] shape.npx vitest run test/advisor → green; npx tsc --noEmit clean.src/market/tools.ts, src/market/store.ts client acquisition · Test: exists in B5MARKET_STORE=turso|native (Worker var). turso → old getTursoClient() path; native → getCloudflareContext().storage.sql. Default turso at deploy so prod is unchanged until we flip.homezero-flue with MARKET_STORE=turso. Prod serves from Turso exactly as before; the DO's native SQLite is still empty.POST /admin/ingest/north-fork-valley-v9; watch it run to done via the count endpoint (C3). Also copy current live listings + market_reports from Turso into the DO (one-time) so the flip has live data, not just the static brain.MARKET_STORE=native; redeploy. The agent now reads/writes its own SQLite./home metrics tiles, the Slack desk (parcel_brief, list_listings), a sweep + a metrics beat, and the advisor scout's next read — all green off native. Session-clear route exercised once.store.ts (remove getTursoClient + the transition shim), remove @libsql/client from the market path, wrangler.jsoncturso branch of the flag + getTursoClient + the libsql import from the market path. Grep-clean @libsql/client / TURSO_* in the market path → zero.tsc; deploy. Then drop TURSO_DATABASE_URL/TURSO_AUTH_TOKEN from the Worker (last, after a clean soak).Workspace file API gives real file I/O over it. Independent of the Turso cutover — buildable any time.
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.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.tsmarketFiles(): 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.fakeSqlStorage()-backed): write SOUL.md then read it back byte-for-byte; list('wiki/') returns written paths; a second resolve of the handle sees the same files (persistence, not in-memory).SOUL.md/AGENT.md with the agent's current persona (lift from src/market/prompts.ts so identity has one home, not two).npx vitest run test/market/files.test.ts → green; npx tsc --noEmit clean.| Spec requirement | Covered by |
|---|---|
| Brain in DO SQLite, native API (no libsql shim) | B1–B3, B5 |
| Vectors in Vectorize, none in the DO | B3 (drop table), B4 |
| Sessions disposable; brain re-loadable (stable id + R2), never rename | A (recovery model), C (R2 backup), E (stable id, in-place flip) |
| Markdown (wiki, SOUL/AGENT/JOURNAL) lives as DO files via Workspace | F1–F2 |
| Cross-agent reads via DO RPC; Convex untouched | D1–D3; Convex calls untouched in B5 |
| One-time ingest, Flue scheduleEvery, R2 | C1–C3 |
| Live agent never dark; in-place cutover; Pete-gated flip | E1–E4 |
| Keep source column/value names | B2–B3 (SQL verbatim) |