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.
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().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).fact_value_json-style names, the COALESCE-preserving upserts, the MAX(0,MIN(1,…)) confidence clamp). The port changes the handle, never the SQL.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.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.fakeSqlStorage() (test/helpers/fakeSqlStorage.ts, backed by node:sqlite) presenting .exec(sql,…b).toArray(); the libSQL :memory: clients in advisor tests swap to it.brainStore.ts to native SQLite▢ step✚ write failing test▷ run/verify✓ commit
Sql type out of the market so both agents import one definition.
Sql/SqlResult to a shared moduleflue/src/sqlh.ts · Modify: flue/src/market/sqlh.ts (re-export) · Test: existing market + helper suites (no new test)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.src/sqlh.ts (copy the doc comment too). In src/market/sqlh.ts, replace the bodies with export type { Sql, SqlResult } from '../sqlh'; so every existing from '../market/sqlh' / from './sqlh' import (incl. test/helpers/fakeSqlStorage.ts) keeps resolving unchanged.npx vitest run (market + helpers) and npx tsc --noEmit → both clean. Zero market behavior change.brainStore.ts to native SQLite
One file, one atomic mechanical port. Delete provisioning; every data fn speaks ctx.storage.sql.
flue/src/advisor/brainStore.ts · Test: flue/test/advisor/brainStore.test.tsprovisionAdvisorDb, getAdvisorTursoClient, getReadyBrainClient (+ the readyClients map), advisorDbName, advisorEnv, the AdvisorTursoEnv interface, and import type { Client } from '@libsql/client'. Add import type { Sql } from '../sqlh';.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).test/advisor/brainStore.test.ts: replace the libSQL client setup (createClient({ url: ':memory:' })) with const sql = fakeSqlStorage() (import from ../helpers/fakeSqlStorage); pass sql everywhere a c was passed; drop any provisionAdvisorDb/advisorDbName tests (those fns are gone). All behavioral assertions stay identical — seeded core yearnings, COALESCE-preserving re-seed, the confidence clamp, searchFiles line numbers, the listFilePaths prefix rule.npx vitest run test/advisor/brainStore.test.ts → expect FAIL first (signatures/import not yet ported).brainStore.ts per the rule above.npx vitest run test/advisor/brainStore.test.ts → green. Then npx tsc --noEmit — expect errors ONLY at the not-yet-ported call sites (advisorFs.ts, tools.ts), which Phase B fixes.brainSql() accessorflue/src/advisor/brainSql.ts · Test: none (thin context wrapper; exercised live)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).npx tsc --noEmit on the file's importers later; nothing to unit-test in isolation.finish_birth stops provisioningflue/src/advisor/tools.ts · Test: flue/test/advisor/birth-brain.test.ts, flue/test/advisor/read.test.ts, flue/test/advisor/brain-tools.test.tsgetBrainClient 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.getBrainClient = async () => c, instead do const sql = fakeSqlStorage(); await ensureBrainSchema(sql); getBrainClient = async () => sql;. Assertions (born marker, seeded yearnings via brain_read, the COALESCE preserve, params) stay identical.npx vitest run test/advisor/birth-brain.test.ts test/advisor/read.test.ts test/advisor/brain-tools.test.ts → expect FAIL (default still imports the deleted getReadyBrainClient).getReadyBrainClient import; import brainSql.npx vitest run test/advisor → green; npx tsc --noEmit clean (advisorFs handled in B.3 — sequence B.3 before the final tsc if needed).flue/src/advisor/advisorFs.ts · Test: flue/test/advisor/advisorFs.test.tsmakeAdvisorSandboxApi(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.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.advisorFs.test.ts: the pure-seam tests build makeAdvisorSandboxApi(c) with a libSQL client — swap to const sql = fakeSqlStorage(); await ensureBrainSchema(sql); const api = makeAdvisorSandboxApi(sql);. All filesystem assertions (read/write/stat/readdir/exists/rm, the grep rg + glob find command parsing) stay identical.npx vitest run test/advisor/advisorFs.test.ts → FAIL first (signature).advisorFs.ts.npx vitest run test/advisor/advisorFs.test.ts → green; npx tsc --noEmit across flue/ → clean.onStart ensures the schema at bootflue/src/agents/advisor.ts · Test: flue/test/advisor/rhythm.test.tscloudflare = 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".rhythm.test.ts (which already drives onStart against a fake base): give the fake base a ctx = { storage: { sql: fakeSqlStorage() } }; assert onStart creates the tables (e.g. ctx.storage.sql.exec("SELECT name FROM sqlite_master WHERE type='table'").toArray() includes yearnings, files, skill_metrics, skill_params, sessions) AND still arms the four alarms once (existing assertions).npx vitest run test/advisor/rhythm.test.ts → FAIL first.ensureBrainSchema call + imports + comment fix.npx vitest run test/advisor/rhythm.test.ts → green; full npx vitest run + npx tsc --noEmit + npx flue build --target cloudflare → all clean.flue/scripts/smoke.sh (unchanged) · Deploy: homezero-flue Workerteam: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.npx flue build --target cloudflare then wrangler deploy from flue/ (follows .wrangler/deploy/config.json → homezero-flue; NOT homezero). The four TURSO_* advisor secrets are still present but now unused by the advisor path.flue/scripts/smoke.sh with a fresh devUserId (Paonia/covered market). Watch the advisor logs (wrangler tail or the SSE read route) for the birth turn → finish_birth → born, confirming no Turso call. Pete reviews the PR + this run here.createSessionEnv → apply the B.3 thunk fallback), fix-forward on the branch, redeploy, re-run.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.tsMARKET_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.marketRead.test.ts/scout.test.ts: drop the turso-branch cases; keep the RPC-path assertions (same ScoutListing[] shape via the mocked stub).turso branch + getTursoClient import from marketRead.ts and the unused default from tools.ts. Grep the advisor path: grep -rn "TURSO_ADVISOR\|getAdvisorTursoClient\|provisionAdvisorDb\|@libsql/client" src/advisor → expect ZERO hits.npx vitest run test/advisor → green; npx tsc --noEmit + npx flue build --target cloudflare → clean. Deploy.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).grep -rn "TURSO_" flue/src → only market-path matches (if any) remain; the advisor path is clean.smoke.sh once more → still births (proves nothing read them).homezero-advisors group DBs. HomeZero is fully off Turso. Update the memory + the spec's status.| Spec requirement | Covered 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.sql | B.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 touched | n/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 market | C.1 |
Last four TURSO_* secrets dropped → fully off Turso | D.2 |