create-walle 0.9.32 → 0.9.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/docs/postgres-storage-abstraction.html +732 -0
  3. package/template/claude-task-manager/lib/headless-term-service.js +29 -1
  4. package/template/claude-task-manager/lib/state-sync/emit-clamp.js +32 -0
  5. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +1 -1
  6. package/template/claude-task-manager/lib/walle-ctm-history.js +115 -6
  7. package/template/claude-task-manager/public/css/walle-session.css +57 -1
  8. package/template/claude-task-manager/public/index.html +66 -8
  9. package/template/claude-task-manager/public/js/message-renderer.js +87 -0
  10. package/template/claude-task-manager/public/js/state-sync-client.js +32 -0
  11. package/template/claude-task-manager/public/js/walle-session.js +324 -90
  12. package/template/claude-task-manager/server.js +122 -7
  13. package/template/claude-task-manager/workers/state-detectors/codex.js +25 -6
  14. package/template/claude-task-manager/workers/state-detectors/index.js +1 -0
  15. package/template/package.json +1 -1
  16. package/template/wall-e/chat.js +7 -75
  17. package/template/wall-e/coding/coding-run-controller.js +118 -195
  18. package/template/wall-e/coding/compaction-service.js +91 -10
  19. package/template/wall-e/coding/no-progress-guard.js +4 -1
  20. package/template/wall-e/coding/output-boundary.js +94 -0
  21. package/template/wall-e/coding/permission-service.js +15 -0
  22. package/template/wall-e/coding/session-workspaces.js +70 -0
  23. package/template/wall-e/coding/tool-execution-controller.js +54 -3
  24. package/template/wall-e/coding-orchestrator.js +40 -299
  25. package/template/wall-e/coding-prompts.js +62 -8
  26. package/template/wall-e/context/token-counter.js +35 -15
  27. package/template/wall-e/tools/local-tools.js +45 -17
  28. package/template/wall-e/tools/permission-checker.js +5 -0
