Advisor Brain Native — Implementation Plan

Execution doc for the spec. Move each buyer's advisor brain off its per-buyer Turso database into the advisor's own Durable Object SQLite. Hard cut, no flag. 2026-06-25

Goal: A brand-new advisor is born without touching Turso — its brain (yearnings, skill metrics/params, files, sessions) lives in its own DO's native SQLite — and the existing smoke test births one and watches it read the live v10 market over RPC. When that's green, the last four TURSO_* secrets are dropped and HomeZero is fully off Turso.

Architecture: The advisor is one Durable Object per buyer (/agents/advisor/:userId, userId = ctx.id), so its per-buyer Turso DB maps 1:1 onto that DO's own SQLite — no shared store, no cross-agent RPC, and (a full grep confirms) no vectors. The port is mechanical: every brainStore.ts data fn already takes an injected client, so the async @libsql/client Client (c.execute({sql,args}) → {rows}) becomes the synchronous native handle (sql.exec(query, ...binds).toArray()) — the exact transform the market's store.ts/parcels.ts already proved. The provisioning machinery (provisionAdvisorDb, getAdvisorTursoClient, getReadyBrainClient, advisorDbName) is deleted: the DO's SQLite is simply there. The already-injectable getBrainClient seam means tools/tests barely move — the default flips from "provision+connect a libSQL client" to "return getCloudflareContext().storage.sql", and tests swap their libSQL :memory: client for the existing fakeSqlStorage(). Schema is ensured once at boot in onStart via this.ctx.storage.sql. The advisor's market read is already native (MARKET_STORE=native → listForSaleRpc) and is left alone.

Tech: TypeScript · @flue/runtime (beta.5) on Cloudflare Workers/DO · vitest · node:sqlite (fakeSqlStorage). 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). Tools + the file sandbox resolve the handle via getCloudflareContext().storage.sql (valid in request and alarm-beat model turns); the boot-time schema-ensure uses this.ctx.storage.sql directly (no async-context dependency). No cross-agent RPC reads an advisor brain — if one is ever added it MUST use this.ctx, never getCloudflareContext().
Hard cut — no flag. Unlike the market's MARKET_STORE, there is NO ADVISOR_STORE fallback. Delete the Turso path outright. Green-field: no real buyers, old advisor sessions already bricked, existing per-buyer Turso DBs abandoned (no data migration).
Keep SQL verbatim. Table DDL, column names, and SQL text are preserved exactly (fact_value_json-style names, the COALESCE-preserving upserts, the MAX(0,MIN(1,…)) confidence clamp). The port changes the handle, never the SQL.
No vectors. The advisor brain is purely relational. Nothing moves to Vectorize; there is no embedding code to touch.
The advisor's market read is out of scope. read_market_listings/scout already reads the market DO over RPC behind the live MARKET_STORE=native. Don't refactor it — except the dead Turso fallback branch, removed in Phase D.
Secrets & git. Never print/commit secret values. Branch feature/advisor-brain-native → PR → squash-merge; never commit to main. The live E2E birth (Phase C) and the secret drop + DB destroy (Phase D2) are deploy-time ops gated on Pete.
Tests. vitest. The native data layer is tested via the existing fakeSqlStorage() (test/helpers/fakeSqlStorage.ts, backed by node:sqlite) presenting .exec(sql,…b).toArray(); the libSQL :memory: clients in advisor tests swap to it.
Phases
  1. 0 — Shared SQL handle
  2. A — Port brainStore.ts to native SQLite
  3. B — Re-wire the access points (sandbox, tools, onStart)
  4. C — End-to-end proof (birth + market read)
  5. D — Cut the last Turso

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

Phase 0 — Shared SQL handle Lift the Sql type out of the market so both agents import one definition.

0.1 · Lift Sql/SqlResult to a shared module

Create: flue/src/sqlh.ts · Modify: flue/src/market/sqlh.ts (re-export) · Test: existing market + helper suites (no new test)
Produces: export interface SqlResult { toArray(): Array<Record<string,unknown>> } and export interface Sql { exec(query:string, ...bindings:unknown[]): SqlResult } at src/sqlh.ts — moved verbatim from src/market/sqlh.ts. Consumed by: the advisor brain store (Phase A) and the existing market store, both via Sql.
Phase A — Port brainStore.ts to native SQLite One file, one atomic mechanical port. Delete provisioning; every data fn speaks ctx.storage.sql.

A.1 · Port the whole brain store to the native handle

