The last mile off Turso: move each buyer's advisor brain off its per-buyer Turso database and into the advisor's own Durable Object SQLite. Decision + build spec · 2026-06-25
In one breath: We moved the market brain off Turso — but every buyer's advisor still mints its own per-buyer Turso database the moment it's born (provisionAdvisorDb → a fresh DB in the homezero-advisors group). That's the last Turso dependency, and it's the one blocking new-advisor birth right now: loading the 3.1M-fact market brain into Turso this month blew the org's rows-written quota, so the provisioning API 403s and no new advisor can be created. The fix is the same move, cleaner: an advisor is one Durable Object per buyer (/agents/advisor/:userId), so its brain maps 1:1 onto that DO's own native SQLite — no shared store, no cross-agent RPC, and (unlike the market) zero vectors to relocate. Provisioning disappears entirely: the DO's SQLite is just there, so birth stops calling Turso → the quota wall vanishes. Hard cut, no fallback flag, green-field (no real buyers yet, old sessions already bricked). When it's proven by an end-to-end birth, we drop the last four TURSO_* secrets and HomeZero is fully off Turso.
"We're off Turso" was true for the market only. The advisor half was never touched, and it has a live Turso dependency on the hottest path there is — birth:
finish_birth calls provisionAdvisorDb(userId), which POSTs to the Turso Platform API to create a brand-new per-buyer database (adv-<slug> in the homezero-advisors group), then connects and seeds yearnings. Every new advisor = one new Turso DB.
That's why birthing is blocked right now. Ingesting the market's 3.1M facts into Turso for the market migration drove the org's monthly rows-written metric to ~156% of the plan cap with overages disabled — so the org is "blocked from creating databases" and provisionAdvisorDb returns 403. The irony writes itself: the market load we just removed from Turso is what's strangling the advisor that still depends on it. Deleting a DB won't help (rows-written is cumulative-for-the-month); the only ways out are a billing change, the month rollover, or — properly — not needing Turso at birth at all.
| Turso's role for the advisor | Status after this migration |
|---|---|
| Per-buyer brain database (yearnings, skill metrics/params, files, sessions) | gone Each advisor's brain lives in its own DO's SQLite, keyed to the DO id (= userId). Local, microsecond reads, no vendor. |
| Provisioning at birth (Platform API create-database) | deleted The DO's SQLite always exists. No API call → no quota → birth unblocked. |
Group token + 4 secrets (TURSO_API_TOKEN, TURSO_ORG, TURSO_ADVISOR_GROUP, TURSO_ADVISOR_GROUP_TOKEN) | retired Dropped after the native birth is proven. With these gone, HomeZero touches no Turso anywhere. |
This is the companion finish to Market Brain Goes Native. Same thesis — the agent's private brain belongs in its own DO, with Flue's grain, not over an HTTP hop to an external vendor — applied to the only remaining holdout.
The advisor migration is strictly simpler than the market's, for two structural reasons. Both are worth stating plainly, because they delete whole categories of work the market needed.
An advisor runs at /agents/advisor/:userId with userId = ctx.id. The brain is per-buyer — and so is the DO. So the per-buyer Turso DB drops exactly onto that one DO's own SQLite.
The market needed a shared store read by many agents → it had to build cross-agent DO RPC (the FLUE_MARKET_AGENT stub, listListingsRpc, the MARKET_STORE flag). The advisor needs none of that: nobody else reads a buyer's advisor brain. No stub, no flag, no RPC surface.
The advisor brain is purely relational — yearnings as confidence scores, skill metrics/params as tuned values, a files table, a sessions table. A full grep of flue/src/advisor/** finds no embedding, vec0, vector, or Vectorize.
The market's trickiest phase was relocating embeddings to a Cloudflare Vectorize binding. The advisor has nothing to relocate — it matches homes against yearnings qualitatively, in the model, not by embedding distance. The whole vector phase simply doesn't exist here.
What's left is a mechanical SQL port: every data function already takes an injected client, so the swap is one-for-one — libSQL's async client.execute({sql,args}) (returns {rows}) becomes the native, synchronous sql.exec(query, ...binds).toArray() — plus the deletion of the provisioning machinery. That's the entire job.
The north star is parity: a native advisor must behave identically to a Turso one. Same five tables, same seeding, same beats. Only the substrate changes.
| Brain table | Holds | Touched by | Port |
|---|---|---|---|
yearnings | standing questions — key, prompt, confidence [0,1], weight, status, source, last_touched | birth seeds 7 core + custom; brain_read lists; brain_write/reweightYearning nudge confidence on 👍/👎 in the tend beat | verbatim DDL + SQL, sql.exec bodies |
skill_metrics | per-skill learning — (skill, metric_key, window) → value, n | writeMetric (skill self-tuning) | verbatim |
skill_params | tuned hyperparams — (skill, param_key) → value | readParams/writeParam | verbatim |
files | path → content — backs IDENTITY.md + notes/* | the advisorFs SessionEnv → Flue native file tools (read/write/edit/grep/glob) | file helpers → sql |
sessions | recent turns — turn_id, started_at, role, content | appendSessionEntries/readRecentSessions (parity port; flag for a later is-this-still-used check) | verbatim |
finish_birth ensures schema + seedYearnings (7 universal-core at confidence 0.2 + custom from intake answers at 0.3), then markBorn in Convex. Minus the Turso provisioning call.reweightYearning.MARKET_STORE=native → listForSaleRpc), reads yearnings, places matching homes on the board. The advisor reading the market is unchanged and stays.check_identity/write_identity and the file-tool sandbox round-trip through the native files table.getCloudflareContext().storage.sql — valid in every model turn, whether triggered by an HTTP request or an alarm-fired beat (both run inside Flue's async context). Schema-ensure at boot uses this.ctx.storage.sql directly in onStart() (no async-context dependency). There is no cross-agent RPC reading an advisor brain, so the this.ctx form is needed only at boot — but if one is ever added, it must use this.ctx, never getCloudflareContext().The deletions are the headline. Gone from the advisor path: provisionAdvisorDb (the Platform-API create), getAdvisorTursoClient (the libSQL connect), the getReadyBrainClient per-userId memoization (existed only to amortize a network connect — native has none), advisorDbName (no remote DB to name), and the @libsql/client import. What replaces all of it is one line of intent: the brain is this DO's SQLite.
The handle, the only moving part. A tiny shared Sql type — { exec(q, ...binds): { toArray(): Row[] } } — already lives at flue/src/market/sqlh.ts. We lift it to a shared module so both agents import one definition. Every advisor data fn changes its parameter from (c: Client, …) to (sql: Sql, …) and its body from await c.execute({sql,args}) to sql.exec(query, ...args).toArray(). Functions keep their async signatures (the sync handle drops straight in), so call sites barely move.
No fallback flag. The market kept a MARKET_STORE flag to de-risk live data and many consumers; the advisor has neither — green-field, no buyers, old DBs abandoned — so we delete the Turso path outright. Each phase ends green (tsc + tests + flue build).
Sql to a shared moduleSql/SqlResult interface out of src/market/sqlh.ts to a shared src/sqlh.ts (re-export from the old path for zero market churn). The advisor's fakeSqlStorage() test double reuses the existing helper in test/helpers/.provisionAdvisorDb, getAdvisorTursoClient, getReadyBrainClient, advisorDbName, advisorEnv, the AdvisorTursoEnv type, and the @libsql/client import from brainStore.ts.(c: Client, …) → (sql: Sql, …); body libSQL execute → sql.exec(query, ...binds).toArray(). SQL text + column names verbatim. Covers: ensureSchema (was ensureBrainSchema), seedYearnings, upsertYearning, listYearnings, reweightYearning, writeMetric, readParams, writeParam, appendSessionEntries, readRecentSessions, and the file helpers (writeFileRow, readFileRow, listFilePaths, deleteFileRow, fileExists, searchFiles, saveFiles, loadFiles).fakeSqlStorage():memory: client for the synchronous fake. Green = behavior preserved byte-for-byte (the seeded yearnings, the COALESCE-preserving upserts, the confidence clamp, the file search).getBrainClient(userId) with a no-arg brainSql() → getCloudflareContext().storage.sql (the DO's own storage; userId is no longer needed to find the handle). Keep userId in the tools closure for Convex calls + identity.finish_birth stops provisioningseedYearnings(brainSql(), answers) then markBorn. Schema is guaranteed by onStart (next step). brain_read/brain_write read brainSql().advisorFs on the native handlebrainSql() lazily per call (never at construction) so file ops work in both request and alarm-beat turns.onStart ensures schemaensureSchema(this.ctx.storage.sql) to the advisor's onStart() (alongside the existing alarm arming) so all five tables exist before the first turn. Idempotent; uses the boot-safe this.ctx handle.flue/scripts/smoke.sh against the deployed Worker: createIntake (Paonia, covered market) with a fresh devUserId → poll team:getForUser until status=born. No Turso touched — proves the quota wall is gone.brain_read returns its seeded yearnings (native), and a scout/board read pulls the live v10 market over RPC. Both agents, one flow.getTursoClient / TURSO_ADVISOR_* references left in the advisor path (incl. the market-read Turso fallback already superseded by MARKET_STORE=native). Full suite green; deploy.TURSO_API_TOKEN, TURSO_ORG, TURSO_ADVISOR_GROUP, TURSO_ADVISOR_GROUP_TOKEN from the Worker and destroy the homezero-advisors group DBs. HomeZero is fully off Turso.The whole migration is judged by one run of the existing advisor smoke test — now meaningful because it exercises the native brain and the live market in a single pass.
status=born within the poll window with no provisionAdvisorDb 403 in the logs; (2) the follow-up reply is in-persona and reflects the seeded yearnings; (3) the scout/board read returns live v10 market listings over RPC. Green on all three = the advisor is native, birth is unblocked, and the two agents compose.MARKET_STORE-style flag earns nothing — we delete the Turso path outright. The point is to kill Turso, not keep it limping behind a flag.adv-* DBs are simply dropped with the group.read_market_listings/scout already reads the market DO via listForSaleRpc behind MARKET_STORE=native (live). That is a separate, already-native path and is out of scope here except as the thing the E2E proves still works.| Risk | Mitigation |
|---|---|
libSQL API (async .execute({sql,args}), {rows}) vs native (sync .exec(q,...binds).toArray()) | Per-fn body rewrite; fakeSqlStorage() tests assert identical results (seeding, COALESCE upserts, confidence clamp, file search). Signatures stay async so call sites don't churn. |
The files table is touched during alarm-fired beats (scout/dream use file tools) | getCloudflareContext() is valid in alarm-beat model turns (market-confirmed: nightly beats read it fine). advisorFs resolves the handle lazily per call, never at construction. |
| Schema not present before the first birth turn | onStart runs at DO boot before message handling and ensures all five tables via this.ctx.storage.sql (idempotent). |
| A future cross-agent reader of an advisor brain uses the wrong handle | Documented rule: any RPC method must use this.ctx.storage.sql, never getCloudflareContext(). None exists today. |