Advisor Brain Goes Native

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.

Contents
  1. Why now — the birth blocker & the last mile
  2. The key insight — per-buyer = per-DO, zero vectors
  3. What "works the same" requires
  4. Target architecture
  5. The build — phased (hard cut)
  6. The end-to-end proof
  7. Risks & decisions

1 · Why now — the birth blocker & the last mile

"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:

When a buyer onboards, 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 advisorStatus 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.

2 · The key insight — per-buyer = per-DO, zero vectors

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.

① One DO per buyer → a 1:1 fit

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.

② No vectors at all

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.

3 · What "works the same" requires

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 tableHoldsTouched byPort
yearningsstanding questions — key, prompt, confidence [0,1], weight, status, source, last_touchedbirth seeds 7 core + custom; brain_read lists; brain_write/reweightYearning nudge confidence on 👍/👎 in the tend beatverbatim DDL + SQL, sql.exec bodies
skill_metricsper-skill learning — (skill, metric_key, window) → value, nwriteMetric (skill self-tuning)verbatim
skill_paramstuned hyperparams — (skill, param_key) → valuereadParams/writeParamverbatim
filespath → content — backs IDENTITY.md + notes/*the advisorFs SessionEnv → Flue native file tools (read/write/edit/grep/glob)file helpers → sql
sessionsrecent turns — turn_id, started_at, role, contentappendSessionEntries/readRecentSessions (parity port; flag for a later is-this-still-used check)verbatim

The behaviors that must keep working, unchanged

The one correctness rule (the market's hard-won lesson). Brain access from a tool or the file sandbox resolves the handle via 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().

4 · Target architecture

┌─ ONE advisor DO per buyer ──────────────────────────────────────┐ │ id: <userId> (= ctx.id — stable, the buyer's advisor) │ │ │ │ native storage — the DO's own SQLite (local, microsecond): │ │ ├─ yearnings · skill_metrics · skill_params · sessions │ │ │ — the relational brain (no vectors) │ │ └─ files IDENTITY.md · notes/* via advisorFs SessionEnv │ │ (Flue native file tools) │ │ sessions (flue_*) chat history — disposable │ └───────┬─────────────────────────────────────────────────────────┘ │ reads the MARKET brain over DO RPC (unchanged): │ listForSaleRpc(50) on FLUE_MARKET_AGENT → v10 market DO ▼ ┌─ OUTSIDE ─────────────────────────────────────────────────────────┐ │ Convex board · /home · roster · Clerk glue — the app surface, │ │ where the advisor publishes; never a route to the brain │ │ (no Turso anywhere — provisioning + group token deleted) │ └────────────────────────────────────────────────────────────────────┘

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.

5 · The build — phased (hard cut)

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).

Phase 0 · Shared SQL handle
  1. Lift Sql to a shared module
    Move the Sql/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/.
Phase A · Port the brain store to native SQLite
  1. Delete the provisioning machinery
    Remove provisionAdvisorDb, getAdvisorTursoClient, getReadyBrainClient, advisorDbName, advisorEnv, the AdvisorTursoEnv type, and the @libsql/client import from brainStore.ts.
  2. Port every data fn to the native handle
    Signature (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).
  3. Re-point brainStore tests to fakeSqlStorage()
    Swap the libSQL :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).
Phase B · Re-wire the access points
  1. A single brain accessor
    Replace the per-userId 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.
  2. finish_birth stops provisioning
    Drop the provision + connect; just seedYearnings(brainSql(), answers) then markBorn. Schema is guaranteed by onStart (next step). brain_read/brain_write read brainSql().
  3. advisorFs on the native handle
    The SessionEnv file helpers resolve brainSql() lazily per call (never at construction) so file ops work in both request and alarm-beat turns.
  4. onStart ensures schema
    Add ensureSchema(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.
Phase C · Prove it end-to-end
  1. Birth a brand-new advisor natively
    Run 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.
  2. Confirm the brain + the market read
    Follow-up message → advisor wakes in the configured branch, brain_read returns its seeded yearnings (native), and a scout/board read pulls the live v10 market over RPC. Both agents, one flow.
Phase D · Cut the last Turso
  1. Grep-clean dead Turso refs
    Remove any now-dead 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.
  2. Drop the four secrets + the group
    After native birth is proven in practice, remove 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.

6 · The end-to-end proof

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.

smoke.sh │ ├─ convex run intakes:createIntake { where:"Paonia, CO 81428", devUserId, answers } │ └─ schedules ringAdvisor → POST /agents/advisor/<userId> (Bearer AUTH) │ ├─ advisor DO boots: onStart → ensureSchema(this.ctx.storage.sql) [native, no Turso] │ model turn: check_identity → write_identity → finish_birth │ └─ seedYearnings(brainSql(), answers) + markBorn [native, no Turso] │ ├─ poll team:getForUser until status=born ← birth WITHOUT a Turso provision │ └─ follow-up message: brain_read() → seeded yearnings from native SQLite scout/board → listForSaleRpc(50) on the v10 market DO ← both agents, one flow reply in persona
Pass criteria. (1) 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.

7 · Risks & decisions

Decided — advisor brain goes fully native, hard cut, no flag. Pete, 2026-06-25. The advisor has no live data and no second consumer to de-risk (green-field, no real buyers, old sessions bricked), so the 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.
Decided — green-field; abandon existing per-buyer Turso advisor DBs. Pete, 2026-06-25. No real buyers exist and old advisor sessions are already bricked by the beta.5 upgrade, so there is no data worth migrating. New advisors are born native; the old adv-* DBs are simply dropped with the group.
Decided — no vectors. The advisor brain is purely relational; nothing moves to Vectorize. The market's vector phase has no analog here.
Decided — the advisor keeps reading the market over RPC, unchanged. The advisor's 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.
RiskMitigation
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 turnonStart 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 handleDocumented rule: any RPC method must use this.ctx.storage.sql, never getCloudflareContext(). None exists today.

Companion to Market Brain Goes Native (shipped, market live on native). This is the advisor half — the last Turso dependency, on the birth path. Build via subagent-driven development, Opus on every delegated agent, branch → PR → squash-merge. · 2026-06-25