Modify: flue/src/advisor/brainStore.ts · Test: flue/test/advisor/brainStore.test.ts
Delete (the provisioning machinery — all Turso): provisionAdvisorDb, getAdvisorTursoClient, getReadyBrainClient (+ the readyClients map), advisorDbName, advisorEnv, the AdvisorTursoEnv interface, and import type { Client } from '@libsql/client'. Add import type { Sql } from '../sqlh';.
Mechanical transform, every remaining fn (ensureBrainSchema, upsertYearning, seedYearnings, listYearnings, reweightYearning, writeMetric, readParams, writeParam, appendSessionEntries, readRecentSessions, writeFileRow, readFileRow, listFilePaths, deleteFileRow, fileExists, searchFiles, saveFiles, loadFiles):
// signature
(c: Client, …)              →   (sql: Sql, …)
// no-arg / template SQL
await c.execute(`SELECT …`)              →   sql.exec(`SELECT …`).toArray()
// parameterized SQL  (args array → VARIADIC binds)
await c.execute({ sql: `… ?`, args:[a,b] })  →   sql.exec(`… ?`, a, b).toArray()
// result rows
res.rows                    →   the array returned by .toArray()
res.rows[0]                 →   rows[0]
res.rows.length             →   rows.length
Functions keep their async signatures (the sync handle drops straight in; callers' await is unchanged). SQL text + column names verbatim. Keep the name ensureBrainSchema (advisorFs + tools import it).
Phase B — Re-wire the access points The sandbox, the tools, and onStart resolve the native handle. Provisioning calls disappear.

B.1 · The brainSql() accessor

Create: flue/src/advisor/brainSql.ts · Test: none (thin context wrapper; exercised live)
Produces: export function brainSql(): Sql { return getCloudflareContext().storage.sql as unknown as Sql; } (import getCloudflareContext from '@flue/runtime/cloudflare', Sql from '../sqlh') — mirrors the market's marketSql() (src/market/tools.ts:41). Node-importable (the import only throws when called outside a CF context; tools/tests inject instead). Used by B.2 (tools default) and B.3 (advisorFs).

B.2 · Tools read the native handle; finish_birth stops provisioning

Modify: flue/src/advisor/tools.ts · Test: flue/test/advisor/birth-brain.test.ts, flue/test/advisor/read.test.ts, flue/test/advisor/brain-tools.test.ts
The getBrainClient seam flips type, not shape. Deps.getBrainClient?: (userId: string) => Promise<Sql> (was Promise<Client>); default in BOTH makeAdvisorTools (line 106) and checkIdentityTool (line 402) becomes deps.getBrainClient ?? (async () => brainSql()) (drop the getReadyBrainClient import). Every const c = await getBrainClient(ctx.userId) call site is unchanged — c is now an Sql, and writeIdentity(makeAdvisorSandboxApi(c), …), listYearnings(c), readParams(c,…), upsertYearning(c,…), writeParam(c,…) all now take Sql (ported in A.1 / B.3).
finish_birth (line 150): keep await ensureBrainSchema(c) (idempotent, defensive — onStart also ensures), keep await seedYearnings(c, answers) and markBorn and the best-effort Slack channel. Remove nothing else; the provisioning is gone because getBrainClient no longer provisions.

B.3 · The file sandbox on the native handle

Modify: flue/src/advisor/advisorFs.ts · Test: flue/test/advisor/advisorFs.test.ts
makeAdvisorSandboxApi(c: Client) → makeAdvisorSandboxApi(sql: Sql): change the type + the inner readFileRow(c,…)/writeFileRow(c,…)/listFilePaths(c,…)/deleteFileRow(c,…)/fileExists(c,…)/searchFiles(c,…) calls to pass sql. Drop the provisionAdvisorDb, getAdvisorTursoClient, ensureBrainSchema, Client imports that the factory used. advisorFs(userId).createSessionEnv becomes: const sql = brainSql(); return createSandboxSessionEnv(makeAdvisorSandboxApi(sql), '/'); — no provision, no connect, no per-init schema-ensure (onStart owns schema). Keep the userId param for symmetry/logging.
Context check (do this, don't assume): confirm createSessionEnv runs inside Flue's async context (so brainSql() resolves). The market's tools prove getCloudflareContext() works in model-turn scope. If a deploy smoke shows createSessionEnv firing OUTSIDE the context (a throw), fall back to lazy resolution: pass makeAdvisorSandboxApi a () => Sql thunk and resolve per file-op (every op runs in a model turn = in-context). Prefer the simple form; keep the thunk in your pocket.

B.4 · onStart ensures the schema at boot

Modify: flue/src/agents/advisor.ts · Test: flue/test/advisor/rhythm.test.ts
In the cloudflare = extend({ base }) block's onStart(), ensure the five brain tables exist BEFORE the rhythmOn guard (so a future schema add reaches already-armed DOs too): at the top of onStart, await ensureBrainSchema((this as unknown as { ctx:{ storage:{ sql: unknown } } }).ctx.storage.sql as Sql); (mirrors the market's ingestSql() access, src/agents/market.ts:173) — using this.ctx, NOT getCloudflareContext(), because onStart may run outside the async context. Then the existing if (this.state?.rhythmOn) return guard + the four schedule calls. Import ensureBrainSchema from '../advisor/brainStore' and Sql from '../sqlh'. Update the stale "Turso brain" comment on the sandbox field (line 55-58) to "the DO's own native SQLite".
Phase C — End-to-end proof (birth + market read) Deploy the branch; the existing smoke test births a native advisor that reads the live v10 market. Deploy-time, gated on Pete.

C.1 · Deploy + run the advisor smoke test

Use: flue/scripts/smoke.sh (unchanged) · Deploy: homezero-flue Worker
Pass criteria (all three): (1) team:getForUser reaches status=born within the poll window with no provisionAdvisorDb 403 in the logs; (2) the follow-up message yields an in-persona reply whose brain_read reflects the seeded yearnings (native SQLite); (3) a scout/board read returns live v10 market listings over RPC. Turso is NOT touched anywhere in the run.
Phase D — Cut the last Turso Grep-clean dead refs; then drop the four secrets + destroy the group. HomeZero fully off Turso.

D.1 · Grep-clean dead Turso refs in the advisor path

Modify: flue/src/advisor/marketRead.ts (drop the dead turso fallback branch + getTursoClient import), flue/src/advisor/tools.ts (drop the getMarketClient = … ?? getTursoClient default + import) · Test: flue/test/advisor/marketRead.test.ts, flue/test/advisor/scout.test.ts
Why safe: MARKET_STORE=native is live and the market's Turso DB is already destroyed, so the turso branch of scoutMarketListings is dead code that would throw if ever hit. Make the advisor's market read RPC-only: scoutMarketListings always resolves the market DO stub (listForSaleRpc). Keep the injectable getReadStub seam for tests.

D.2 · Drop the four secrets + destroy the group (gated on Pete + a proven native birth)

Worker: remove secrets · Turso: destroy the advisors group DBs
After Phase C is green in practice and D.1 is deployed: remove TURSO_API_TOKEN, TURSO_ORG, TURSO_ADVISOR_GROUP, TURSO_ADVISOR_GROUP_TOKEN from the homezero-flue Worker (wrangler secret delete), and destroy the abandoned per-buyer DBs in the homezero-advisors group (turso db list → turso db destroy <name> --yes). No real-buyer data is lost (green-field).

Self-review checklist

Spec requirementCovered by
Advisor brain in its own DO's native SQLite (no libSQL shim)0.1, A.1, B.1–B.3
Provisioning deleted → birth touches no Turso (the unblock)A.1 (delete provision machinery), B.2 (finish_birth), C.1 (proof)
All five tables + behaviors identical (yearnings seed, reweight clamp, metrics/params, files, sessions)A.1 (verbatim SQL), brainStore/advisorFs/birth-brain tests
Schema ensured at boot via this.ctx.storage.sqlB.4
Tools + sandbox use getCloudflareContext().storage.sql (request + alarm-beat valid)B.1–B.3
Hard cut, no flag; green-field (no data migration)whole plan; A.1 deletions; D.2 destroy
No vectors touchedn/a (none exist) — confirmed by grep in the spec
Advisor's market read unchanged (already native RPC)out of scope; D.1 only removes the dead Turso fallback
E2E births a native advisor that reads the live v10 marketC.1
Last four TURSO_* secrets dropped → fully off TursoD.2

Execution: subagent-driven, Opus on every agent, fresh subagent per task with a review between. Branch feature/advisor-brain-native → PR for Pete. Phases 0–B + D.1-code are branch work; C.1 (deploy + live smoke) and D.2 (secret drop + DB destroy) are deploy-time ops gated on Pete's review. Companion to the spec and to the market plan. · 2026-06-25