@@ -0,0 +1,732 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CTM + Wall-E · Storage Abstraction, SQLite↔Postgres Shadowing &amp; the Multi-Tenant Server</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0b0e14; --panel:#11161f; --panel2:#161d28; --ink:#e6edf3; --dim:#9aa7b8;
10
+ --line:#212a37; --accent:#5ec8ff; --accent2:#7ee787; --warn:#ffb86b; --bad:#ff7b72;
11
+ --grace:#76e0c2; --pg:#7aa2ff; --gpu:#9d7bff; --mono:"SF Mono",ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace;
12
+ }
13
+ *{box-sizing:border-box}
14
+ html{scroll-behavior:smooth}
15
+ body{
16
+ margin:0;background:radial-gradient(1200px 700px at 80% -10%,#16202e 0%,var(--bg) 55%);
17
+ color:var(--ink);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
18
+ -webkit-font-smoothing:antialiased;padding-bottom:6rem;
19
+ }
20
+ .wrap{max-width:1040px;margin:0 auto;padding:0 24px}
21
+ header.hero{padding:64px 24px 40px;max-width:1040px;margin:0 auto}
22
+ .kicker{font:600 12px/1 var(--mono);letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}
23
+ h1{font-size:40px;line-height:1.12;margin:0 0 16px;letter-spacing:-.02em;font-weight:740}
24
+ h1 .g{background:linear-gradient(100deg,var(--grace),var(--pg) 55%,var(--gpu));-webkit-background-clip:text;background-clip:text;color:transparent}
25
+ .sub{font-size:18px;color:var(--dim);max-width:74ch;margin:0}
26
+ .meta{margin-top:22px;font:500 13px/1.5 var(--mono);color:var(--dim)}
27
+ .meta b{color:var(--ink);font-weight:600}
28
+
29
+ h2{font-size:25px;letter-spacing:-.01em;margin:56px 0 8px;font-weight:700;scroll-margin-top:20px}
30
+ h2 .n{font:700 14px/1 var(--mono);color:var(--accent);margin-right:12px;vertical-align:2px}
31
+ h3{font-size:18px;margin:30px 0 8px;font-weight:660}
32
+ p{margin:12px 0}
33
+ a{color:var(--accent);text-decoration:none;border-bottom:1px solid #2c4a5e}
34
+ a:hover{border-color:var(--accent)}
35
+ .lead{color:var(--dim);font-size:16.5px;margin-top:4px}
36
+ code{font:13.5px/1.5 var(--mono);background:#0d141d;border:1px solid var(--line);border-radius:5px;padding:1px 6px;color:#cfe6ff}
37
+ strong{color:#fff;font-weight:660}
38
+ em{color:var(--ink);font-style:italic}
39
+
40
+ .panel{background:linear-gradient(180deg,var(--panel),#0e131b);border:1px solid var(--line);border-radius:14px;padding:22px 24px;margin:22px 0}
41
+ .tldr{border-left:3px solid var(--accent)}
42
+ .tldr h2{margin-top:0}
43
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
44
+ .grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
45
+ @media(max-width:780px){.grid2,.grid3{grid-template-columns:1fr}}
46
+ .card{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:16px 18px}
47
+ .card h4{margin:0 0 8px;font-size:15px;font-weight:660;display:flex;align-items:center;gap:8px}
48
+ .card p{margin:6px 0;font-size:14.5px;color:var(--dim)}
49
+ .pill{display:inline-block;font:600 11px/1 var(--mono);padding:5px 9px;border-radius:999px;border:1px solid var(--line);color:var(--dim);background:#0d141d}
50
+ .dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
51
+
52
+ table{width:100%;border-collapse:collapse;margin:18px 0;font-size:14px}
53
+ th,td{text-align:left;padding:11px 13px;border-bottom:1px solid var(--line);vertical-align:top}
54
+ th{font:600 12px/1 var(--mono);letter-spacing:.05em;text-transform:uppercase;color:var(--dim)}
55
+ tbody tr:hover{background:#0f1620}
56
+ td code{font-size:12.5px}
57
+ .yes{color:var(--accent2)} .no{color:var(--bad)} .meh{color:var(--warn)}
58
+
59
+ .callout{border:1px solid var(--line);border-radius:12px;padding:14px 18px;margin:18px 0;background:#0e1019}
60
+ .callout.warn{border-color:#5a4326;background:#1a1408}
61
+ .callout.good{border-color:#26503a;background:#0a1710}
62
+ .callout .h{font:600 12px/1 var(--mono);letter-spacing:.06em;text-transform:uppercase;margin-bottom:6px}
63
+ .callout.warn .h{color:var(--warn)} .callout.good .h{color:var(--accent2)}
64
+ .callout p{margin:4px 0;font-size:14.5px}
65
+
66
+ figure{margin:24px 0}
67
+ figcaption{font:500 12.5px/1.5 var(--mono);color:var(--dim);margin-top:10px;text-align:center}
68
+ svg{width:100%;height:auto;display:block}
69
+ .svgwrap{background:#0a0f17;border:1px solid var(--line);border-radius:14px;padding:18px}
70
+
71
+ ul.clean{list-style:none;padding:0;margin:12px 0}
72
+ ul.clean li{padding:7px 0 7px 26px;position:relative;color:var(--dim)}
73
+ ul.clean li::before{content:"";position:absolute;left:4px;top:14px;width:7px;height:7px;border-radius:50%;background:var(--accent)}
74
+ ul.clean li b{color:var(--ink)}
75
+
76
+ .toc{font:500 14px/1.9 var(--mono)}
77
+ .toc a{border:0;color:var(--dim);display:block}
78
+ .toc a:hover{color:var(--accent)}
79
+ .toc .num{color:var(--accent);display:inline-block;width:30px}
80
+
81
+ .legend{display:flex;gap:20px;flex-wrap:wrap;margin-top:14px;font:500 12.5px/1 var(--mono);color:var(--dim)}
82
+ .legend span{display:flex;align-items:center;gap:7px}
83
+
84
+ footer{margin-top:60px;padding-top:24px;border-top:1px solid var(--line);color:var(--dim);font-size:13.5px}
85
+ .src{font-size:13px;line-height:1.9}
86
+ .tag{font:600 10.5px/1 var(--mono);padding:4px 7px;border-radius:5px;border:1px solid var(--line);color:var(--dim)}
87
+ .tag.now{color:var(--accent2);border-color:#26503a}
88
+ .tag.soon{color:var(--warn);border-color:#5a4326}
89
+ .tag.far{color:var(--gpu);border-color:#3a2e5a}
90
+ hr{border:0;border-top:1px solid var(--line);margin:40px 0}
91
+ .big{font-size:30px;font-weight:740;letter-spacing:-.01em}
92
+ .num-strip{display:flex;gap:14px;flex-wrap:wrap;margin:18px 0}
93
+ .stat{flex:1;min-width:150px;background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:16px}
94
+ .stat .v{font-size:24px;font-weight:740;color:#fff}
95
+ .stat .l{font:500 12px/1.4 var(--mono);color:var(--dim);margin-top:4px}
96
+
97
+ /* code blocks for this doc */
98
+ pre.code{background:#0a0f17;border:1px solid var(--line);border-radius:12px;padding:16px 18px;overflow-x:auto;
99
+ font:12.5px/1.65 var(--mono);color:#cfe6ff;margin:16px 0}
100
+ pre.code .c{color:#5f6e80} /* comment */
101
+ pre.code .k{color:#7aa2ff} /* keyword */
102
+ pre.code .s{color:#7ee787} /* string */
103
+ pre.code .f{color:#76e0c2} /* function/ident */
104
+ pre.code .t{color:#ffb86b} /* type */
105
+ pre.code .label{display:block;font:600 11px/1 var(--mono);letter-spacing:.05em;text-transform:uppercase;
106
+ color:var(--dim);margin-bottom:10px}
107
+ .crumb{font:600 11px/1 var(--mono);color:var(--accent);letter-spacing:.04em}
108
+ </style>
109
+ </head>
110
+ <body>
111
+
112
+ <header class="hero">
113
+ <div class="kicker">CTM + Wall-E · Architecture Discussion · 2026-06-29</div>
114
+ <h1>Two engines, one seam: <span class="g">shadowing SQLite into Postgres</span> on the way to a multi-tenant server</h1>
115
+ <p class="sub">A concrete follow-up to the Postgres&nbsp;migration spec. The first doc asked <em>whether</em> Postgres (and a dedicated box) is worth it. This one answers the engineering question the user actually raised: <em>how do we run SQLite and Postgres side by side behind flags, prove the data is byte-for-byte trustworthy by shadowing one onto the other, hide both behind a single storage abstraction, and design — from day one — for a central server that hosts both DBs and, eventually, other users.</em></p>
116
+ <div class="meta">
117
+ Companion to <b>docs/postgres-concurrency-dgx-spark.html</b> and <b>~/.claude/plans/let-s-discuss-do-online-frolicking-pascal.md</b> (Phases 0–5)<br>
118
+ Status: <b>design for review &amp; discussion</b> — not a commitment. Grounded in the real <b>db.js</b> / <b>brain.js</b> / <b>db-async.js</b> code, not assumptions.
119
+ </div>
120
+ </header>
121
+
122
+ <div class="wrap">
123
+
124
+ <div class="panel tldr">
125
+ <h2 style="border:0">TL;DR</h2>
126
+ <ul class="clean">
127
+ <li><b>Coexistence, not a cutover.</b> SQLite stays the source of truth while Postgres earns trust. A single env flag per service (<code>*_DB_BACKEND</code>) selects the mode; the existing <code>db-async.js</code> per-op override Map lets us migrate <em>one operation at a time</em>, reversibly.</li>
128
+ <li><b>Shadowing is how we <em>prove</em> identical — not hope it.</b> In <code>shadow-pg</code> mode every write is dual-applied and a sampled fraction of reads run against <em>both</em> engines and get diffed. Mismatches log to a parity table; they never reach the user. Cutover is gated on a measured low mismatch-rate, not a leap of faith.</li>
129
+ <li><b>One shared abstraction, two implementations.</b> Both <code>db.js</code> (~230 fns) and <code>brain.js</code> (~244 fns) bind to one backend contract in <code>shared/storage/</code>. <code>SqliteBackend</code> / <code>PgBackend</code> / <code>ShadowBackend</code> are interchangeable. The rest of CTM/Wall-E never learns which is live.</li>
130
+ <li><b>Central server: one cluster, two schemas.</b> The CTM DB and the Wall-E brain become schemas <code>ctm</code> and <code>walle</code> in a single Postgres database — so connection pooling stays sane and there's one backup domain. Database-per-app is the fallback only when hard blast-radius isolation is required.</li>
131
+ <li><b>Multi-tenant from day one — as a design posture, not today's load.</b> Every table is tenant-scoped now (<code>tenant_id</code> + Row-Level Security as a DB-level safety net), so opening to other users is a config flip, not a migration. Honest: that turns a single trusted brain into a system that needs auth, isolation tests, per-tenant backups, and abuse controls. RLS isolates <em>rows</em>, not CPU/disk/cache.</li>
132
+ <li><b>Still true:</b> none of this is needed to fix freezes — the Phase&nbsp;0 async seam already does that. This work buys <em>remote/multi-device + a real central service</em>, and the shadow ladder is what makes the move trustworthy instead of scary.</li>
133
+ </ul>
134
+ </div>
135
+
136
+ <div class="panel">
137
+ <div class="crumb">CONTENTS</div>
138
+ <div class="toc" style="margin-top:10px">
139
+ <a href="#goal"><span class="num">01</span>The coexistence goal — why flags + shadow, not a cutover</a>
140
+ <a href="#abstraction"><span class="num">02</span>The shared storage abstraction</a>
141
+ <a href="#modes"><span class="num">03</span>Backend modes &amp; the flag state machine</a>
142
+ <a href="#shadow"><span class="num">04</span>Shadow &amp; parity — proving the data is identical</a>
143
+ <a href="#schema"><span class="num">05</span>Schema port specifics (grounded in the real tables)</a>
144
+ <a href="#topology"><span class="num">06</span>One server, two DBs — cluster topology (4.1)</a>
145
+ <a href="#tenant"><span class="num">07</span>Multi-tenant from day one (4.2)</a>
146
+ <a href="#ladder"><span class="num">08</span>Phased rollout &amp; the shadow ladder</a>
147
+ <a href="#risks"><span class="num">09</span>Risks &amp; honest caveats</a>
148
+ <a href="#checkpoints"><span class="num">10</span>Decision checkpoints</a>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- ============================================ 01 ============================================ -->
153
+ <h2 id="goal"><span class="n">01</span>The coexistence goal — why flags + shadow, not a cutover</h2>
154
+ <p class="lead">A hard cutover is the riskiest possible way to move a database. You flip a switch and discover the differences in production. The whole point of this design is to make the move <em>boring</em>: the two engines run together, and Postgres has to earn primary status by demonstrably matching SQLite under real traffic.</p>
155
+
156
+ <p>Three requirements drive everything below, and they came directly from the brief:</p>
157
+ <div class="grid3">
158
+ <div class="card">
159
+ <h4><span class="dot" style="background:var(--accent2)"></span>Both, side by side</h4>
160
+ <p>SQLite and Postgres coexist — one can <em>read/write-shadow</em> the other — switchable by flags. No window where only the untrusted engine is live.</p>
161
+ </div>
162
+ <div class="card">
163
+ <h4><span class="dot" style="background:var(--pg)"></span>Shadow first</h4>
164
+ <p>Postgres enters in shadow mode. We promote it only after the data is <em>proven</em> identical under sampled real traffic — never on faith.</p>
165
+ </div>
166
+ <div class="card">
167
+ <h4><span class="dot" style="background:var(--gpu)"></span>One abstraction</h4>
168
+ <p>The rest of CTM/Wall-E must not know which engine answered. Backend choice is a wiring decision at the seam, invisible to the ~470 call-site-facing functions.</p>
169
+ </div>
170
+ </div>
171
+
172
+ <div class="callout good">
173
+ <div class="h">The lucky part — the seam already exists</div>
174
+ <p>CTM's <code>db-async.js</code> (on <code>feat/db-async-seam</code>) already promisifies all ~230 <code>db.js</code> exports and routes each op through a <code>configure({ overrides })</code> Map. That Map <em>is</em> the backend-swap point. We don't have to invent the chokepoint — we have to give it a Postgres implementation and a shadow decorator. Wall-E's <code>brain.js</code> needs the analogous seam built (it's still fully synchronous behind <code>getDb()</code>), but to the same shape.</p>
175
+ </div>
176
+
177
+ <!-- ============================================ 02 ============================================ -->
178
+ <h2 id="abstraction"><span class="n">02</span>The shared storage abstraction</h2>
179
+ <p class="lead">One contract, several implementations, bound at the seam. The contract is just "the union of named operations the app already calls" — we are not rewriting call sites, we are giving the existing function names interchangeable engines underneath.</p>
180
+
181
+ <h3>What lives where</h3>
182
+ <table>
183
+ <thead><tr><th>Layer</th><th>Responsibility</th><th>Where</th></tr></thead>
184
+ <tbody>
185
+ <tr><td><b>Call sites</b> (~470 fns)</td><td>Unchanged. <code>await db.upsertSession(...)</code>, <code>await brain.searchMemories(...)</code>.</td><td>everywhere in CTM/Wall-E</td></tr>
186
+ <tr><td><b>The seam</b></td><td>Promisified facade; per-op routing via override Map.</td><td><code>db-async.js</code> (CTM) + new <code>brain-async.js</code> (Wall-E)</td></tr>
187
+ <tr><td><b>Backend contract</b></td><td>The interface every engine implements. Grouped by domain (sessions, memories, vectors, …).</td><td><code>shared/storage/contract.js</code></td></tr>
188
+ <tr><td><b>SqliteBackend</b></td><td>Today's <code>better-sqlite3</code> path via the existing driver + owner-write-queue. <span class="yes">Default. Zero behavior change.</span></td><td><code>shared/storage/sqlite-backend.js</code></td></tr>
189
+ <tr><td><b>PgBackend</b></td><td><code>pg</code> Pool + pgvector + tsvector. Same contract.</td><td><code>shared/storage/pg-backend.js</code></td></tr>
190
+ <tr><td><b>ShadowBackend</b></td><td>Decorator wrapping a <code>primary</code> + <code>secondary</code> + comparator.</td><td><code>shared/storage/shadow-backend.js</code></td></tr>
191
+ <tr><td><b>Comparator + parity log</b></td><td>Normalizes &amp; diffs results; records mismatches.</td><td><code>shared/storage/parity.js</code></td></tr>
192
+ </tbody>
193
+ </table>
194
+
195
+ <div class="callout">
196
+ <div class="h">Why one shared contract (the user's call)</div>
197
+ <p>CTM and Wall-E are separate modules with separate DBs, but they share the <em>same</em> SQLite machinery today (<code>shared/sqlite-owner-write-queue.js</code>, <code>shared/sqlite-write-lock.js</code>). Putting the backend contract, shadow decorator, pg pool, and comparator in <code>shared/storage/</code> means the hard, bug-prone parts (dual-write ordering, result normalization, parity logging) are written and tested <em>once</em>. Each project still supplies its own schema/DDL and its own query bodies — the contract is the shape, not the SQL.</p>
198
+ </div>
199
+
200
+ <h3>The backend interface (sketch)</h3>
201
+ <pre class="code"><span class="label">shared/storage/contract.js — the shape every engine implements</span><span class="c">// A backend is a flat bag of async ops keyed by the SAME names the app</span>
202
+ <span class="c">// already calls. No ORM, no query builder — just "given these args, do this".</span>
203
+ <span class="c">// Reads and writes are both here: today reads bypass the write-queue, so the</span>
204
+ <span class="c">// abstraction must own BOTH paths or shadowing can't see reads.</span>
205
+
206
+ <span class="k">class</span> <span class="t">StorageBackend</span> {
207
+ <span class="c">// lifecycle</span>
208
+ <span class="k">async</span> <span class="f">init</span>(opts) {}
209
+ <span class="k">async</span> <span class="f">close</span>() {}
210
+ <span class="k">async</span> <span class="f">health</span>() {} <span class="c">// { ok, latencyMs, schemaVersion }</span>
211
+
212
+ <span class="c">// every op carries an OpContext so the seam/shadow layer can classify it</span>
213
+ <span class="c">// ctx = { name, kind: 'read'|'write', equality: 'exact'|'set'|'rank'|'vector', tenantId }</span>
214
+ <span class="k">async</span> <span class="f">run</span>(ctx, ...args) {}
215
+ }
216
+
217
+ <span class="c">// SqliteBackend.run() dispatches to the existing db.js / brain.js fn bodies.</span>
218
+ <span class="c">// PgBackend.run() dispatches to hand-ported pg queries with the same names.</span>
219
+ <span class="c">// Same ctx, same args, same return shape — that's the entire contract.</span>
220
+ </pre>
221
+
222
+ <h3>How the seam binds a backend — and swaps one op at a time</h3>
223
+ <pre class="code"><span class="label">wiring — server picks a mode, builds the backend, injects via configure()</span><span class="c">// db-async.js already exposes: configure({ overrides }) -> Map of name -> async fn</span>
224
+ <span class="c">// We translate "a backend" into "an overrides map", op by op.</span>
225
+
226
+ <span class="k">const</span> mode = process.env.<span class="t">CTM_DB_BACKEND</span> || <span class="s">'sqlite'</span>;
227
+ <span class="k">const</span> allow = (process.env.<span class="t">CTM_DB_SHADOW_OPS</span> || <span class="s">'*'</span>).<span class="f">split</span>(<span class="s">','</span>); <span class="c">// incremental rollout</span>
228
+
229
+ <span class="k">const</span> backend = <span class="f">buildBackend</span>(mode, { <span class="c">// sqlite | shadow-pg | shadow-sqlite | postgres</span>
230
+ sqlite: sqliteBackend,
231
+ pg: pgBackend,
232
+ sampleRate: <span class="t">Number</span>(process.env.<span class="t">CTM_DB_SHADOW_SAMPLE</span> || <span class="s">'0.05'</span>),
233
+ parity: parityLogger,
234
+ });
235
+
236
+ <span class="c">// Only ops on the allowlist route through the new backend; the rest stay pure</span>
237
+ <span class="c">// passthrough to today's sync db.js. Migration is monotonic and reversible.</span>
238
+ <span class="k">const</span> overrides = {};
239
+ <span class="k">for</span> (<span class="k">const</span> name <span class="k">of</span> backend.<span class="f">opNames</span>()) {
240
+ <span class="k">if</span> (allow[<span class="t">0</span>] === <span class="s">'*'</span> || allow.<span class="f">includes</span>(name))
241
+ overrides[name] = (...args) => backend.<span class="f">run</span>(<span class="f">ctxFor</span>(name), ...args);
242
+ }
243
+ <span class="f">require</span>(<span class="s">'./db-async'</span>).<span class="f">configure</span>({ overrides });
244
+ </pre>
245
+ <p class="lead">Set <code>CTM_DB_SHADOW_OPS=upsertSession,getSessionMessagesPage</code> and only those two ops shadow; everything else is untouched. That's the unit of risk — one operation, not the whole 10,543-line module.</p>
246
+
247
+ <!-- ============================================ 03 ============================================ -->
248
+ <h2 id="modes"><span class="n">03</span>Backend modes &amp; the flag state machine</h2>
249
+ <p class="lead">Four modes, one ordered ladder. You only ever move one rung at a time, and every rung keeps a trusted fallback.</p>
250
+
251
+ <figure>
252
+ <div class="svgwrap">
253
+ <svg viewBox="0 0 980 240" role="img" aria-label="Backend mode state machine">
254
+ <defs>
255
+ <marker id="arr" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
256
+ <path d="M0,0 L9,4.5 L0,9 Z" fill="#5ec8ff"></path>
257
+ </marker>
258
+ <marker id="arrb" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
259
+ <path d="M0,0 L9,4.5 L0,9 Z" fill="#9aa7b8"></path>
260
+ </marker>
261
+ </defs>
262
+ <!-- nodes -->
263
+ <g font-family="SF Mono, ui-monospace, monospace">
264
+ <!-- sqlite -->
265
+ <rect x="20" y="78" width="190" height="84" rx="12" fill="#0e1620" stroke="#26503a"></rect>
266
+ <text x="115" y="104" fill="#7ee787" font-size="14" font-weight="700" text-anchor="middle">sqlite</text>
267
+ <text x="115" y="126" fill="#9aa7b8" font-size="11" text-anchor="middle">SQLite only</text>
268
+ <text x="115" y="143" fill="#9aa7b8" font-size="11" text-anchor="middle">today · default</text>
269
+
270
+ <!-- shadow-pg -->
271
+ <rect x="270" y="78" width="190" height="84" rx="12" fill="#0e1620" stroke="#2c4a5e"></rect>
272
+ <text x="365" y="102" fill="#5ec8ff" font-size="14" font-weight="700" text-anchor="middle">shadow-pg</text>
273
+ <text x="365" y="123" fill="#9aa7b8" font-size="11" text-anchor="middle">SQLite primary</text>
274
+ <text x="365" y="139" fill="#9aa7b8" font-size="11" text-anchor="middle">Pg secondary · diffed</text>
275
+
276
+ <!-- shadow-sqlite -->
277
+ <rect x="520" y="78" width="200" height="84" rx="12" fill="#0e1620" stroke="#3a3a5e"></rect>
278
+ <text x="620" y="102" fill="#7aa2ff" font-size="14" font-weight="700" text-anchor="middle">shadow-sqlite</text>
279
+ <text x="620" y="123" fill="#9aa7b8" font-size="11" text-anchor="middle">Postgres primary</text>
280
+ <text x="620" y="139" fill="#9aa7b8" font-size="11" text-anchor="middle">SQLite mirror · fallback</text>
281
+
282
+ <!-- postgres -->
283
+ <rect x="780" y="78" width="180" height="84" rx="12" fill="#0e1620" stroke="#3a2e5a"></rect>
284
+ <text x="870" y="104" fill="#9d7bff" font-size="14" font-weight="700" text-anchor="middle">postgres</text>
285
+ <text x="870" y="126" fill="#9aa7b8" font-size="11" text-anchor="middle">Postgres only</text>
286
+ <text x="870" y="143" fill="#9aa7b8" font-size="11" text-anchor="middle">SQLite retired</text>
287
+
288
+ <!-- forward arrows -->
289
+ <line x1="210" y1="112" x2="266" y2="112" stroke="#5ec8ff" stroke-width="2" marker-end="url(#arr)"></line>
290
+ <line x1="460" y1="112" x2="516" y2="112" stroke="#5ec8ff" stroke-width="2" marker-end="url(#arr)"></line>
291
+ <line x1="720" y1="112" x2="776" y2="112" stroke="#5ec8ff" stroke-width="2" marker-end="url(#arr)"></line>
292
+ <text x="238" y="58" fill="#5ec8ff" font-size="10" text-anchor="middle">parity ok</text>
293
+ <text x="488" y="58" fill="#5ec8ff" font-size="10" text-anchor="middle">promote</text>
294
+ <text x="748" y="58" fill="#5ec8ff" font-size="10" text-anchor="middle">cutover</text>
295
+
296
+ <!-- back arrows (rollback) -->
297
+ <path d="M266,150 C240,196 286,196 210,150" fill="none" stroke="#9aa7b8" stroke-width="1.4" stroke-dasharray="4 4" marker-end="url(#arrb)"></path>
298
+ <path d="M516,150 C490,196 536,196 460,150" fill="none" stroke="#9aa7b8" stroke-width="1.4" stroke-dasharray="4 4" marker-end="url(#arrb)"></path>
299
+ <path d="M776,150 C750,196 796,196 720,150" fill="none" stroke="#9aa7b8" stroke-width="1.4" stroke-dasharray="4 4" marker-end="url(#arrb)"></path>
300
+ <text x="490" y="214" fill="#9aa7b8" font-size="10" text-anchor="middle">every rung is reversible — drop back instantly on regression</text>
301
+ </g>
302
+ </svg>
303
+ </div>
304
+ <figcaption>Fig 1 · The mode ladder. Forward on proven parity; back on any regression. The trusted engine is always still there.</figcaption>
305
+ </figure>
306
+
307
+ <table>
308
+ <thead><tr><th>Mode</th><th>Primary (source of truth)</th><th>Secondary</th><th>Reads</th><th>Writes</th><th>When</th></tr></thead>
309
+ <tbody>
310
+ <tr>
311
+ <td><code>sqlite</code></td><td>SQLite</td><td>—</td><td>SQLite</td><td>SQLite</td><td>Today. Default.</td>
312
+ </tr>
313
+ <tr>
314
+ <td><code>shadow-pg</code></td><td><b>SQLite</b></td><td>Postgres</td><td>SQLite serves; <span class="meh">sampled %</span> also read Pg &amp; diff</td><td>both (Pg best-effort)</td><td>Build confidence safely</td>
315
+ </tr>
316
+ <tr>
317
+ <td><code>shadow-sqlite</code></td><td><b>Postgres</b></td><td>SQLite</td><td>Postgres serves; sampled % also read SQLite &amp; diff</td><td>both (SQLite best-effort)</td><td>Pg in hot path, SQLite as net</td>
318
+ </tr>
319
+ <tr>
320
+ <td><code>postgres</code></td><td>Postgres</td><td>—</td><td>Postgres</td><td>Postgres</td><td>After cutover; SQLite dormant</td>
321
+ </tr>
322
+ </tbody>
323
+ </table>
324
+
325
+ <h3>The flags (follow today's <code>process.env.CTM_*</code> / <code>WALL_E_*</code> convention)</h3>
326
+ <table>
327
+ <thead><tr><th>Flag</th><th>Default</th><th>Meaning</th></tr></thead>
328
+ <tbody>
329
+ <tr><td><code>CTM_DB_BACKEND</code> / <code>WALL_E_DB_BACKEND</code></td><td><code>sqlite</code></td><td>Selects the mode above.</td></tr>
330
+ <tr><td><code>*_DB_PG_URL</code></td><td>—</td><td>Postgres connection string (pgbouncer endpoint in cloud).</td></tr>
331
+ <tr><td><code>*_DB_SHADOW_SAMPLE</code></td><td><code>0.05</code></td><td>Fraction of reads that also hit the secondary &amp; get diffed.</td></tr>
332
+ <tr><td><code>*_DB_SHADOW_OPS</code></td><td><code>*</code></td><td>Comma-list of op names allowed to route to the new backend (incremental rollout).</td></tr>
333
+ <tr><td><code>*_DB_SHADOW_FAIL</code></td><td><code>log</code></td><td><code>log</code> | <code>alert</code> — what a secondary write/diff failure does. Never <code>throw</code>: the secondary may not break the user's request.</td></tr>
334
+ </tbody>
335
+ </table>
336
+
337
+ <div class="callout warn">
338
+ <div class="h">Best-effort secondary — a hard rule</div>
339
+ <p>In shadow modes the secondary is a <em>shadow</em>: if its write fails or its read disagrees, we log/alert and move on. It is <b>never</b> allowed to fail the user-facing request or block the primary commit. The day a Postgres hiccup freezes the UI is the day we've reinvented the disease we're curing.</p>
340
+ </div>
341
+
342
+ <!-- ============================================ 04 ============================================ -->
343
+ <h2 id="shadow"><span class="n">04</span>Shadow &amp; parity — proving the data is identical</h2>
344
+ <p class="lead">"Shadow" is only worth anything if the comparison is honest. Some operations are deterministic (an upsert, a point read) and must match byte-for-byte. Others (full-text ranking, vector top-k) are <em>legitimately</em> allowed to differ in tie-break order across engines. The comparator encodes that distinction explicitly, per op.</p>
345
+
346
+ <h3>Write path</h3>
347
+ <pre class="code"><span class="label">ShadowBackend.run() — write op</span><span class="k">async</span> <span class="f">run</span>(ctx, ...args) {
348
+ <span class="k">if</span> (ctx.kind === <span class="s">'write'</span>) {
349
+ <span class="k">const</span> p = <span class="k">await</span> <span class="k">this</span>.primary.<span class="f">run</span>(ctx, ...args); <span class="c">// commit on truth FIRST</span>
350
+ <span class="k">try</span> {
351
+ <span class="k">const</span> s = <span class="k">await</span> <span class="k">this</span>.secondary.<span class="f">run</span>(ctx, ...args); <span class="c">// best-effort mirror</span>
352
+ <span class="k">if</span> (!<span class="f">writeAgrees</span>(ctx, p, s)) <span class="c">// rowcount / returned id / conflict</span>
353
+ <span class="k">this</span>.parity.<span class="f">log</span>(ctx, { kind: <span class="s">'write-divergence'</span>, p, s });
354
+ } <span class="k">catch</span> (e) {
355
+ <span class="k">this</span>.parity.<span class="f">log</span>(ctx, { kind: <span class="s">'write-error'</span>, error: e.message });
356
+ }
357
+ <span class="k">return</span> p; <span class="c">// caller only ever sees the primary</span>
358
+ }
359
+ <span class="c">// ... read path below</span>
360
+ }
361
+ </pre>
362
+
363
+ <h3>Read path</h3>
364
+ <pre class="code"><span class="label">ShadowBackend.run() — read op, sampled</span> <span class="k">const</span> p = <span class="k">await</span> <span class="k">this</span>.primary.<span class="f">run</span>(ctx, ...args);
365
+ <span class="k">if</span> (<span class="f">sample</span>(<span class="k">this</span>.sampleRate)) { <span class="c">// e.g. 5% of reads</span>
366
+ <span class="f">queueMicrotask</span>(<span class="k">async</span> () => { <span class="c">// off the response path — no added latency</span>
367
+ <span class="k">try</span> {
368
+ <span class="k">const</span> s = <span class="k">await</span> <span class="k">this</span>.secondary.<span class="f">run</span>(ctx, ...args);
369
+ <span class="k">const</span> verdict = <span class="f">compare</span>(ctx.equality, p, s); <span class="c">// exact | set | rank | vector</span>
370
+ <span class="k">if</span> (!verdict.ok) <span class="k">this</span>.parity.<span class="f">log</span>(ctx, { kind: <span class="s">'read-divergence'</span>, ...verdict });
371
+ } <span class="k">catch</span> (e) { <span class="k">this</span>.parity.<span class="f">log</span>(ctx, { kind: <span class="s">'read-error'</span>, error: e.message }); }
372
+ });
373
+ }
374
+ <span class="k">return</span> p;
375
+ </pre>
376
+
377
+ <h3>Equality classes — what "match" means per op</h3>
378
+ <table>
379
+ <thead><tr><th>Class</th><th>Rule</th><th>Example ops</th></tr></thead>
380
+ <tbody>
381
+ <tr><td><code>exact</code></td><td>Deep-equal after normalizing JSON key order &amp; timestamp precision.</td><td><code>upsertSession</code>, <code>getSetting</code>, <code>getSessionIdentity</code>, point reads</td></tr>
382
+ <tr><td><code>set</code></td><td>Same <em>set</em> of rows; order ignored (DB-default ordering differs).</td><td><code>listPrompts</code>, <code>getAgentSessions</code>, unordered list reads</td></tr>
383
+ <tr><td><code>rank</code></td><td>Top-K overlap ≥ threshold; FTS5 BM25 vs Postgres <code>ts_rank</code> won't tie-break identically.</td><td><code>ftsSearchMessages</code>, <code>ftsSearchPrompts</code>, <code>searchMemories</code></td></tr>
384
+ <tr><td><code>vector</code></td><td>Top-K recall ≥ threshold + distance within ε; int8 quantization ≠ <code>halfvec</code> bit-for-bit.</td><td>Wall-E embedding KNN (<code>embeddings.js</code> <code>MATCH</code> → pgvector <code>&lt;=&gt;</code>)</td></tr>
385
+ </tbody>
386
+ </table>
387
+
388
+ <div class="callout">
389
+ <div class="h">This is the honest core of the design</div>
390
+ <p>If we demanded byte equality on FTS ranking or vector search, shadow would scream "mismatch" forever and we'd learn nothing. By naming the equality class <em>per operation</em>, the parity signal is meaningful: an <code>exact</code> op that diverges is a real bug; a <code>rank</code> op whose top-10 overlap dips below threshold is a tuning signal. The <code>OpContext.equality</code> field is set once, in a table next to each op, and reviewed as part of the port.</p>
391
+ </div>
392
+
393
+ <h3>The parity log (lives on the primary, never blocks)</h3>
394
+ <pre class="code"><span class="label">shared/storage — storage_parity_log</span><span class="k">CREATE TABLE</span> storage_parity_log (
395
+ id <span class="t">BIGSERIAL</span> <span class="k">PRIMARY KEY</span>,
396
+ ts <span class="t">TIMESTAMPTZ</span> <span class="k">NOT NULL DEFAULT</span> now(),
397
+ op <span class="t">TEXT</span> <span class="k">NOT NULL</span>, <span class="c">-- e.g. 'getSessionMessagesPage'</span>
398
+ kind <span class="t">TEXT</span> <span class="k">NOT NULL</span>, <span class="c">-- read-divergence | write-divergence | *-error</span>
399
+ equality <span class="t">TEXT</span>, <span class="c">-- exact | set | rank | vector</span>
400
+ args_hash <span class="t">TEXT</span>, <span class="c">-- stable hash of args (NO raw private values)</span>
401
+ detail <span class="t">JSONB</span>, <span class="c">-- diff summary, counts, overlap %, ε</span>
402
+ tenant_id <span class="t">UUID</span>
403
+ );
404
+ <span class="c">-- A dashboard reads mismatch-rate per op over time. Cutover gate watches this.</span>
405
+ </pre>
406
+ <p>Args are stored as a stable <em>hash</em>, not raw values — the parity log must never become a side-channel that leaks private memory or session content.</p>
407
+
408
+ <!-- ============================================ 05 ============================================ -->
409
+ <h2 id="schema"><span class="n">05</span>Schema port specifics — grounded in the real tables</h2>
410
+ <p class="lead">This is not hypothetical. CTM has ~45–50 tables (inline DDL in <code>createTables()</code>/<code>runMigrations()</code>, plus 59 idempotent <code>ALTER</code>s and 4 FTS5 virtual tables with sync triggers); Wall-E has ~70 tables behind <code>PRAGMA user_version</code> = 28, no FTS5, vectors in <code>sqlite-vec</code>. Here's exactly what each maps to.</p>
411
+
412
+ <table>
413
+ <thead><tr><th>SQLite artifact (real)</th><th>Where it lives today</th><th>Postgres target</th></tr></thead>
414
+ <tbody>
415
+ <tr>
416
+ <td>4 FTS5 virtual tables + <code>session_message_rows_ai/ad/au</code> triggers</td>
417
+ <td>CTM <code>db.js</code> (createTables/runMigrations)</td>
418
+ <td><code>tsvector</code> generated column + GIN index; triggers become <code>GENERATED ALWAYS AS</code> or a single <code>BEFORE</code> trigger. Query <code>MATCH</code> → <code>@@ to_tsquery</code>, rank via <code>ts_rank</code>. <span class="pill">equality: rank</span></td>
419
+ </tr>
420
+ <tr>
421
+ <td>LIKE-based <code>memory_index</code> + <code>searchMemories</code></td>
422
+ <td>Wall-E <code>brain.js</code></td>
423
+ <td><code>pg_trgm</code> GIN (substring) or <code>tsvector</code> if word-stemmed search is wanted. <span class="pill">equality: rank/set</span></td>
424
+ </tr>
425
+ <tr>
426
+ <td><code>sqlite-vec</code> <code>vec0</code>, int8-quantized (nomic 768-d), <code>MATCH … k=? ORDER BY distance</code></td>
427
+ <td>Wall-E <code>embeddings.js</code></td>
428
+ <td><code>pgvector</code> <code>halfvec(768)</code> + HNSW (optional VectorChord). <code>&lt;=&gt;</code>/<code>&lt;#&gt;</code> operators. int8→halfvec needs convert-or-re-embed + re-rank. <span class="pill">equality: vector</span></td>
429
+ </tr>
430
+ <tr>
431
+ <td>JSON-as-TEXT, <code>json_array_length</code></td>
432
+ <td>both</td>
433
+ <td><code>JSONB</code> + <code>jsonb_array_length</code> + GIN where queried.</td>
434
+ </tr>
435
+ <tr>
436
+ <td><code>INSERT OR IGNORE</code> / <code>ON CONFLICT DO UPDATE</code></td>
437
+ <td>both (upserts everywhere)</td>
438
+ <td><code>ON CONFLICT</code> — near-verbatim. The easiest part.</td>
439
+ </tr>
440
+ <tr>
441
+ <td>Schema version: CTM <code>settings.schema_version</code> row <em>and</em> Wall-E <code>PRAGMA user_version</code>=28</td>
442
+ <td>two different mechanisms (!)</td>
443
+ <td>Unify both onto a <code>schema_migrations</code> table per schema. The 59 CTM ALTERs + 28 Wall-E migrations become ordered, recorded steps.</td>
444
+ </tr>
445
+ <tr>
446
+ <td>WAL, file locks, manual checkpoints, owner-write-queue, storage-policy (Dropbox guard)</td>
447
+ <td><code>shared/sqlite-*.js</code> + workers</td>
448
+ <td>Deleted. MVCC + a connection pool replace all of it — a net simplification, not just a swap.</td>
449
+ </tr>
450
+ </tbody>
451
+ </table>
452
+
453
+ <div class="callout warn">
454
+ <div class="h">The two version mechanisms are a real trap</div>
455
+ <p>CTM tracks schema as a <code>settings</code> row; Wall-E uses <code>PRAGMA user_version</code>. A naïve migration that assumes one will silently skip the other's migrations. The port must read <em>both</em> and converge them onto a single recorded <code>schema_migrations</code> table — and the parity harness should assert post-migration schema equivalence before any data flows.</p>
456
+ </div>
457
+
458
+ <!-- ============================================ 06 ============================================ -->
459
+ <h2 id="topology"><span class="n">06</span>One server, two DBs — cluster topology (question 4.1)</h2>
460
+ <p class="lead"><b>Should one Postgres instance host both the CTM DB and the Wall-E brain?</b> Yes — as <em>two schemas in one database</em>, not two separate databases, and definitely not two clusters. The deciding factor is connection pooling.</p>
461
+
462
+ <figure>
463
+ <div class="svgwrap">
464
+ <svg viewBox="0 0 940 360" role="img" aria-label="One cluster, two schemas topology">
465
+ <defs>
466
+ <marker id="a2" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#5ec8ff"></path></marker>
467
+ <linearGradient id="boxg2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#141d2a"></stop><stop offset="1" stop-color="#0d141d"></stop></linearGradient>
468
+ </defs>
469
+ <g font-family="SF Mono, ui-monospace, monospace">
470
+ <!-- clients -->
471
+ <rect x="24" y="40" width="150" height="60" rx="10" fill="#0e1620" stroke="#2c4a5e"></rect>
472
+ <text x="99" y="66" fill="#e6edf3" font-size="12" text-anchor="middle">CTM server</text>
473
+ <text x="99" y="84" fill="#9aa7b8" font-size="10" text-anchor="middle">db-async seam</text>
474
+ <rect x="24" y="120" width="150" height="60" rx="10" fill="#0e1620" stroke="#2c4a5e"></rect>
475
+ <text x="99" y="146" fill="#e6edf3" font-size="12" text-anchor="middle">Wall-E daemon</text>
476
+ <text x="99" y="164" fill="#9aa7b8" font-size="10" text-anchor="middle">brain-async seam</text>
477
+
478
+ <!-- pgbouncer -->
479
+ <rect x="300" y="70" width="150" height="90" rx="12" fill="url(#boxg2)" stroke="#5ec8ff"></rect>
480
+ <text x="375" y="104" fill="#5ec8ff" font-size="13" font-weight="700" text-anchor="middle">pgbouncer</text>
481
+ <text x="375" y="124" fill="#9aa7b8" font-size="10" text-anchor="middle">transaction pooling</text>
482
+ <text x="375" y="140" fill="#9aa7b8" font-size="10" text-anchor="middle">pools ARE per-database</text>
483
+
484
+ <line x1="174" y1="76" x2="296" y2="104" stroke="#5ec8ff" stroke-width="1.8" marker-end="url(#a2)"></line>
485
+ <line x1="174" y1="150" x2="296" y2="128" stroke="#5ec8ff" stroke-width="1.8" marker-end="url(#a2)"></line>
486
+
487
+ <!-- postgres cluster -->
488
+ <rect x="540" y="26" width="372" height="300" rx="16" fill="url(#boxg2)" stroke="#7aa2ff"></rect>
489
+ <text x="726" y="52" fill="#7aa2ff" font-size="13" font-weight="700" text-anchor="middle">PostgreSQL cluster — one instance</text>
490
+ <text x="726" y="70" fill="#9aa7b8" font-size="10" text-anchor="middle">database: workbench · one backup domain · MVCC</text>
491
+
492
+ <!-- schema ctm -->
493
+ <rect x="566" y="90" width="150" height="100" rx="10" fill="#0e1620" stroke="#26503a"></rect>
494
+ <text x="641" y="116" fill="#7ee787" font-size="12" font-weight="700" text-anchor="middle">schema: ctm</text>
495
+ <text x="641" y="138" fill="#9aa7b8" font-size="10" text-anchor="middle">~45–50 tables</text>
496
+ <text x="641" y="154" fill="#9aa7b8" font-size="10" text-anchor="middle">tsvector + GIN</text>
497
+ <text x="641" y="170" fill="#9aa7b8" font-size="10" text-anchor="middle">sessions, prompts…</text>
498
+
499
+ <!-- schema walle -->
500
+ <rect x="736" y="90" width="150" height="100" rx="10" fill="#0e1620" stroke="#3a2e5a"></rect>
501
+ <text x="811" y="116" fill="#9d7bff" font-size="12" font-weight="700" text-anchor="middle">schema: walle</text>
502
+ <text x="811" y="138" fill="#9aa7b8" font-size="10" text-anchor="middle">~70 tables</text>
503
+ <text x="811" y="154" fill="#9aa7b8" font-size="10" text-anchor="middle">pgvector halfvec</text>
504
+ <text x="811" y="170" fill="#9aa7b8" font-size="10" text-anchor="middle">memories, knowledge…</text>
505
+
506
+ <!-- shared extensions -->
507
+ <rect x="566" y="210" width="320" height="92" rx="10" fill="#0e1620" stroke="#2c4a5e"></rect>
508
+ <text x="726" y="236" fill="#5ec8ff" font-size="11" font-weight="700" text-anchor="middle">shared extensions &amp; roles</text>
509
+ <text x="726" y="258" fill="#9aa7b8" font-size="10" text-anchor="middle">pgvector · pg_trgm · (optional) VectorChord</text>
510
+ <text x="726" y="276" fill="#9aa7b8" font-size="10" text-anchor="middle">RLS policies · per-app role · search_path</text>
511
+ <text x="726" y="293" fill="#9aa7b8" font-size="10" text-anchor="middle">storage_parity_log (during shadow)</text>
512
+
513
+ <line x1="450" y1="115" x2="536" y2="130" stroke="#5ec8ff" stroke-width="1.8" marker-end="url(#a2)"></line>
514
+ </g>
515
+ </svg>
516
+ </div>
517
+ <figcaption>Fig 2 · One cluster, one database, two schemas. <code>search_path</code> namespaces <code>ctm</code> vs <code>walle</code>; a single pgbouncer pool serves both because pooling is per-database.</figcaption>
518
+ </figure>
519
+
520
+ <table>
521
+ <thead><tr><th>Option</th><th>Isolation</th><th>Pooling</th><th>Ops</th><th>Verdict</th></tr></thead>
522
+ <tbody>
523
+ <tr>
524
+ <td><b>Two schemas, one DB</b> <span class="pill">recommended</span></td>
525
+ <td>Namespace separation; one role per app; <code>search_path</code></td>
526
+ <td><span class="yes">One pool, sane</span></td>
527
+ <td><span class="yes">One backup, one upgrade</span></td>
528
+ <td class="yes">Default. Cross-subsystem queries possible; clean separation.</td>
529
+ </tr>
530
+ <tr>
531
+ <td>Two databases, one cluster</td>
532
+ <td>Stronger; separate catalogs</td>
533
+ <td><span class="bad">Pools are per-DB → multiplies, risks <code>max_connections</code></span></td>
534
+ <td><span class="meh">Two backup targets</span></td>
535
+ <td class="meh">Only if you need catalog-level separation.</td>
536
+ </tr>
537
+ <tr>
538
+ <td>Two clusters</td>
539
+ <td>Total</td>
540
+ <td>Independent</td>
541
+ <td><span class="bad">2× everything</span></td>
542
+ <td class="no">Overkill for one workbench.</td>
543
+ </tr>
544
+ </tbody>
545
+ </table>
546
+
547
+ <div class="callout good">
548
+ <div class="h">Why pooling decides it</div>
549
+ <p>pgbouncer pools are calculated <em>per database</em>. Database-per-app means two independent pools competing for one <code>max_connections</code> budget — exactly the kind of foot-gun that bites later under load. Two schemas in one database share a pool, keep one backup/upgrade domain, and still let you grant a separate role per app and lock cross-schema access down with <code>search_path</code> + privileges. (Sources: Crunchy, PlanetScale.)</p>
550
+ </div>
551
+
552
+ <!-- ============================================ 07 ============================================ -->
553
+ <h2 id="tenant"><span class="n">07</span>Multi-tenant from day one (question 4.2)</h2>
554
+ <p class="lead"><b>What if the central instance must serve other users?</b> Per the brief, we design for that <em>now</em> — but "design for it" means <em>make every table tenant-scoped and let RLS enforce it</em>, not "stand up a billing system this quarter." The schema is ready; the product decision stays open.</p>
555
+
556
+ <h3>The recommendation: shared schema + <code>tenant_id</code> + RLS (with a db-per-tenant escape hatch)</h3>
557
+ <div class="grid3">
558
+ <div class="card">
559
+ <h4><span class="dot" style="background:var(--accent2)"></span>Shared schema + RLS</h4>
560
+ <p><b>The default.</b> One set of tables, every row carries <code>tenant_id</code>, RLS policy enforces scoping at the DB even if the app sends an unscoped query. Scales cleanly; one migration path.</p>
561
+ </div>
562
+ <div class="card">
563
+ <h4><span class="dot" style="background:var(--warn)"></span>Schema-per-tenant</h4>
564
+ <p>Stronger isolation, but operationally heavy past a few hundred tenants and the migration fan-out is real. Not the default.</p>
565
+ </div>
566
+ <div class="card">
567
+ <h4><span class="dot" style="background:var(--gpu)"></span>Database-per-tenant</h4>
568
+ <p>Reserved for a regulated / white-label tier needing physical isolation + per-tenant backup/restore. Hybrid: route those few via a tenant→backend map.</p>
569
+ </div>
570
+ </div>
571
+
572
+ <h3>What "tenant-scoped now" concretely means</h3>
573
+ <pre class="code"><span class="label">RLS — the DB-level safety net (NOT the only authz gate)</span><span class="c">-- 1) every table gets the column, even while we run single-tenant</span>
574
+ <span class="k">ALTER TABLE</span> walle.memories <span class="k">ADD COLUMN</span> tenant_id <span class="t">UUID</span> <span class="k">NOT NULL DEFAULT</span> <span class="s">'…self…'</span>;
575
+
576
+ <span class="c">-- 2) turn on RLS and scope by a per-connection setting</span>
577
+ <span class="k">ALTER TABLE</span> walle.memories <span class="k">ENABLE ROW LEVEL SECURITY</span>;
578
+ <span class="k">CREATE POLICY</span> tenant_isolation <span class="k">ON</span> walle.memories
579
+ <span class="k">USING</span> (tenant_id = current_setting(<span class="s">'app.tenant_id'</span>)::<span class="t">uuid</span>);
580
+
581
+ <span class="c">-- 3) the pool sets the tenant on every checkout, inside the txn</span>
582
+ <span class="c">-- pgbouncer transaction-pooling safe: SET LOCAL is scoped to the txn</span>
583
+ <span class="k">SET LOCAL</span> app.tenant_id = <span class="s">'…current user…'</span>;
584
+ </pre>
585
+
586
+ <pre class="code"><span class="label">pg-backend — bind tenant per checkout</span><span class="k">async</span> <span class="f">run</span>(ctx, ...args) {
587
+ <span class="k">const</span> client = <span class="k">await</span> <span class="k">this</span>.pool.<span class="f">connect</span>();
588
+ <span class="k">try</span> {
589
+ <span class="k">await</span> client.<span class="f">query</span>(<span class="s">'BEGIN'</span>);
590
+ <span class="k">await</span> client.<span class="f">query</span>(<span class="s">"SET LOCAL app.tenant_id = $1"</span>, [ctx.tenantId]);
591
+ <span class="k">return</span> <span class="k">await</span> <span class="k">this</span>.<span class="f">dispatch</span>(client, ctx, args); <span class="c">// RLS now scopes every row</span>
592
+ } <span class="k">finally</span> { <span class="k">await</span> client.<span class="f">query</span>(<span class="s">'COMMIT'</span>); client.<span class="f">release</span>(); }
593
+ }
594
+ </pre>
595
+
596
+ <div class="callout warn">
597
+ <div class="h">Honest about what multi-tenancy actually costs</div>
598
+ <p>Today the brain is a <em>single trusted dataset</em> — your memories, your sessions. Multi-tenant turns it into a system that needs: authentication &amp; per-request tenant identity, isolation <em>tests</em> (a cross-tenant leak is a breach, not a bug), per-tenant backup/restore, and abuse/quota controls. And the limit of RLS: it isolates <b>rows</b>, not CPU, disk, or shared cache — a noisy tenant can still degrade everyone. RLS is the safety net under app-level scoping, never a substitute for it. (Sources: AWS, Nile, ClickHouse, PlanetScale.)</p>
599
+ </div>
600
+
601
+ <div class="callout good">
602
+ <div class="h">Why design it in now even if we never flip it on</div>
603
+ <p>Adding <code>tenant_id</code> + RLS to a live, populated database later is a painful backfill-and-rewrite migration. Adding it to tables we're <em>already porting</em> during the shadow phase is nearly free. So the rule is: <b>tenant-scope every table during the port; run with a single self tenant; opening to others becomes a config + auth decision, not a schema migration.</b></p>
604
+ </div>
605
+
606
+ <!-- ============================================ 08 ============================================ -->
607
+ <h2 id="ladder"><span class="n">08</span>Phased rollout &amp; the shadow ladder</h2>
608
+ <p class="lead">This doesn't replace Phases 0–5 from the spec — it operationalizes the risky middle. The parity harness the spec calls for in Phase 3 isn't a one-off offline diff; it's <code>shadow-pg</code> mode running continuously, in-process, under real traffic.</p>
609
+
610
+ <table>
611
+ <thead><tr><th>Phase</th><th>This design's contribution</th><th>Mode</th><th>Status</th></tr></thead>
612
+ <tbody>
613
+ <tr><td><b>0 — Async seam</b></td><td>Already done (CTM). Build the analogous <code>brain-async.js</code> for Wall-E.</td><td><code>sqlite</code></td><td><span class="tag now">CTM shipped</span></td></tr>
614
+ <tr><td><b>1 — Abstraction + flags</b></td><td><code>shared/storage/</code> contract, <code>SqliteBackend</code>, mode selector, parity log table. Pg DDL (two schemas, tenant-scoped).</td><td><code>sqlite</code></td><td><span class="tag soon">next</span></td></tr>
615
+ <tr><td><b>2 — PgBackend + port subsystems</b></td><td><code>PgBackend</code> op bodies; FTS5→tsvector, sqlite-vec→pgvector. Per-op <code>equality</code> classes assigned.</td><td><code>shadow-pg</code> (allowlist grows)</td><td><span class="tag soon">next</span></td></tr>
616
+ <tr><td><b>3 — Prove parity</b></td><td>Run <code>shadow-pg</code> broadly; watch <code>storage_parity_log</code> mismatch-rate per op until below threshold.</td><td><code>shadow-pg</code> (<code>*</code>)</td><td><span class="tag far">gated</span></td></tr>
617
+ <tr><td><b>4 — Promote &amp; cut over</b></td><td>Flip to Pg-primary, keep SQLite as net; then retire SQLite machinery (write-lock, owner-queue, storage-policy, workers, FTS triggers).</td><td><code>shadow-sqlite</code> → <code>postgres</code></td><td><span class="tag far">gated</span></td></tr>
618
+ <tr><td><b>5 — Central server</b></td><td>Hosted instance, pgbouncer, read-cache, auth; turn RLS on for real if/when other users arrive.</td><td><code>postgres</code></td><td><span class="tag far">payoff</span></td></tr>
619
+ </tbody>
620
+ </table>
621
+
622
+ <div class="num-strip">
623
+ <div class="stat"><div class="v">1 op</div><div class="l">unit of migration risk — via SHADOW_OPS allowlist</div></div>
624
+ <div class="stat"><div class="v">0</div><div class="l">user-visible failures a shadow secondary may cause</div></div>
625
+ <div class="stat"><div class="v">2 schemas</div><div class="l">ctm + walle in one database, one pool</div></div>
626
+ <div class="stat"><div class="v">4 classes</div><div class="l">exact / set / rank / vector equality</div></div>
627
+ </div>
628
+
629
+ <!-- ============================================ 09 ============================================ -->
630
+ <h2 id="risks"><span class="n">09</span>Risks &amp; honest caveats</h2>
631
+ <div class="grid2">
632
+ <div class="card">
633
+ <h4><span class="dot" style="background:var(--bad)"></span>Dual-write races / partial failure</h4>
634
+ <p>Primary commits, secondary fails → divergence. Mitigated by: primary-is-truth ordering, best-effort secondary, parity log, and re-sync from primary. We are <em>not</em> attempting distributed transactions.</p>
635
+ </div>
636
+ <div class="card">
637
+ <h4><span class="dot" style="background:var(--warn)"></span>Shadow doubles write load</h4>
638
+ <p>Every write hits both engines; sampled reads hit both. Acceptable for a single-user workbench; revisit sample rate before any real fleet.</p>
639
+ </div>
640
+ <div class="card">
641
+ <h4><span class="dot" style="background:var(--warn)"></span>The abstraction can leak</h4>
642
+ <p>Transactions spanning multiple ops, SQLite-specific PRAGMAs, <code>last_insert_rowid()</code> semantics. The contract must model multi-op transactions explicitly, not pretend every op is standalone.</p>
643
+ </div>
644
+ <div class="card">
645
+ <h4><span class="dot" style="background:var(--gpu)"></span>Non-deterministic ops</h4>
646
+ <p>FTS ranking and vector top-k won't match byte-for-byte. Handled by equality classes — but the thresholds need tuning, and a too-loose threshold hides real regressions.</p>
647
+ </div>
648
+ <div class="card">
649
+ <h4><span class="dot" style="background:var(--pg)"></span>pgbouncer transaction pooling</h4>
650
+ <p>No session-level state, prepared-statement &amp; <code>SET</code> caveats. The <code>SET LOCAL … inside BEGIN</code> pattern is chosen precisely to be transaction-pooling safe.</p>
651
+ </div>
652
+ <div class="card">
653
+ <h4><span class="dot" style="background:var(--bad)"></span>int8 vectors don't convert cleanly</h4>
654
+ <p>Wall-E's nomic embeddings are int8-quantized in <code>sqlite-vec</code>. To pgvector <code>halfvec</code> means convert-with-rerank or re-embed — a real, measurable step, not a cast.</p>
655
+ </div>
656
+ </div>
657
+
658
+ <div class="callout">
659
+ <div class="h">The caveat that governs all the others</div>
660
+ <p>None of this is required to fix the freezes — the Phase&nbsp;0 async seam already captures most of that relief on SQLite alone. This entire body of work is justified by <b>remote/multi-device access and a real central server</b>. If that ambition cools, the rational stop is "async seam shipped, SQLite stays." The shadow ladder exists so that <em>if</em> we go, we go on evidence.</p>
661
+ </div>
662
+
663
+ <!-- ============================================ 10 ============================================ -->
664
+ <h2 id="checkpoints"><span class="n">10</span>Decision checkpoints — what must be true to climb each rung</h2>
665
+ <table>
666
+ <thead><tr><th>Gate</th><th>Advance only when…</th><th>Roll back if…</th></tr></thead>
667
+ <tbody>
668
+ <tr>
669
+ <td><code>sqlite</code> → <code>shadow-pg</code></td>
670
+ <td>Pg schema (both schemas, tenant-scoped) stands up; schema-equivalence assertion passes; <code>SqliteBackend</code> proven a no-op (all tests green through the seam).</td>
671
+ <td>Any behavior change vs today.</td>
672
+ </tr>
673
+ <tr>
674
+ <td>grow the <code>SHADOW_OPS</code> allowlist</td>
675
+ <td>Each added op shows zero <code>exact</code>-class divergences and in-threshold <code>rank</code>/<code>vector</code> over a soak window.</td>
676
+ <td>Any <code>exact</code> divergence (that's a real bug — fix before widening).</td>
677
+ </tr>
678
+ <tr>
679
+ <td><code>shadow-pg</code> → <code>shadow-sqlite</code></td>
680
+ <td>Full-op shadow runs N hours; mismatch-rate under threshold; Pg p50/p99 read latency acceptable with cache.</td>
681
+ <td>Mismatch-rate or latency regresses → stay on SQLite-primary.</td>
682
+ </tr>
683
+ <tr>
684
+ <td><code>shadow-sqlite</code> → <code>postgres</code></td>
685
+ <td>Pg-primary ran in production-shape with SQLite mirror agreeing; backup/restore drill on Pg succeeds.</td>
686
+ <td>Any data-loss or correctness surprise with Pg as truth.</td>
687
+ </tr>
688
+ <tr>
689
+ <td><code>postgres</code> → multi-tenant on</td>
690
+ <td>Auth + per-request tenant identity exist; cross-tenant isolation tests pass; per-tenant backup/restore proven; quota controls in place.</td>
691
+ <td>Any cross-tenant leak in testing (this gate is a breach boundary, not a feature flag).</td>
692
+ </tr>
693
+ </tbody>
694
+ </table>
695
+
696
+ <div class="callout good">
697
+ <div class="h">The one-sentence version</div>
698
+ <p>Build one storage abstraction, let Postgres shadow SQLite under real traffic until a per-operation parity signal says it's safe, promote on evidence, retire the SQLite machinery, and — because we tenant-scoped every table on the way through — opening the central server to other users is a config-and-auth decision rather than a migration.</p>
699
+ </div>
700
+
701
+ <footer>
702
+ <p><b>Companion documents</b></p>
703
+ <p class="src">
704
+ · <a href="postgres-concurrency-dgx-spark.html">postgres-concurrency-dgx-spark.html</a> — the "is it worth it / supercomputer-as-a-service" framing.<br>
705
+ · <code>~/.claude/plans/let-s-discuss-do-online-frolicking-pascal.md</code> — the approved Phase 0–5 spec this design operationalizes.
706
+ </p>
707
+ <p style="margin-top:18px"><b>Sources</b></p>
708
+ <p class="src">
709
+ Multi-tenancy &amp; RLS:
710
+ <a href="https://clickhouse.com/resources/engineering/multi-tenant-saas-postgres-architecture">ClickHouse — multi-tenant SaaS on Postgres</a> ·
711
+ <a href="https://www.thenile.dev/blog/multi-tenant-rls">Nile — Postgres RLS</a> ·
712
+ <a href="https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/">AWS — RLS data isolation</a> ·
713
+ <a href="https://planetscale.com/blog/approaches-to-tenancy-in-postgres">PlanetScale — approaches to tenancy</a><br>
714
+ Shadow / dual-write:
715
+ <a href="https://www.infoq.com/articles/shadow-table-strategy-data-migration/">InfoQ — shadow table strategy</a> ·
716
+ <a href="https://medium.com/google-cloud/online-database-migration-by-dual-write-this-is-not-for-everyone-cb4307118f4b">Bussler — online migration by dual-write</a><br>
717
+ Pooling / topology:
718
+ <a href="https://www.crunchydata.com/blog/postgres-at-scale-running-multiple-pgbouncers">Crunchy — running multiple pgbouncers</a> ·
719
+ <a href="https://planetscale.com/blog/scaling-postgres-connections-with-pgbouncer">PlanetScale — scaling Postgres connections with pgbouncer</a><br>
720
+ Vectors / FTS:
721
+ <a href="https://github.com/pgvector/pgvector">pgvector</a> ·
722
+ <a href="https://neon.com/blog/dont-use-vector-use-halvec-instead-and-save-50-of-your-storage-cost">Neon — halfvec</a> ·
723
+ <a href="https://www.paradedb.com/learn/search-in-postgresql/full-text-search">ParadeDB — Postgres FTS</a><br>
724
+ Hosting:
725
+ <a href="https://www.buildmvpfast.com/blog/neon-vs-supabase-vs-turso-serverless-postgres-mvp-2026">Neon vs Supabase vs Turso (2026)</a>
726
+ </p>
727
+ <p style="margin-top:18px;color:#5f6e80">Codebase claims (function/table counts, flag names, FTS5/sqlite-vec, schema-version mechanisms, Fly-SQLite) verified against <code>db.js</code>, <code>db-async.js</code>, <code>brain.js</code>, <code>embeddings.js</code>, <code>fly.toml</code>, and <code>shared/sqlite-*.js</code> at time of writing. Design doc for discussion — no code was changed.</p>
728
+ </footer>
729
+
730
+ </div>
731
+ </body>
732
+ </html>