<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://reddb.io/blog/rss.xml</id>
  <title>RedDB blog</title>
  <subtitle>Engineering writing on multi-model databases, RAG, vault security.</subtitle>
  <link href="https://reddb.io/blog/rss.xml" rel="self"/>
  <link href="https://reddb.io/blog"/>
  <updated>2026-06-09</updated>
  <entry>
    <id>https://reddb.io/blog/reddb-on-a-raspberry-pi</id>
    <title>RedDB on a Raspberry Pi: the smallest sane install</title>
    <link href="https://reddb.io/blog/reddb-on-a-raspberry-pi"/>
    <updated>2026-06-09</updated>
    <published>2026-06-09</published>
    <author><name>RedDB team</name></author>
    <summary>A weekend exercise — image a Pi 5, install RedDB, load a hundred thousand rows, and see what happens when document + vector + KV + blob all live on a $80 board under the desk.</summary>
    <content type="html">&lt;p&gt;The pitch we keep making is that RedDB collapses four engines into one — document, vector, KV, blob — sharing one WAL, one auth surface, one ordered cursor for change capture. That story sounds different on a server with 64 cores and 512 GB of RAM than it does on a Raspberry Pi 5 that draws less power than the desk lamp it sits next to.&lt;/p&gt;
&lt;p&gt;So we put one on a Pi 5 for a weekend. This is the playbook and the numbers.&lt;/p&gt;
&lt;h2&gt;What you need&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Raspberry Pi 5, 8 GB model. The 4 GB will technically run but the HNSW index for the demo dataset spills under memory pressure and your numbers will be noise.&lt;/li&gt;
&lt;li&gt;A real SSD over USB 3, or an NVMe HAT. Do not run this on the microSD card. RedDB&amp;#39;s WAL is &lt;code&gt;fsync&lt;/code&gt;-heavy; an SD card will both bottleneck writes and wear out in a month.&lt;/li&gt;
&lt;li&gt;The active cooler. The CPU will sit at 65–70 °C under load and will throttle without it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Total cost of the rig as we built it: roughly $130 for the Pi + cooler + power supply + 256 GB NVMe + HAT. Less than a single month of the smallest managed-Postgres tier on any cloud.&lt;/p&gt;
&lt;h2&gt;Image and install&lt;/h2&gt;
&lt;p&gt;We used the 64-bit Pi OS Lite release, no desktop. Flash it with the official imager, enable SSH from the imager&amp;#39;s settings pane before writing, and boot.&lt;/p&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh pi@reddb-pi.local
sudo apt update &amp;amp;&amp;amp; sudo apt install -y curl
curl -fsSL https://get.reddb.io/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The install script detects &lt;code&gt;aarch64&lt;/code&gt;, pulls the ARM64 build, drops a &lt;code&gt;reddb&lt;/code&gt; binary in &lt;code&gt;/usr/local/bin&lt;/code&gt;, and writes a systemd unit at &lt;code&gt;/etc/systemd/system/reddb.service&lt;/code&gt; pointing the data directory at &lt;code&gt;/var/lib/reddb&lt;/code&gt;. If you mounted the NVMe at &lt;code&gt;/mnt/nvme&lt;/code&gt;, point the data dir there instead before starting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo sed -i &amp;#39;s|/var/lib/reddb|/mnt/nvme/reddb|&amp;#39; /etc/systemd/system/reddb.service
sudo systemctl daemon-reload
sudo systemctl enable --now reddb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Health check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl http://localhost:7878/health
# {&amp;quot;status&amp;quot;:&amp;quot;ok&amp;quot;,&amp;quot;version&amp;quot;:&amp;quot;...&amp;quot;,&amp;quot;uptime_ms&amp;quot;:1843}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the whole install. There is no second engine to provision, no extension to compile, no sidecar to wire up.&lt;/p&gt;
&lt;h2&gt;Load a dataset&lt;/h2&gt;
&lt;p&gt;We used a tiny slice of an open product catalog — 100,000 rows, each with a title, a 256-character description, and a 384-dimensional embedding generated locally with &lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt; (which itself runs on the Pi, slowly but it runs).&lt;/p&gt;
&lt;p&gt;Create the table and the index:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE products (
  id          UUID PRIMARY KEY,
  title       TEXT NOT NULL,
  description TEXT NOT NULL,
  price_cents INT NOT NULL,
  embedding   VECTOR(384) NOT NULL
);

CREATE INDEX products_embedding_idx
  ON products
  USING hnsw (embedding vector_cosine_ops);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bulk-load over the line-delimited JSON import path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;reddb import --table products --format ndjson &amp;lt; products.ndjson
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Load time for 100,000 rows on NVMe: &lt;strong&gt;94 seconds&lt;/strong&gt;, including HNSW index construction with default &lt;code&gt;M=16&lt;/code&gt; and &lt;code&gt;ef_construction=200&lt;/code&gt;. That works out to roughly 1,064 rows per second, embedding bytes included.&lt;/p&gt;
&lt;h2&gt;Run a query&lt;/h2&gt;
&lt;p&gt;Pick a vector to search with — in practice the embedding of a user&amp;#39;s query, here we just pulled one from a random row to keep the demo self-contained — and run nearest-neighbor search filtered by price:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, title, price_cents,
       1 - (embedding &amp;lt;=&amp;gt; $1) AS similarity
FROM products
WHERE price_cents &amp;lt; 5000
ORDER BY embedding &amp;lt;=&amp;gt; $1
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Latency numbers, warm cache, single client, &lt;code&gt;ef_search=64&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;P50&lt;/th&gt;
&lt;th&gt;P99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;KV &lt;code&gt;GET&lt;/code&gt; (small value)&lt;/td&gt;
&lt;td&gt;0.4 ms&lt;/td&gt;
&lt;td&gt;1.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document point read by primary key&lt;/td&gt;
&lt;td&gt;0.6 ms&lt;/td&gt;
&lt;td&gt;1.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector top-10 over 100k rows, no filter&lt;/td&gt;
&lt;td&gt;8 ms&lt;/td&gt;
&lt;td&gt;18 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector top-10 with &lt;code&gt;price_cents&lt;/code&gt; predicate&lt;/td&gt;
&lt;td&gt;11 ms&lt;/td&gt;
&lt;td&gt;24 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single-row upsert (text + vector, one tx)&lt;/td&gt;
&lt;td&gt;4 ms&lt;/td&gt;
&lt;td&gt;9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 KB blob write + immediate read&lt;/td&gt;
&lt;td&gt;6 ms&lt;/td&gt;
&lt;td&gt;14 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;None of these are competitive with a fat server. All of them are well inside what makes for a responsive demo, a homelab assistant, an on-device search UI, or a workshop you give to a room full of students who can each have their own Pi.&lt;/p&gt;
&lt;h2&gt;What this is and is not&lt;/h2&gt;
&lt;p&gt;This is portability proof. The same binary, the same protocol, the same SQL surface that runs on a c7g.8xlarge runs on $130 of hardware on your desk. There is no &amp;quot;lite&amp;quot; build, no missing modality, no separate config flag for &amp;quot;small mode.&amp;quot;&lt;/p&gt;
&lt;p&gt;It is not a production deployment target for a real customer-facing system. The Pi&amp;#39;s single SSD is your single point of failure, the network is whatever&amp;#39;s in your house, and one CPU throttle event takes you offline. Treat it as a hobby rig, a dev environment, a teaching tool, an edge node where the alternative is no database at all.&lt;/p&gt;
&lt;p&gt;What we like about this install is what it implies about every other install. If RedDB fits on a Pi, then the operator complexity of the four-engine stack we replaced — Postgres, Qdrant, Redis, S3, each with its own backups, auth, monitoring, version — was never inherent to the problem. It was the toll of the stitching.&lt;/p&gt;
&lt;p&gt;The smallest sane install is one engine, one binary, one systemd unit. Everywhere else, you just need a bigger Pi.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/cross-session-task-queues</id>
    <title>Cross-session task queues — AFK agents with durable resume</title>
    <link href="https://reddb.io/blog/cross-session-task-queues"/>
    <updated>2026-06-07</updated>
    <published>2026-06-07</published>
    <author><name>RedDB team</name></author>
    <summary>An AFK agent&apos;s to-do list is the worst place to keep ephemeral state. Move it to a RedDB queue with checkpoints, idempotency keys, and a watchdog and a Stop becomes a pause, a crash becomes a retry, and a machine swap becomes a noop.</summary>
    <content type="html">&lt;h2&gt;The AFK promise that breaks at 3am&lt;/h2&gt;
&lt;p&gt;You hand the agent a 40-item backlog before bed. Eight hours later you wake up to find it crashed on item 12, the context window got compacted past the point of useful, and the remaining 28 items live only inside a transcript nobody will read. Or worse — it finished items 1–11, started 12, crashed, restarted, and ran 12 again (writing duplicate rows, double-posting the PR comment, double-charging the API).&lt;/p&gt;
&lt;p&gt;The Ralph loop and every other AFK pattern share the same failure mode: &lt;strong&gt;the task list lives in the agent&amp;#39;s head&lt;/strong&gt;. Stop the agent and the list vanishes. Restart it and there&amp;#39;s no idempotency on what was already done.&lt;/p&gt;
&lt;p&gt;The fix is unromantic: model the task list as rows in RedDB. State machine on each row. Idempotency key on each side-effect. Watchdog that reclaims expired leases. Sibling to &lt;a href=&quot;/blog/sub-agent-dispatch-queue&quot;&gt;D12&amp;#39;s sub-agent dispatch queue&lt;/a&gt; but pointed inward — &lt;em&gt;the agent&amp;#39;s own work&lt;/em&gt;, not its fan-out.&lt;/p&gt;
&lt;h2&gt;The schema: state, checkpoint, idem_key&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TYPE afk_state AS ENUM (&amp;#39;todo&amp;#39;, &amp;#39;running&amp;#39;, &amp;#39;paused&amp;#39;, &amp;#39;done&amp;#39;, &amp;#39;failed&amp;#39;);

CREATE TABLE afk_task (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  agent_id      text NOT NULL,                      -- which AFK runner owns this list
  ordinal       int  NOT NULL,                      -- stable order in the backlog
  title         text NOT NULL,
  spec          jsonb NOT NULL,                     -- prompt + acceptance criteria
  state         afk_state NOT NULL DEFAULT &amp;#39;todo&amp;#39;,
  checkpoint    jsonb,                              -- mid-task progress: files touched, sub-results
  idem_key      text UNIQUE,                        -- one row per (agent, logical-task)
  lease_until   timestamptz,                        -- null when not running
  attempts      int NOT NULL DEFAULT 0,
  max_attempts  int NOT NULL DEFAULT 5,
  last_error    text,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX afk_task_next_idx
  ON afk_task (agent_id, ordinal)
  WHERE state IN (&amp;#39;todo&amp;#39;,&amp;#39;paused&amp;#39;);

CREATE INDEX afk_task_lease_idx
  ON afk_task (lease_until)
  WHERE state = &amp;#39;running&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three columns do the heavy lifting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;state&lt;/code&gt; — the row&amp;#39;s place in the lifecycle. &lt;code&gt;todo → running → done&lt;/code&gt; on the happy path; &lt;code&gt;running → paused&lt;/code&gt; on a clean Stop; &lt;code&gt;running → failed&lt;/code&gt; after &lt;code&gt;max_attempts&lt;/code&gt;; &lt;code&gt;paused → running&lt;/code&gt; on resume.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;checkpoint&lt;/code&gt; — every non-trivial step writes its progress here. Files modified, sub-prompts completed, partial scores. The agent that picks the task up after a crash reads this before deciding what to do next.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lease_until&lt;/code&gt; — set when a worker claims the row, refreshed on each checkpoint. The watchdog reclaims rows whose lease expired (worker crashed without releasing).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Enqueue: idempotent and ordered&lt;/h2&gt;
&lt;p&gt;The dispatcher is whatever produces the backlog — a &lt;code&gt;/plan&lt;/code&gt; command, a &lt;code&gt;to-issues&lt;/code&gt; skill output, a CI job. It writes rows with &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; on &lt;code&gt;idem_key&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { Client } from &amp;#39;pg&amp;#39;

export async function enqueue(
  pg: Client,
  agentId: string,
  tasks: { title: string; spec: object; idemKey: string }[],
) {
  await pg.query(&amp;#39;BEGIN&amp;#39;)
  try {
    const startOrdinal = (
      await pg.query(
        `SELECT COALESCE(MAX(ordinal), 0) AS m FROM afk_task WHERE agent_id = $1`,
        [agentId],
      )
    ).rows[0].m

    for (const [i, t] of tasks.entries()) {
      await pg.query(
        `INSERT INTO afk_task (agent_id, ordinal, title, spec, idem_key)
         VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (idem_key) DO NOTHING`,
        [agentId, startOrdinal + i + 1, t.title, t.spec, t.idemKey],
      )
    }
    await pg.query(&amp;#39;COMMIT&amp;#39;)
  } catch (e) {
    await pg.query(&amp;#39;ROLLBACK&amp;#39;)
    throw e
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;idem_key&lt;/code&gt; is the contract. &lt;code&gt;&amp;quot;afk:ralph:issue-145:v1&amp;quot;&lt;/code&gt; survives every restart and every duplicate dispatch. Re-running the same &lt;code&gt;/plan&lt;/code&gt; is a noop.&lt;/p&gt;
&lt;h2&gt;Claim: SKIP LOCKED + lease&lt;/h2&gt;
&lt;p&gt;The agent&amp;#39;s run loop claims one row at a time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const CLAIM = `
  WITH next AS (
    SELECT id FROM afk_task
     WHERE agent_id = $1
       AND state IN (&amp;#39;todo&amp;#39;,&amp;#39;paused&amp;#39;)
     ORDER BY ordinal
     FOR UPDATE SKIP LOCKED
     LIMIT 1
  )
  UPDATE afk_task t
     SET state       = &amp;#39;running&amp;#39;,
         lease_until = now() + interval &amp;#39;15 minutes&amp;#39;,
         attempts    = t.attempts + 1,
         updated_at  = now()
    FROM next
   WHERE t.id = next.id
  RETURNING t.*;
`

export async function claimNext(pg: Client, agentId: string) {
  const r = await pg.query(CLAIM, [agentId])
  return r.rows[0] ?? null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is what makes this safe under multiple parallel runners on the same &lt;code&gt;agent_id&lt;/code&gt; — two machines running the same AFK loop will never grab the same row. 15-minute lease is a starting point; tune to the longest reasonable task duration.&lt;/p&gt;
&lt;h2&gt;Checkpoint: write often, write cheap&lt;/h2&gt;
&lt;p&gt;Every meaningful step inside the task body writes a checkpoint. The frequency is the recovery-cost knob — too sparse and a crash redoes hours of work; too dense and you bloat the row.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export async function checkpoint(
  pg: Client,
  taskId: string,
  patch: object,
) {
  await pg.query(
    `UPDATE afk_task
        SET checkpoint = COALESCE(checkpoint, &amp;#39;{}&amp;#39;::jsonb) || $2::jsonb,
            lease_until = now() + interval &amp;#39;15 minutes&amp;#39;,
            updated_at  = now()
      WHERE id = $1`,
    [taskId, patch],
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;jsonb || jsonb&lt;/code&gt; merge keeps it additive — no read-modify-write race between the worker and a stop-handler. Inside a Ralph iteration the natural checkpoint boundaries are: after exploration, after first edit, after &lt;code&gt;pnpm test&lt;/code&gt; passes, after commit. Four writes per task is plenty.&lt;/p&gt;
&lt;h2&gt;Resume: read the checkpoint, don&amp;#39;t redo it&lt;/h2&gt;
&lt;p&gt;On startup the agent reads its own state before claiming a new row:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const r = await pg.query(
  `SELECT * FROM afk_task
    WHERE agent_id = $1 AND state = &amp;#39;running&amp;#39;
    ORDER BY ordinal LIMIT 1`,
  [agentId],
)
if (r.rows[0]) {
  // crashed mid-flight; the row&amp;#39;s lease will expire and the watchdog
  // will flip it to &amp;#39;paused&amp;#39; for us. Pick it up explicitly:
  return resumeFromCheckpoint(r.rows[0])
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;resumeFromCheckpoint&lt;/code&gt; is the per-task-kind logic — for a Ralph iteration that means re-reading the issue file plus the checkpoint&amp;#39;s &lt;code&gt;files_touched&lt;/code&gt; list, skipping the parts already done, picking up at the next unmet acceptance criterion. The checkpoint isn&amp;#39;t a magic resume — it&amp;#39;s a structured note from past-self to future-self.&lt;/p&gt;
&lt;h2&gt;Watchdog: reclaim, retry, give up&lt;/h2&gt;
&lt;p&gt;A separate process (cron, systemd timer, or a &lt;code&gt;Stop&lt;/code&gt; hook wired into the agent itself) runs every minute:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 1. Reclaim expired leases
UPDATE afk_task
   SET state = CASE
                 WHEN attempts &amp;gt;= max_attempts THEN &amp;#39;failed&amp;#39;
                 ELSE &amp;#39;paused&amp;#39;
               END,
       lease_until = NULL,
       last_error  = COALESCE(last_error, &amp;#39;lease expired&amp;#39;),
       updated_at  = now()
 WHERE state = &amp;#39;running&amp;#39;
   AND lease_until &amp;lt; now();

-- 2. Surface poison pills
SELECT id, agent_id, title, attempts, last_error
  FROM afk_task
 WHERE state = &amp;#39;failed&amp;#39;
   AND updated_at &amp;gt; now() - interval &amp;#39;1 hour&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first statement is the resume mechanism — a crashed worker&amp;#39;s row gets flipped back to &lt;code&gt;paused&lt;/code&gt; so the next claim picks it up. The second is the dead-letter view that the human (or an orchestrator) checks in the morning.&lt;/p&gt;
&lt;h2&gt;Stop handler: graceful pause, not a crash&lt;/h2&gt;
&lt;p&gt;Claude Code emits a &lt;code&gt;Stop&lt;/code&gt; event when the user cancels mid-flight. Wiring a hook to it flips the row cleanly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;hooks&amp;quot;: {
    &amp;quot;Stop&amp;quot;: [
      {
        &amp;quot;matcher&amp;quot;: &amp;quot;.*&amp;quot;,
        &amp;quot;command&amp;quot;: &amp;quot;psql $RDB_URL -c \&amp;quot;UPDATE afk_task SET state=&amp;#39;paused&amp;#39;, lease_until=NULL, updated_at=now() WHERE agent_id=&amp;#39;$AFK_AGENT_ID&amp;#39; AND state=&amp;#39;running&amp;#39;\&amp;quot;&amp;quot;
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The difference between &lt;code&gt;paused&lt;/code&gt; and a watchdog-reclaimed &lt;code&gt;running&lt;/code&gt; is intent — a &lt;code&gt;paused&lt;/code&gt; row was put down deliberately; a &lt;code&gt;running&lt;/code&gt; row past its lease was abandoned. The watchdog treats them identically on resume, but the audit trail keeps the distinction.&lt;/p&gt;
&lt;h2&gt;When to skip this&lt;/h2&gt;
&lt;p&gt;Three cases where adding a queue is overkill:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single-task agents.&lt;/strong&gt; If the agent does one thing and exits, the durability surface is the commit, not the queue. Don&amp;#39;t wrap a &lt;code&gt;/fix-one-bug&lt;/code&gt; flow in this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sub-second tasks.&lt;/strong&gt; Lease overhead dominates. Use the in-process &lt;code&gt;Task&lt;/code&gt; tool; the queue exists for tasks measured in minutes, not seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tasks with no idempotency story.&lt;/strong&gt; If you can&amp;#39;t write a stable &lt;code&gt;idem_key&lt;/code&gt;, you can&amp;#39;t safely retry. Either fix the side-effect to be idempotent or accept the risk and document it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Tying it together&lt;/h2&gt;
&lt;p&gt;The Ralph loop and every AFK pattern in this series — &lt;a href=&quot;/blog/slash-commands-with-memory&quot;&gt;/remember commands&lt;/a&gt;, &lt;a href=&quot;/blog/eval-datasets-in-reddb&quot;&gt;eval replay&lt;/a&gt;, &lt;a href=&quot;/blog/sub-agent-dispatch-queue&quot;&gt;sub-agent fan-out&lt;/a&gt; — all become noticeably more boring once their state machine is a table you can &lt;code&gt;SELECT&lt;/code&gt; from. The observability queries from &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;D9&lt;/a&gt; join straight against &lt;code&gt;afk_task&lt;/code&gt; so &amp;quot;what&amp;#39;s my agent doing right now?&amp;quot; stops being a transcript-grep problem and starts being a SQL problem. Boring is the goal.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/eval-datasets-in-reddb</id>
    <title>Eval datasets in RedDB — from ad-hoc prompts to versioned regression sets</title>
    <link href="https://reddb.io/blog/eval-datasets-in-reddb"/>
    <updated>2026-06-05</updated>
    <published>2026-06-05</published>
    <author><name>RedDB team</name></author>
    <summary>Most teams evaluate agents with a notebook full of one-off prompts that nobody re-runs. Capture every interesting turn into a RedDB table, tag it, version it, and your eval becomes a query that any model upgrade can be diffed against in seconds.</summary>
    <content type="html">&lt;h2&gt;The notebook eval is a dead end&lt;/h2&gt;
&lt;p&gt;Pick any team running an agent in production for more than a quarter and look at how they evaluate it. You will find a Jupyter notebook, an &lt;code&gt;evals.py&lt;/code&gt;, or a folder of &lt;code&gt;.txt&lt;/code&gt; prompts. Someone wrote them the week the agent shipped. Nobody has re-run them in two months. When a new model drops — Claude Opus 4.7 last month, Sonnet 4.6 the week before — the same notebook gets opened, a few cells get edited, results are eyeballed, &amp;quot;looks fine,&amp;quot; done.&lt;/p&gt;
&lt;p&gt;That is not an eval. That is a memory of an eval.&lt;/p&gt;
&lt;p&gt;The problems compound:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No baseline.&lt;/strong&gt; The output of last month&amp;#39;s run is gone. You cannot diff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No coverage signal.&lt;/strong&gt; Which production scenarios are covered? No one knows. The prompts that matter — the ones that broke last quarter — are not in the file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No tagging.&lt;/strong&gt; &amp;quot;Show me the long-context cases&amp;quot; requires &lt;code&gt;grep&lt;/code&gt; over a folder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No fresh data.&lt;/strong&gt; Every interesting failure the agent had this sprint is in a Slack thread, not in the eval set.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fix is the one a database engineer would write on a napkin: every interesting interaction is a row, tagging is a column, versioning is a &lt;code&gt;dataset_version&lt;/code&gt; table, replay is a &lt;code&gt;SELECT … WHERE tag = &amp;#39;long-context&amp;#39;&lt;/code&gt;, and the model upgrade is a &lt;code&gt;JOIN&lt;/code&gt; between two run tables. Builds on the &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;memory schema from D1&lt;/a&gt;, the &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;observability tables from D9&lt;/a&gt;, and the &lt;a href=&quot;/blog/sub-agent-dispatch-queue&quot;&gt;dispatch queue from D12&lt;/a&gt; (workers replay the set in parallel).&lt;/p&gt;
&lt;h2&gt;The schema: cases, datasets, runs&lt;/h2&gt;
&lt;p&gt;Three tables. Cases are the facts. Datasets are versioned snapshots. Runs are the result of replaying a dataset against one model + prompt revision.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- A single captured interaction. Append-only.
CREATE TABLE eval_case (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  source          text NOT NULL,                  -- &amp;#39;production&amp;#39; | &amp;#39;incident&amp;#39; | &amp;#39;handcrafted&amp;#39;
  prompt          text NOT NULL,                  -- full user turn, including system context
  context         jsonb NOT NULL DEFAULT &amp;#39;{}&amp;#39;,    -- files, tool history, env
  reference       text,                           -- gold answer if known; NULL = judged at run time
  tags            text[] NOT NULL DEFAULT &amp;#39;{}&amp;#39;,   -- [&amp;#39;long-context&amp;#39;,&amp;#39;tool-use&amp;#39;,&amp;#39;regression-#1284&amp;#39;]
  captured_at     timestamptz NOT NULL DEFAULT now(),
  captured_by     text NOT NULL,                  -- agent id or user
  embedding       vector(1024)                    -- voyage-3, for &amp;quot;find similar cases&amp;quot;
);

CREATE INDEX eval_case_tags_idx  ON eval_case USING gin (tags);
CREATE INDEX eval_case_embed_idx ON eval_case USING hnsw (embedding vector_cosine_ops);

-- A frozen, named snapshot of cases. Immutable once published.
CREATE TABLE eval_dataset (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name        text NOT NULL,                      -- &amp;#39;core-v3&amp;#39;, &amp;#39;long-context-2026-q2&amp;#39;
  version     int  NOT NULL,
  case_ids    uuid[] NOT NULL,                    -- locked at publish
  notes       text,
  published_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (name, version)
);

-- One row per (dataset_version, case, model+prompt revision) replay.
CREATE TABLE eval_run (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  dataset_id    uuid NOT NULL REFERENCES eval_dataset(id),
  case_id       uuid NOT NULL REFERENCES eval_case(id),
  model         text NOT NULL,                    -- &amp;#39;claude-opus-4-7&amp;#39;
  prompt_rev    text NOT NULL,                    -- git sha of the system prompt
  output        text NOT NULL,
  pass          boolean,                          -- NULL if judged later
  judge_score   numeric(3,2),                     -- 0.00–1.00
  judge_reason  text,
  latency_ms    int  NOT NULL,
  input_tokens  int  NOT NULL,
  output_tokens int  NOT NULL,
  cost_usd      numeric(10,6) NOT NULL,
  ran_at        timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX eval_run_dataset_idx ON eval_run (dataset_id, model, prompt_rev);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three design choices to call out:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cases are append-only.&lt;/strong&gt; A captured case never mutates. If the gold answer was wrong, you write a &lt;em&gt;new&lt;/em&gt; case and tag the old one &lt;code&gt;superseded&lt;/code&gt;. This is how you get clean diffs across versions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Datasets freeze &lt;code&gt;case_ids&lt;/code&gt;.&lt;/strong&gt; Publishing a dataset is a snapshot. Six months from now you can rerun &lt;code&gt;core-v3&lt;/code&gt; and the membership has not drifted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;prompt_rev&lt;/code&gt; is the git SHA of the system prompt.&lt;/strong&gt; Without it, a regression looks like a model regression when it is actually a prompt edit. Always log both.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Capture: the hook that fills the table&lt;/h2&gt;
&lt;p&gt;Capturing has to be cheap or nobody does it. A &lt;code&gt;PostToolUse&lt;/code&gt; hook with a slash-command escape hatch covers both flows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auto-capture&lt;/strong&gt; any turn that ends with an explicit user thumbs-down (&lt;code&gt;/eval-bad&lt;/code&gt;) or a hook-detected failure (timeout, retry-exhausted, judge score &amp;lt; threshold).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Manual capture&lt;/strong&gt; via &lt;code&gt;/eval-capture &amp;lt;tag1,tag2&amp;gt;&lt;/code&gt; when the human notices something worth keeping.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;/eval-capture&lt;/code&gt; is one shell script. It reads the current conversation tail, embeds the prompt with voyage-3, and INSERTs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# ~/.claude/commands/eval-capture.sh
set -euo pipefail

tags=&amp;quot;${1:-handcrafted}&amp;quot;
prompt=&amp;quot;$(claude-cli conversation tail --turns 1 --format text)&amp;quot;
context=&amp;quot;$(claude-cli context dump --format json)&amp;quot;

embedding=&amp;quot;$(curl -s https://api.voyageai.com/v1/embeddings \
  -H &amp;quot;Authorization: Bearer $VOYAGE_API_KEY&amp;quot; \
  -H &amp;#39;Content-Type: application/json&amp;#39; \
  -d &amp;quot;$(jq -nc --arg i &amp;quot;$prompt&amp;quot; \
        &amp;#39;{model:&amp;quot;voyage-3&amp;quot;, input:[$i]}&amp;#39;)&amp;quot; \
  | jq -c &amp;#39;.data[0].embedding&amp;#39;)&amp;quot;

psql &amp;quot;$REDDB_URL&amp;quot; -v ON_ERROR_STOP=1 &amp;lt;&amp;lt;SQL
INSERT INTO eval_case (source, prompt, context, tags, captured_by, embedding)
VALUES (
  &amp;#39;handcrafted&amp;#39;,
  \$\$${prompt//\$\$/\$\$\$\$}\$\$,
  &amp;#39;${context}&amp;#39;::jsonb,
  string_to_array(&amp;#39;${tags}&amp;#39;, &amp;#39;,&amp;#39;)::text[],
  &amp;#39;${USER}&amp;#39;,
  &amp;#39;${embedding}&amp;#39;::vector
);
SQL

echo &amp;quot;captured to eval_case (tags: ${tags})&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;PostToolUse&lt;/code&gt; is the same idea wrapped around a failure detector — drop in the &lt;a href=&quot;/blog/hooks-transactional-audit&quot;&gt;transactional-hook pattern from D3&lt;/a&gt; so a half-written embedding never lands without its row.&lt;/p&gt;
&lt;h2&gt;Publish: freezing a dataset&lt;/h2&gt;
&lt;p&gt;A dataset is a one-line query plus an insert:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Pin everything captured this quarter that is not superseded.
WITH picked AS (
  SELECT id FROM eval_case
  WHERE captured_at &amp;gt;= &amp;#39;2026-04-01&amp;#39;
    AND NOT (&amp;#39;superseded&amp;#39; = ANY(tags))
)
INSERT INTO eval_dataset (name, version, case_ids, notes)
SELECT &amp;#39;core&amp;#39;, 4,
       array_agg(id ORDER BY captured_at),
       &amp;#39;Q2 2026 baseline, includes the 1284 long-context regression&amp;#39;
FROM picked;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Membership is now frozen. Anyone — six months, three model upgrades, two prompt rewrites from now — can rerun &lt;code&gt;core&lt;/code&gt; v4 and get the same case set.&lt;/p&gt;
&lt;h2&gt;Replay: the eval is a query&lt;/h2&gt;
&lt;p&gt;Replay is a function over &lt;code&gt;(dataset_id, model, prompt_rev)&lt;/code&gt;. The naive single-process version is fine for ~200 cases; for anything bigger, push each case into the &lt;a href=&quot;/blog/sub-agent-dispatch-queue&quot;&gt;dispatch queue from D12&lt;/a&gt; and let N workers chew through it in parallel.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// scripts/replay.ts
import { Client } from &amp;#39;pg&amp;#39;
import Anthropic from &amp;#39;@anthropic-ai/sdk&amp;#39;

const db  = new Client({ connectionString: process.env.REDDB_URL })
const ai  = new Anthropic()
const MODEL       = process.env.MODEL       ?? &amp;#39;claude-opus-4-7&amp;#39;
const PROMPT_REV  = process.env.PROMPT_REV  ?? execSyncSha(&amp;#39;prompts/system.md&amp;#39;)
const DATASET     = process.env.DATASET     ?? &amp;#39;core&amp;#39;
const VERSION     = Number(process.env.VERSION ?? 4)

await db.connect()

const { rows: [ds] } = await db.query(
  `SELECT id, case_ids FROM eval_dataset WHERE name=$1 AND version=$2`,
  [DATASET, VERSION],
)

const { rows: cases } = await db.query(
  `SELECT id, prompt, reference FROM eval_case WHERE id = ANY($1::uuid[])`,
  [ds.case_ids],
)

for (const c of cases) {
  const t0 = Date.now()
  const res = await ai.messages.create({
    model: MODEL,
    max_tokens: 2048,
    system: await fs.readFile(&amp;#39;prompts/system.md&amp;#39;, &amp;#39;utf8&amp;#39;),
    messages: [{ role: &amp;#39;user&amp;#39;, content: c.prompt }],
  })
  const out = res.content.map(b =&amp;gt; b.type === &amp;#39;text&amp;#39; ? b.text : &amp;#39;&amp;#39;).join(&amp;#39;&amp;#39;)

  const { pass, score, reason } = c.reference
    ? exactMatch(out, c.reference)
    : await llmJudge(out, c.prompt)        // separate small-model call

  await db.query(
    `INSERT INTO eval_run
       (dataset_id, case_id, model, prompt_rev, output,
        pass, judge_score, judge_reason, latency_ms,
        input_tokens, output_tokens, cost_usd)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
    [ds.id, c.id, MODEL, PROMPT_REV, out,
     pass, score, reason, Date.now() - t0,
     res.usage.input_tokens, res.usage.output_tokens, priceUsd(res.usage, MODEL)],
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no eval framework here. There is a &lt;code&gt;for&lt;/code&gt; loop and an &lt;code&gt;INSERT&lt;/code&gt;. That is the whole point — the &lt;em&gt;eval&lt;/em&gt; is the SQL you write afterwards.&lt;/p&gt;
&lt;h2&gt;The eval is the SQL afterwards&lt;/h2&gt;
&lt;p&gt;This is what you actually wanted all along.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Pass rate per model on the latest core dataset
SELECT model, prompt_rev,
       count(*)            AS n,
       avg(pass::int)::numeric(4,3) AS pass_rate,
       avg(judge_score)::numeric(4,3) AS mean_score,
       sum(cost_usd)::numeric(8,2)   AS run_cost
FROM eval_run r
JOIN eval_dataset d ON d.id = r.dataset_id
WHERE d.name = &amp;#39;core&amp;#39; AND d.version = 4
GROUP BY model, prompt_rev
ORDER BY pass_rate DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Regressions: cases the new model fails that the old one passed
WITH old AS (
  SELECT case_id, pass FROM eval_run
  WHERE model = &amp;#39;claude-opus-4-6&amp;#39; AND prompt_rev = &amp;#39;a1b2c3&amp;#39;
), new AS (
  SELECT case_id, pass, output FROM eval_run
  WHERE model = &amp;#39;claude-opus-4-7&amp;#39; AND prompt_rev = &amp;#39;a1b2c3&amp;#39;
)
SELECT c.id, c.prompt, c.tags, new.output
FROM eval_case c
JOIN old ON old.case_id = c.id
JOIN new ON new.case_id = c.id
WHERE old.pass AND NOT new.pass
ORDER BY &amp;#39;regression&amp;#39; = ANY(c.tags) DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Coverage by tag — which areas of the dataset are thin?
SELECT unnest(tags) AS tag, count(*) AS cases
FROM eval_case
WHERE id = ANY((SELECT case_ids FROM eval_dataset WHERE name=&amp;#39;core&amp;#39; AND version=4))
GROUP BY 1
ORDER BY 2 DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- &amp;quot;Find me cases like the one that just broke production&amp;quot;
SELECT id, prompt, tags
FROM eval_case
ORDER BY embedding &amp;lt;=&amp;gt; (SELECT embedding FROM eval_case WHERE id = $1)
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last one is the killer feature CLI evals do not have. When a production failure lands, you embed the failing prompt and the database hands you the ten most-similar historical cases — your regression set writes itself.&lt;/p&gt;
&lt;h2&gt;When this is overkill&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Solo prototype, two prompts.&lt;/strong&gt; Stay in a notebook. You will outgrow it; you have not yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No judge signal.&lt;/strong&gt; If neither exact-match nor an LLM judge can score the output, the table is just a log. Fix the judging problem before the storage problem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Daily replay against a frontier model.&lt;/strong&gt; Cost discipline matters more than capture discipline. Sample first, run nightly on a small slice, full runs only on model bumps and prompt-revision PRs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The shape we end up with — &lt;em&gt;cases are facts, datasets are snapshots, runs are joins&lt;/em&gt; — is the same shape every mature evaluation system has, from ML research benchmarks to compiler test suites. The novelty is just that the agent feeds itself: every interesting turn it ever has becomes a row that the next model release has to answer for.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/sub-agent-dispatch-queue</id>
    <title>Sub-agent dispatch via database queue — durable parallelism across sessions</title>
    <link href="https://reddb.io/blog/sub-agent-dispatch-queue"/>
    <updated>2026-06-03</updated>
    <published>2026-06-03</published>
    <author><name>RedDB team</name></author>
    <summary>Claude Code&apos;s Task tool spawns sub-agents that die with the session. Push the work into a RedDB queue instead and workers on any machine pick it up, write results back, and the parent subscribes to completions via LISTEN/NOTIFY. Fire-and-forget becomes durable fan-out.</summary>
    <content type="html">&lt;h2&gt;The Task tool&amp;#39;s shelf life is one session&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Task(subagent_type=…)&lt;/code&gt; is the most underused tool in Claude Code. Spawn five sub-agents, each chews on an independent slice, parent collects results — except every one of those sub-agents lives inside the parent&amp;#39;s process. Close the laptop, kill the terminal, hit a context-window cap on the parent, and the work evaporates. Mid-flight tool calls are simply gone; their partial output is unrecoverable.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s fine for &amp;quot;explore the codebase, summarize&amp;quot; — disposable. It&amp;#39;s not fine for &amp;quot;run the eval suite against 200 prompts,&amp;quot; &amp;quot;lint and auto-fix 80 packages,&amp;quot; &amp;quot;regenerate embeddings for every doc touched this week.&amp;quot; Those are the jobs you actually want to send AFK. They need to survive a restart.&lt;/p&gt;
&lt;p&gt;The pattern in this post: push sub-agent tasks into a RedDB queue, let one or more workers pull from it, write results back to the same row, and have the parent session subscribe to completions via &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;. Same &lt;code&gt;Task&lt;/code&gt; ergonomics on the surface, durable fan-out underneath. Builds on the &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;memory backend from D1&lt;/a&gt;, the &lt;a href=&quot;/blog/hooks-transactional-audit&quot;&gt;transactional-hook pattern from D3&lt;/a&gt;, and the &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;observability schema from D9&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The schema: one table, three states, a notify trigger&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TYPE task_state AS ENUM (&amp;#39;pending&amp;#39;, &amp;#39;running&amp;#39;, &amp;#39;done&amp;#39;, &amp;#39;failed&amp;#39;);

CREATE TABLE agent_task (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  parent_id    text NOT NULL,                       -- session id of dispatcher
  kind         text NOT NULL,                       -- &amp;#39;review&amp;#39;, &amp;#39;embed&amp;#39;, &amp;#39;eval&amp;#39;…
  payload      jsonb NOT NULL,                      -- prompt, file list, cfg
  state        task_state NOT NULL DEFAULT &amp;#39;pending&amp;#39;,
  worker_id    text,
  attempts     int NOT NULL DEFAULT 0,
  max_attempts int NOT NULL DEFAULT 3,
  result       jsonb,
  error        text,
  idem_key     text UNIQUE,                         -- dedupe on retry
  created_at   timestamptz NOT NULL DEFAULT now(),
  started_at   timestamptz,
  finished_at  timestamptz,
  visible_at   timestamptz NOT NULL DEFAULT now()   -- delayed retry, lease
);

CREATE INDEX agent_task_claim_idx
  ON agent_task (visible_at)
  WHERE state = &amp;#39;pending&amp;#39;;

CREATE INDEX agent_task_parent_idx ON agent_task (parent_id, state);

CREATE OR REPLACE FUNCTION notify_task_done() RETURNS trigger AS $$
BEGIN
  IF NEW.state IN (&amp;#39;done&amp;#39;,&amp;#39;failed&amp;#39;) AND OLD.state &amp;lt;&amp;gt; NEW.state THEN
    PERFORM pg_notify(&amp;#39;task_done_&amp;#39; || NEW.parent_id, NEW.id::text);
  END IF;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER agent_task_notify
  AFTER UPDATE ON agent_task
  FOR EACH ROW EXECUTE FUNCTION notify_task_done();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four moving parts worth a comment. &lt;code&gt;idem_key&lt;/code&gt; lets a flaky dispatcher retry without double-queuing — same key, second insert returns the existing row. &lt;code&gt;visible_at&lt;/code&gt; doubles as both &lt;em&gt;delayed retry&lt;/em&gt; (push it ten seconds into the future after a failure) and &lt;em&gt;worker lease&lt;/em&gt; (a claimer bumps it 5 minutes out so a peer doesn&amp;#39;t steal in-flight work). The partial index on &lt;code&gt;(visible_at) WHERE state = &amp;#39;pending&amp;#39;&lt;/code&gt; keeps the claim query a single index range scan even at a million rows. The &lt;code&gt;pg_notify&lt;/code&gt; channel is per-parent so a dispatcher hears only its own completions — no fan-out storm to unrelated sessions.&lt;/p&gt;
&lt;h2&gt;Dispatcher: a tiny wrapper around Task&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// .claude/lib/dispatch.ts
import { Pool } from &amp;#39;pg&amp;#39;
const pool = new Pool({ connectionString: process.env.REDDB_URL })

export async function dispatch(kind: string, payload: unknown, opts: {
  parentId: string
  idemKey?: string
  maxAttempts?: number
}) {
  const { rows } = await pool.query(
    `INSERT INTO agent_task (parent_id, kind, payload, idem_key, max_attempts)
     VALUES ($1, $2, $3, $4, COALESCE($5, 3))
     ON CONFLICT (idem_key) DO UPDATE SET idem_key = EXCLUDED.idem_key
     RETURNING id`,
    [opts.parentId, kind, payload, opts.idemKey ?? null, opts.maxAttempts ?? null],
  )
  return rows[0].id as string
}

export async function awaitAll(parentId: string, ids: string[], timeoutMs = 600_000) {
  const client = await pool.connect()
  try {
    await client.query(`LISTEN &amp;quot;task_done_${parentId}&amp;quot;`)
    const pending = new Set(ids)
    const results = new Map&amp;lt;string, any&amp;gt;()

    const drain = async () =&amp;gt; {
      const { rows } = await client.query(
        `SELECT id, state, result, error FROM agent_task
          WHERE parent_id = $1 AND id = ANY($2::uuid[]) AND state IN (&amp;#39;done&amp;#39;,&amp;#39;failed&amp;#39;)`,
        [parentId, [...pending]],
      )
      for (const r of rows) { results.set(r.id, r); pending.delete(r.id) }
    }
    await drain()

    if (pending.size === 0) return results
    return await new Promise&amp;lt;typeof results&amp;gt;((resolve, reject) =&amp;gt; {
      const timer = setTimeout(() =&amp;gt; reject(new Error(&amp;#39;dispatch timeout&amp;#39;)), timeoutMs)
      client.on(&amp;#39;notification&amp;#39;, async (msg) =&amp;gt; {
        if (!pending.has(msg.payload!)) return
        await drain()
        if (pending.size === 0) { clearTimeout(timer); resolve(results) }
      })
    })
  } finally {
    client.release()
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ON CONFLICT … DO UPDATE … RETURNING id&lt;/code&gt; dance is the idempotent-insert trick — same &lt;code&gt;idem_key&lt;/code&gt; returns the original row&amp;#39;s id, so a dispatcher that crashes between two &lt;code&gt;INSERT&lt;/code&gt;s and resumes won&amp;#39;t enqueue twice. &lt;code&gt;awaitAll&lt;/code&gt; does a &lt;em&gt;drain-then-listen&lt;/em&gt; so completions that landed before the &lt;code&gt;LISTEN&lt;/code&gt; registered still get picked up; without the initial drain you&amp;#39;d have a race where fast workers finish before the parent subscribes.&lt;/p&gt;
&lt;h2&gt;Worker: claim, run, write back&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// workers/agent-worker.ts — run one per machine, or one per CPU core
import { Pool } from &amp;#39;pg&amp;#39;
import { runSubagent } from &amp;#39;./runner&amp;#39;   // your wrapper around the Claude API or CLI
const pool = new Pool({ connectionString: process.env.REDDB_URL })
const workerId = `${process.env.HOSTNAME}-${process.pid}`

async function claim() {
  const { rows } = await pool.query(`
    UPDATE agent_task
       SET state = &amp;#39;running&amp;#39;,
           worker_id = $1,
           started_at = now(),
           visible_at = now() + interval &amp;#39;5 minutes&amp;#39;,
           attempts = attempts + 1
     WHERE id = (
       SELECT id FROM agent_task
        WHERE state = &amp;#39;pending&amp;#39; AND visible_at &amp;lt;= now()
        ORDER BY created_at
        FOR UPDATE SKIP LOCKED
        LIMIT 1
     )
     RETURNING id, kind, payload, attempts, max_attempts
  `, [workerId])
  return rows[0]
}

async function finish(id: string, ok: boolean, body: unknown) {
  await pool.query(
    `UPDATE agent_task
        SET state = $2, ${ok ? &amp;#39;result&amp;#39; : &amp;#39;error&amp;#39;} = $3, finished_at = now()
      WHERE id = $1`,
    [id, ok ? &amp;#39;done&amp;#39; : &amp;#39;failed&amp;#39;, ok ? body : String(body)],
  )
}

async function retry(id: string, attempts: number, max: number, err: unknown) {
  if (attempts &amp;gt;= max) return finish(id, false, err)
  const backoff = Math.min(60, 2 ** attempts) // seconds
  await pool.query(
    `UPDATE agent_task
        SET state = &amp;#39;pending&amp;#39;, visible_at = now() + ($2 || &amp;#39; seconds&amp;#39;)::interval, error = $3
      WHERE id = $1`,
    [id, backoff, String(err)],
  )
}

while (true) {
  const task = await claim()
  if (!task) { await new Promise(r =&amp;gt; setTimeout(r, 1000)); continue }
  try {
    const out = await runSubagent(task.kind, task.payload)
    await finish(task.id, true, out)
  } catch (err) {
    await retry(task.id, task.attempts, task.max_attempts, err)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is the load-bearing line. Drop it and N workers serialize on the same row lock; keep it and they fan out cleanly across the queue. The visibility-timeout update inside the same statement is the &lt;em&gt;lease&lt;/em&gt; — if this worker crashes mid-task, the row reverts to claimable after five minutes because a watchdog (next section) flips &lt;code&gt;state&lt;/code&gt; back to &lt;code&gt;pending&lt;/code&gt;. The exponential backoff on retry tops out at one minute so failed tasks don&amp;#39;t park in the queue for hours.&lt;/p&gt;
&lt;h2&gt;Watchdog: reclaim leases, alert on poison pills&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- run every minute from cron or pg_cron
UPDATE agent_task
   SET state = &amp;#39;pending&amp;#39;, visible_at = now() + interval &amp;#39;5 seconds&amp;#39;
 WHERE state = &amp;#39;running&amp;#39;
   AND started_at &amp;lt; now() - interval &amp;#39;5 minutes&amp;#39;;

-- alert: tasks that hit max_attempts
SELECT id, kind, error, attempts
  FROM agent_task
 WHERE state = &amp;#39;failed&amp;#39;
   AND finished_at &amp;gt; now() - interval &amp;#39;1 hour&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First query is the lease reclaimer. Second is the canary — wire it to the observability dashboard from &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;D9&lt;/a&gt; and a Slack webhook so poison pills surface within an hour instead of clogging the queue silently.&lt;/p&gt;
&lt;h2&gt;Calling it from a Claude Code session&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// .claude/skills/parallel-review/run.ts
import { dispatch, awaitAll } from &amp;#39;../../lib/dispatch&amp;#39;

const parentId = process.env.CLAUDE_SESSION_ID!
const files: string[] = JSON.parse(process.env.CHANGED_FILES!)

const ids = await Promise.all(files.map(f =&amp;gt;
  dispatch(&amp;#39;review&amp;#39;, { file: f, ruleset: &amp;#39;house-style&amp;#39; }, {
    parentId,
    idemKey: `${parentId}:${f}`,
  })
))

const results = await awaitAll(parentId, ids, 15 * 60_000)
for (const [, r] of results) {
  console.log(r.state === &amp;#39;done&amp;#39; ? r.result : `FAIL: ${r.error}`)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From the model&amp;#39;s perspective this looks like a synchronous fan-out, but the rows live in the DB. Kill the session, restart it tomorrow, re-run with the same &lt;code&gt;parentId&lt;/code&gt; and &lt;code&gt;idem_key&lt;/code&gt;s, and &lt;code&gt;awaitAll&lt;/code&gt; returns immediately for tasks already finished and waits only on the stragglers. That&amp;#39;s the durable part.&lt;/p&gt;
&lt;h2&gt;When this pattern earns its keep&lt;/h2&gt;
&lt;p&gt;Three signals that you&amp;#39;ve outgrown raw &lt;code&gt;Task&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Single fan-out &amp;gt; 30 sub-agents.&lt;/strong&gt; In-process parallelism starts to choke on rate limits and the parent&amp;#39;s context fills with sub-agent output it has to scroll past.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Work outlasts a session.&lt;/strong&gt; Anything where &amp;quot;let it run overnight&amp;quot; is a normal answer — evals, batch refactors, doc regeneration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;More than one machine has spare cycles.&lt;/strong&gt; A laptop and a workstation can both pull from the same queue; the queue is the only piece of shared state.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If none of those hold, &lt;code&gt;Task&lt;/code&gt; is still the right primitive — don&amp;#39;t drag in a database to dispatch three sibling agents that finish in 90 seconds.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s next&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/blog/eval-datasets-in-reddb&quot;&gt;D13 (eval datasets)&lt;/a&gt; builds on this queue: every dispatched eval becomes a row, every replay against a new model version is another fan-out against the same set of &lt;code&gt;payload&lt;/code&gt;s. &lt;a href=&quot;/blog/cross-session-task-queues&quot;&gt;D14 (cross-session task queues)&lt;/a&gt; generalises the worker loop into the AFK-agent runtime: same table, same claim query, plus checkpoints and resume tokens so a sub-agent can be paused mid-thought and revived on a different host.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/context-window-economics</id>
    <title>Context window economics — when memory beats reloading docs</title>
    <link href="https://reddb.io/blog/context-window-economics"/>
    <updated>2026-06-01</updated>
    <published>2026-06-01</published>
    <author><name>RedDB team</name></author>
    <summary>Every CLI agent burns tokens re-reading the same files turn after turn. Put a number on the waste, work out the breakeven against a semantic-memory layer in RedDB, and show the SQL plus hook that flips the math for projects past a few thousand lines.</summary>
    <content type="html">&lt;h2&gt;The hidden bill on every CLI agent session&lt;/h2&gt;
&lt;p&gt;Open Claude Code in a medium repo. Ask a question. Watch it &lt;code&gt;Read&lt;/code&gt; the same five files it read yesterday. Ask a follow-up. Watch it &lt;code&gt;Grep&lt;/code&gt; the same constants. The model is smart, the tool calls are cheap individually, and the per-turn waste hides inside a green checkmark — so nobody adds it up. This post does the addition, then shows the fix.&lt;/p&gt;
&lt;p&gt;The fix is a semantic-memory layer that lives in the same RedDB table the rest of &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;Pillar D&lt;/a&gt; uses: read once, embed, query top-k chunks per turn instead of re-reading the whole tree. The interesting part isn&amp;#39;t the code — it&amp;#39;s where the breakeven sits. For most real projects it&amp;#39;s around turn three.&lt;/p&gt;
&lt;h2&gt;The naive baseline: re-read everything&lt;/h2&gt;
&lt;p&gt;Call this the &amp;quot;stateless agent&amp;quot; baseline. Per turn the agent reloads:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; and any &lt;code&gt;@import&lt;/code&gt;-chained memory files — call it &lt;code&gt;M&lt;/code&gt; tokens&lt;/li&gt;
&lt;li&gt;a handful of source files for context — call it &lt;code&gt;S&lt;/code&gt; tokens&lt;/li&gt;
&lt;li&gt;recent diffs, &lt;code&gt;git log&lt;/code&gt;, command output — &lt;code&gt;D&lt;/code&gt; tokens&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Per turn, input tokens: &lt;code&gt;C = M + S + D&lt;/code&gt;. Across &lt;code&gt;N&lt;/code&gt; turns: &lt;code&gt;N · C&lt;/code&gt;. Cost: &lt;code&gt;N · C · p_in&lt;/code&gt;, where &lt;code&gt;p_in&lt;/code&gt; is the input-token price.&lt;/p&gt;
&lt;p&gt;For Claude Opus 4.7 (Jan 2026 list) that&amp;#39;s roughly &lt;code&gt;$15 / 1M&lt;/code&gt; input, &lt;code&gt;$75 / 1M&lt;/code&gt; output. Prompt cache hits land near &lt;code&gt;$1.50 / 1M&lt;/code&gt;. Cache is the reason this isn&amp;#39;t worse — but cache misses on every new turn that touches a slightly different file set, and the cache TTL is five minutes. After lunch you pay full price again.&lt;/p&gt;
&lt;p&gt;A worked number for a 50k-line monorepo, ten-turn session, ~30k input tokens per turn (10k from rules + 15k from re-read files + 5k from logs):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input tokens   = 10 turns × 30_000 = 300_000
cost (cold)    = 300_000 / 1_000_000 × $15  = $4.50
cost (warm)    = 300_000 / 1_000_000 × $1.50 = $0.45
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Forty-five cents per warm session sounds fine. Multiply by 12 engineers × 8 sessions/day × 21 working days and you&amp;#39;re at $907/month, and that&amp;#39;s the &lt;em&gt;cached&lt;/em&gt; number. The cold-cache reality across machine restarts and overnight idle is closer to $2k/month per twelve-engineer team — for the privilege of re-reading files that haven&amp;#39;t changed.&lt;/p&gt;
&lt;h2&gt;The memory-backed alternative&lt;/h2&gt;
&lt;p&gt;Swap the per-turn re-read for a per-turn semantic query.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Index once.&lt;/strong&gt; A background indexer chunks every file in the repo (200–400 tokens per chunk), embeds it with &lt;code&gt;voyage-3&lt;/code&gt; (~$0.06 / 1M tokens, one-time amortised), writes the rows into the same &lt;code&gt;memory&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query per turn.&lt;/strong&gt; On &lt;code&gt;SessionStart&lt;/code&gt; (or whichever hook owns context priming), embed the conversation tail, run a &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; cosine search, pull the top-k chunks. &lt;code&gt;k = 6&lt;/code&gt; and chunk size 300 tokens means ~1.8k tokens of injected context vs the 15k of &amp;quot;re-read the whole file&amp;quot; — an order of magnitude shave on the source-file portion of &lt;code&gt;C&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-index on change.&lt;/strong&gt; A &lt;code&gt;PostToolUse&lt;/code&gt; hook on &lt;code&gt;Write|Edit&lt;/code&gt; invalidates and re-embeds the touched files. Tiny per-edit cost; rounds to zero against any session.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The per-turn input now looks like &lt;code&gt;C&amp;#39; = M + Q + R&lt;/code&gt;, where &lt;code&gt;Q&lt;/code&gt; is the embedding query (a few hundred tokens of conversation tail) and &lt;code&gt;R&lt;/code&gt; is the retrieved chunks. In our worked example, &lt;code&gt;C&amp;#39; ≈ 10_000 + 800 + 1_800 = 12_600&lt;/code&gt;, down from 30k. The model still does the work — it just doesn&amp;#39;t re-read what it already knew.&lt;/p&gt;
&lt;h2&gt;The breakeven, plotted as algebra&lt;/h2&gt;
&lt;p&gt;Per session, the memory-backed path pays:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cost_memory = N · C&amp;#39; · p_in                # per-turn query + retrieval
            + E_query · N · p_embed        # embedding the query each turn
            + E_index · F · p_embed / R_amort   # amortised indexing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;E_index&lt;/code&gt; is total tokens indexed, &lt;code&gt;F&lt;/code&gt; is the change-frequency multiplier (how often files re-embed), &lt;code&gt;R_amort&lt;/code&gt; is how many sessions the index serves before a full rebuild. For most repos &lt;code&gt;R_amort ≫ 100&lt;/code&gt;, and &lt;code&gt;p_embed&lt;/code&gt; is ~250× cheaper than &lt;code&gt;p_in&lt;/code&gt;, so the indexing column vanishes.&lt;/p&gt;
&lt;p&gt;The naive path pays:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cost_naive = N · C · p_in
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Breakeven is the turn count where the constant-cost ramp-up of the memory path is paid off by the smaller per-turn slope:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;N_breakeven  =  E_index · p_embed / R_amort
                ────────────────────────────────
                (C - C&amp;#39;) · p_in  -  E_query · p_embed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Plug the worked numbers (&lt;code&gt;C - C&amp;#39; = 17_400&lt;/code&gt; input tokens saved per turn, &lt;code&gt;E_query = 800&lt;/code&gt;, &lt;code&gt;p_in = $15/Mtok&lt;/code&gt;, &lt;code&gt;p_embed = $0.06/Mtok&lt;/code&gt;, &lt;code&gt;E_index = 5_000_000&lt;/code&gt;, &lt;code&gt;R_amort = 200&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;numerator    = 5_000_000 × 0.06 / 200 / 1_000_000  ≈ $0.0015
denominator  = (17_400 × 15 - 800 × 0.06) / 1_000_000 ≈ $0.000261
N_breakeven  ≈ 5.7 turns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Below six turns per session, full re-read wins. Above six, memory wins, and the gap widens linearly with &lt;code&gt;N&lt;/code&gt;. Any AFK / Ralph-style loop blows past breakeven in the first hour.&lt;/p&gt;
&lt;h2&gt;The actual code&lt;/h2&gt;
&lt;p&gt;The schema is the one from the &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;memory-backend post&lt;/a&gt;, no changes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- already exists from D1
CREATE TABLE memory (
  id         text PRIMARY KEY,
  scope      text NOT NULL,           -- &amp;#39;repo:&amp;lt;slug&amp;gt;:file:&amp;lt;path&amp;gt;&amp;#39;
  body       text NOT NULL,
  embedding  vector(1024),
  meta       jsonb NOT NULL DEFAULT &amp;#39;{}&amp;#39;::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX memory_embedding_hnsw
  ON memory USING hnsw (embedding vector_cosine_ops);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A one-shot indexer (Node, ~40 lines, runs from the repo root):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// scripts/index-repo.ts
import { readFile } from &amp;#39;node:fs/promises&amp;#39;
import { glob } from &amp;#39;glob&amp;#39;
import { ulid } from &amp;#39;ulid&amp;#39;
import postgres from &amp;#39;postgres&amp;#39;
import { VoyageAIClient } from &amp;#39;voyageai&amp;#39;

const sql = postgres(process.env.DATABASE_URL!)
const voyage = new VoyageAIClient({ apiKey: process.env.VOYAGE_API_KEY! })
const REPO = process.env.REPO_SLUG ?? &amp;#39;rdb-lair&amp;#39;
const CHUNK = 300                          // ~tokens, sliced by characters at 4x

const files = await glob(&amp;#39;**/*.{ts,tsx,md,sql}&amp;#39;, { ignore: [&amp;#39;**/node_modules/**&amp;#39;, &amp;#39;**/dist/**&amp;#39;] })

for (const path of files) {
  const text = await readFile(path, &amp;#39;utf8&amp;#39;)
  const chunks: string[] = []
  for (let i = 0; i &amp;lt; text.length; i += CHUNK * 4) chunks.push(text.slice(i, i + CHUNK * 4))
  if (chunks.length === 0) continue

  const embeddings = await voyage.embed({ input: chunks, model: &amp;#39;voyage-3&amp;#39; })
  const rows = chunks.map((body, i) =&amp;gt; ({
    id: ulid(),
    scope: `repo:${REPO}:file:${path}`,
    body,
    embedding: JSON.stringify(embeddings.data[i].embedding),
    meta: { path, chunk: i },
  }))

  await sql`INSERT INTO memory ${sql(rows, &amp;#39;id&amp;#39;, &amp;#39;scope&amp;#39;, &amp;#39;body&amp;#39;, &amp;#39;embedding&amp;#39;, &amp;#39;meta&amp;#39;)}`
  console.log(`indexed ${path} (${chunks.length} chunks)`)
}

await sql.end()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;SessionStart&lt;/code&gt; hook that replaces the per-turn re-read:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# ~/.claude/hooks/session-start-context.sh — emits top-k chunks for the current task
set -euo pipefail

TAIL=&amp;quot;$(jq -r &amp;#39;.conversation_tail // &amp;quot;&amp;quot;&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$CLAUDE_HOOK_INPUT&amp;quot;)&amp;quot;
[ -z &amp;quot;$TAIL&amp;quot; ] &amp;amp;&amp;amp; exit 0

QVEC=$(curl -s https://api.voyageai.com/v1/embeddings \
  -H &amp;quot;Authorization: Bearer $VOYAGE_API_KEY&amp;quot; \
  -H &amp;#39;content-type: application/json&amp;#39; \
  -d &amp;quot;$(jq -n --arg q &amp;quot;$TAIL&amp;quot; &amp;#39;{input:[$q], model:&amp;quot;voyage-3&amp;quot;}&amp;#39;)&amp;quot; \
  | jq -c &amp;#39;.data[0].embedding&amp;#39;)

psql &amp;quot;$DATABASE_URL&amp;quot; -At -F $&amp;#39;\t&amp;#39; &amp;lt;&amp;lt;SQL | jq -Rs &amp;#39;{context: .}&amp;#39;
SELECT meta-&amp;gt;&amp;gt;&amp;#39;path&amp;#39; AS path, body
FROM memory
WHERE scope LIKE &amp;#39;repo:${REPO_SLUG}:file:%&amp;#39;
ORDER BY embedding &amp;lt;=&amp;gt; &amp;#39;${QVEC}&amp;#39;::vector
LIMIT 6;
SQL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wire it up in &lt;code&gt;.claude/settings.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;hooks&amp;quot;: {
    &amp;quot;SessionStart&amp;quot;: [
      { &amp;quot;command&amp;quot;: &amp;quot;~/.claude/hooks/session-start-context.sh&amp;quot;, &amp;quot;priority&amp;quot;: 20 }
    ],
    &amp;quot;PostToolUse&amp;quot;: [
      { &amp;quot;matcher&amp;quot;: &amp;quot;Write|Edit&amp;quot;, &amp;quot;command&amp;quot;: &amp;quot;~/.claude/hooks/reindex-touched-file.sh&amp;quot; }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;reindex-touched-file.sh&lt;/code&gt; hook re-embeds just the file that changed — same shape as the indexer above, scoped to one path. Skipped here for length; it&amp;#39;s ~20 lines.&lt;/p&gt;
&lt;h2&gt;What to measure once it&amp;#39;s live&lt;/h2&gt;
&lt;p&gt;Don&amp;#39;t trust the model. Measure. The &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;agent observability post&lt;/a&gt; drops a &lt;code&gt;PostToolUse&lt;/code&gt; hook that captures every tool call with token counts and cost. Compare two weeks before and after:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  date_trunc(&amp;#39;day&amp;#39;, started_at) AS day,
  sum(input_tokens)             AS input_tokens,
  sum(cost_usd)                 AS cost_usd
FROM tool_call
WHERE session_id IN (SELECT id FROM session WHERE repo = &amp;#39;rdb-lair&amp;#39;)
GROUP BY 1 ORDER BY 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two failure modes to watch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retrieval too narrow.&lt;/strong&gt; &lt;code&gt;k = 6&lt;/code&gt; chunks misses cross-cutting context. Symptom: the model asks &lt;code&gt;Read&lt;/code&gt; for files anyway. Bump &lt;code&gt;k&lt;/code&gt; to 12, or add a second query embedded from the user&amp;#39;s last message instead of the rolling tail.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stale index.&lt;/strong&gt; &lt;code&gt;PostToolUse&lt;/code&gt; reindex misses files changed outside Claude (git pull, another editor). Fix: nightly cron that rescans &lt;code&gt;git diff HEAD~24h&lt;/code&gt; and re-embeds.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;When this is the wrong call&lt;/h2&gt;
&lt;p&gt;Three cases where the naive re-read still wins:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tiny repos.&lt;/strong&gt; Under ~2k lines, &lt;code&gt;C - C&amp;#39;&lt;/code&gt; is small enough that &lt;code&gt;N_breakeven&lt;/code&gt; jumps past 20 turns. Skip the indexer; let the prompt cache handle it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single-shot scripts.&lt;/strong&gt; A one-question session never amortises the per-query embedding cost. Memory layer helps zero.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;High-churn refactors.&lt;/strong&gt; When 40% of files change per session, reindex cost climbs and &lt;code&gt;R_amort&lt;/code&gt; collapses. In that mode, fall back to plain &lt;code&gt;Read&lt;/code&gt; for the touched tree and only query memory for the rest.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For everything else — long-running AFK loops, multi-day feature work, anything that crosses a context-compression boundary — the breakeven is six turns. Pay it once.&lt;/p&gt;
&lt;h2&gt;Where this fits in the pillar&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Storage layer: &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;Claude Code&amp;#39;s memory backend&lt;/a&gt; (the table this post indexes into)&lt;/li&gt;
&lt;li&gt;Hook scaffolding: &lt;a href=&quot;/blog/hooks-transactional-audit&quot;&gt;Hooks that mutate persistent state&lt;/a&gt; (the &lt;code&gt;PostToolUse&lt;/code&gt; transactional pattern that keeps re-index idempotent)&lt;/li&gt;
&lt;li&gt;Cost telemetry: &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;Agent observability: traces, tokens, and costs&lt;/a&gt; (the queries that prove the savings are real)&lt;/li&gt;
&lt;li&gt;Forward link: sub-agent dispatch and AFK queues use the same memory table for cross-session continuity — drafts coming in the next Ralph iterations.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/multi-agent-shared-memory</id>
    <title>Multi-agent shared memory — five agents, one knowledge graph</title>
    <link href="https://reddb.io/blog/multi-agent-shared-memory"/>
    <updated>2026-05-30</updated>
    <published>2026-05-30</published>
    <author><name>RedDB team</name></author>
    <summary>When parallel sub-agents work on related slices of the same problem, they need a shared scratchpad with conflict resolution. A snapshot-read, first-committer-wins write pattern over RedDB gives each agent isolation while still surfacing contradictions as alerts on the orchestrator side.</summary>
    <content type="html">&lt;h2&gt;Why parallel agents need a shared scratchpad&lt;/h2&gt;
&lt;p&gt;Spawning five Claude Code sub-agents in parallel is easy. Letting them cooperate is not. Each sub-agent has its own context, its own tool budget, and no idea what its siblings just decided. Without a coordination layer you get three failure modes within the first hour:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Duplicate work.&lt;/strong&gt; Two agents independently re-derive that the auth middleware uses JWT.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contradictory facts.&lt;/strong&gt; Agent A writes &amp;quot;rate limit is 100 rpm&amp;quot;. Agent B, five minutes later, writes &amp;quot;rate limit is 60 rpm&amp;quot;. The orchestrator merges both and the parent session uses whichever sorts last.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lost decisions.&lt;/strong&gt; Agent C decides to switch the queue from SQS to Redis. Nobody else hears about it until the integration step blows up.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A shared knowledge graph in RedDB — with snapshot reads and first-committer-wins writes — fixes all three. Reuses the &lt;code&gt;memory&lt;/code&gt; table from &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;the memory-backend post&lt;/a&gt; almost verbatim, plus one extra column for the agent that wrote the row and one extra table for the coordination doc.&lt;/p&gt;
&lt;h2&gt;The shape of the fix&lt;/h2&gt;
&lt;p&gt;Three primitives:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Snapshot reads.&lt;/strong&gt; Each sub-agent reads under a fixed transaction snapshot. Whatever was committed when the agent started is what the agent sees, even if siblings keep writing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;First-committer-wins writes.&lt;/strong&gt; Writes to the same &lt;code&gt;(scope, key)&lt;/code&gt; are rejected for any agent that did not see the latest version. The losing agent gets the conflict back as a structured error and decides what to do — retry, escalate, or merge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A coordination doc.&lt;/strong&gt; A single row in &lt;code&gt;consensus&lt;/code&gt; per topic, pointing at the latest agreed value. The orchestrator subscribes to changes and surfaces contradictions.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Schema&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Reuse the memory table from the D1 post, add the writer.
ALTER TABLE memory ADD COLUMN written_by TEXT NOT NULL DEFAULT &amp;#39;human&amp;#39;;
ALTER TABLE memory ADD COLUMN scope_key  TEXT;                -- e.g. &amp;quot;auth.rate_limit&amp;quot;
ALTER TABLE memory ADD COLUMN version    BIGINT NOT NULL DEFAULT 1;

CREATE UNIQUE INDEX memory_scope_key_version
  ON memory (scope_key, version)
  WHERE scope_key IS NOT NULL;

-- One row per topic. Points at the memory row currently considered &amp;quot;true&amp;quot;.
CREATE TABLE consensus (
  scope_key   TEXT PRIMARY KEY,
  memory_id   TEXT NOT NULL REFERENCES memory(id),
  agreed_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  agreed_by   TEXT NOT NULL,                -- orchestrator id
  version     BIGINT NOT NULL
);

-- Audit trail of every conflict so the orchestrator can replay them.
CREATE TABLE conflict (
  id          TEXT PRIMARY KEY,
  scope_key   TEXT NOT NULL,
  loser_id    TEXT NOT NULL REFERENCES memory(id),
  winner_id   TEXT NOT NULL REFERENCES memory(id),
  detected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;version&lt;/code&gt; column is the optimistic-concurrency token. The unique index over &lt;code&gt;(scope_key, version)&lt;/code&gt; is what makes &amp;quot;first committer wins&amp;quot; enforceable in one round-trip: the second writer&amp;#39;s INSERT fails with a unique-violation, no advisory locks, no &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The write path&lt;/h2&gt;
&lt;p&gt;Each sub-agent runs the same compare-and-set:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH latest AS (
  SELECT version FROM memory
   WHERE scope_key = $1
   ORDER BY version DESC
   LIMIT 1
)
INSERT INTO memory (id, kind, scope, scope_key, body, embedding, written_by, version)
SELECT $2, &amp;#39;fact&amp;#39;, $3, $1, $4, $5, $6, COALESCE((SELECT version FROM latest), 0) + 1
RETURNING id, version;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If two agents fire this query concurrently with the same &lt;code&gt;scope_key&lt;/code&gt;, both compute &lt;code&gt;version = N+1&lt;/code&gt; and both try to insert. The unique index lets exactly one win. The other gets &lt;code&gt;23505 unique_violation&lt;/code&gt; back, which the agent SDK should map to a typed &lt;code&gt;ConflictError&lt;/code&gt; carrying the winning row&amp;#39;s id.&lt;/p&gt;
&lt;p&gt;Code (Node, intentionally boring):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;async function writeFact(scopeKey: string, body: string, agentId: string) {
  for (let attempt = 0; attempt &amp;lt; 3; attempt++) {
    try {
      const { rows } = await pg.query(WRITE_FACT_SQL, [
        scopeKey,
        ulid(),
        &amp;#39;agent:&amp;#39; + agentId,
        body,
        await embed(body),
        agentId,
      ])
      return { ok: true, id: rows[0].id, version: rows[0].version }
    } catch (err) {
      if (err.code !== &amp;#39;23505&amp;#39;) throw err
      // Lost the race. Read the current truth and decide.
      const current = await readConsensus(scopeKey)
      if (current.body === body) return { ok: true, id: current.id, version: current.version }
      return { ok: false, conflict: current, attempted: body }
    }
  }
  throw new Error(&amp;#39;writeFact: exhausted retries&amp;#39;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ok: false&lt;/code&gt; branch is the interesting one. The losing agent now has both the consensus value and its own attempted value in hand, and has to choose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Identical body&lt;/strong&gt; → silently accept, no-op. (Same conclusion reached twice — common with parallel research agents.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Different body&lt;/strong&gt; → record a conflict and either escalate to the orchestrator or attempt a merge.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The read path&lt;/h2&gt;
&lt;p&gt;Each sub-agent reads under a snapshot transaction so that mid-task writes by siblings do not change what the agent already reasoned about:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;async function readScope(scope: string) {
  const client = await pg.connect()
  try {
    await client.query(&amp;#39;BEGIN ISOLATION LEVEL REPEATABLE READ&amp;#39;)
    const memories = await client.query(
      `SELECT m.id, m.scope_key, m.body, m.version, m.written_by
         FROM memory m
         JOIN consensus c ON c.memory_id = m.id
        WHERE m.scope = $1`,
      [scope],
    )
    await client.query(&amp;#39;COMMIT&amp;#39;)
    return memories.rows
  } finally {
    client.release()
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;REPEATABLE READ&lt;/code&gt; means subsequent reads in the same transaction see the same snapshot. For a long-running agent task that mixes reads and tool calls, opening one snapshot at the start and closing it at the end is the cleanest model — every fact the agent acts on is internally consistent, even if the world moved on.&lt;/p&gt;
&lt;h2&gt;Recording conflicts and alerting the orchestrator&lt;/h2&gt;
&lt;p&gt;The losing-write path inserts a &lt;code&gt;conflict&lt;/code&gt; row. The orchestrator subscribes to &lt;code&gt;LISTEN conflict_inserted&lt;/code&gt; (Postgres &lt;code&gt;NOTIFY&lt;/code&gt; triggered from a trivial &lt;code&gt;AFTER INSERT&lt;/code&gt; trigger) and gets a structured event the moment two agents disagree:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE OR REPLACE FUNCTION notify_conflict() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify(&amp;#39;conflict_inserted&amp;#39;, json_build_object(
    &amp;#39;scope_key&amp;#39;, NEW.scope_key,
    &amp;#39;loser_id&amp;#39;,  NEW.loser_id,
    &amp;#39;winner_id&amp;#39;, NEW.winner_id
  )::text);
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER conflict_inserted_notify
AFTER INSERT ON conflict
FOR EACH ROW EXECUTE FUNCTION notify_conflict();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The orchestrator-side listener is ten lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const client = await pg.connect()
await client.query(&amp;#39;LISTEN conflict_inserted&amp;#39;)
client.on(&amp;#39;notification&amp;#39;, (msg) =&amp;gt; {
  const { scope_key, loser_id, winner_id } = JSON.parse(msg.payload!)
  orchestrator.alert({
    kind: &amp;#39;fact_conflict&amp;#39;,
    topic: scope_key,
    candidates: [loser_id, winner_id],
  })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Claude Code the orchestrator is usually the parent session that spawned the sub-agents via the Task tool. Surface the alert as a SessionStart-style banner in the next turn so the human (or the parent agent) decides which fact to promote.&lt;/p&gt;
&lt;h2&gt;Promoting consensus&lt;/h2&gt;
&lt;p&gt;When the parent picks a winner — manually or by rule (most-recent, highest-confidence, majority-vote) — promotion is one row update:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO consensus (scope_key, memory_id, agreed_by, version)
VALUES ($1, $2, $3, $4)
ON CONFLICT (scope_key) DO UPDATE
   SET memory_id = EXCLUDED.memory_id,
       agreed_by = EXCLUDED.agreed_by,
       version   = EXCLUDED.version,
       agreed_at = now()
 WHERE consensus.version &amp;lt; EXCLUDED.version;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;WHERE consensus.version &amp;lt; EXCLUDED.version&lt;/code&gt; guard means an out-of-order promotion (rare, but possible if the orchestrator itself parallelises) never rolls consensus backwards.&lt;/p&gt;
&lt;h2&gt;Worked example&lt;/h2&gt;
&lt;p&gt;Three sub-agents auditing a service. The orchestrator spawns them in parallel:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;agent-a: read auth/* → fact &amp;quot;rate_limit = 100 rpm&amp;quot;  → INSERT version 1 ✓
agent-b: read docs/*  → fact &amp;quot;rate_limit = 60 rpm&amp;quot;   → INSERT version 1 ✗ (unique violation)
                                                      → reads consensus (100 rpm)
                                                      → bodies differ, INSERT conflict
agent-c: read tests/* → fact &amp;quot;rate_limit = 100 rpm&amp;quot;  → bodies match, no-op
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The orchestrator&amp;#39;s listener fires once, receives &lt;code&gt;{scope_key: &amp;quot;auth.rate_limit&amp;quot;, loser: B, winner: A}&lt;/code&gt;, and the next turn of the parent session sees:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ Two sub-agents disagreed on &lt;code&gt;auth.rate_limit&lt;/code&gt;. Candidate facts: agent-a says 100 rpm (from &lt;code&gt;auth/limits.ts:42&lt;/code&gt;), agent-b says 60 rpm (from &lt;code&gt;docs/api.md:88&lt;/code&gt;). Promote one?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The human (or a &lt;code&gt;/promote&lt;/code&gt; slash command) picks one, and the consensus row updates. The other fact stays in &lt;code&gt;memory&lt;/code&gt; for audit — nothing is discarded, only de-promoted.&lt;/p&gt;
&lt;h2&gt;What this gives you&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Isolation per agent&lt;/strong&gt; via &lt;code&gt;REPEATABLE READ&lt;/code&gt; snapshots — no torn reads, no flapping facts mid-task.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One-shot conflict detection&lt;/strong&gt; via the &lt;code&gt;(scope_key, version)&lt;/code&gt; unique index — no advisory locks, no leader election.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Append-only history&lt;/strong&gt; — every losing write is still in the &lt;code&gt;memory&lt;/code&gt; table, queryable for &amp;quot;when did we change our mind about X?&amp;quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push-based orchestrator alerts&lt;/strong&gt; via &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; — no polling, conflicts surface within milliseconds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The whole thing is roughly 150 lines of SQL + TypeScript and rides on the same RedDB instance already wired up for &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;agent observability&lt;/a&gt; and &lt;a href=&quot;/blog/slash-commands-with-memory&quot;&gt;the &lt;code&gt;/remember&lt;/code&gt; command&lt;/a&gt;. When the next post in this pillar adds &lt;a href=&quot;/blog/sub-agent-dispatch-queue&quot;&gt;sub-agent dispatch via a database queue&lt;/a&gt;, the queue rows will simply point at scope keys here.&lt;/p&gt;
&lt;p&gt;Five agents, one knowledge graph, no merge hell.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/agent-observability-traces-tokens-costs</id>
    <title>Agent observability — traces, tokens, and costs in RedDB</title>
    <link href="https://reddb.io/blog/agent-observability-traces-tokens-costs"/>
    <updated>2026-05-28</updated>
    <published>2026-05-28</published>
    <author><name>RedDB team</name></author>
    <summary>Most Claude Code setups can&apos;t answer &quot;what did the agent cost me last sprint?&quot; or &quot;which tools dominate p99 latency?&quot;. A PostToolUse hook that writes every tool call, token count, and cost into RedDB turns those questions into one-line SQL.</summary>
    <content type="html">&lt;h2&gt;The questions you can&amp;#39;t answer today&lt;/h2&gt;
&lt;p&gt;Run Claude Code daily for a week and you accumulate a folder of transcripts, a credit-card bill from Anthropic, and no honest answer to questions a manager will ask within a month:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How much did the team spend on agent runs last sprint?&lt;/li&gt;
&lt;li&gt;Which tools account for the bulk of token usage — &lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Bash&lt;/code&gt;, sub-agents?&lt;/li&gt;
&lt;li&gt;What is the p99 latency of &lt;code&gt;Bash&lt;/code&gt; vs &lt;code&gt;Read&lt;/code&gt;? Is one of them dragging turn times?&lt;/li&gt;
&lt;li&gt;Which sessions blew past a sensible cost budget, and what were they doing?&lt;/li&gt;
&lt;li&gt;After we shipped the new memory hook, did average turn cost go up or down?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these is a &lt;code&gt;GROUP BY&lt;/code&gt; away once the data is in a table. The work is in getting it there, accurately, without slowing the agent down. A &lt;code&gt;PostToolUse&lt;/code&gt; hook plus three tables is enough.&lt;/p&gt;
&lt;p&gt;This post assumes you have the same RedDB-as-memory-backend setup from &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;the first post in this pillar&lt;/a&gt; and the transactional hook recipe from &lt;a href=&quot;/blog/hooks-transactional-audit&quot;&gt;the audit-log post&lt;/a&gt;. It reuses the idempotency pattern verbatim — observability is a special case of structured audit.&lt;/p&gt;
&lt;h2&gt;The schema&lt;/h2&gt;
&lt;p&gt;Three tables. One for the tool call itself, one for token/cost facts emitted by the model API, one for session-level rollups so the dashboard does not re-aggregate twelve million rows on every page load.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE tool_call (
  id              TEXT PRIMARY KEY,                  -- ulid
  session_id      TEXT NOT NULL,
  turn_index      INTEGER NOT NULL,                  -- monotonic within session
  tool_name       TEXT NOT NULL,                     -- Read, Bash, Skill, Task, …
  tool_input_size INTEGER NOT NULL,                  -- bytes of tool_input json
  is_error        BOOLEAN NOT NULL DEFAULT false,
  started_at      TIMESTAMPTZ NOT NULL,
  ended_at        TIMESTAMPTZ NOT NULL,
  duration_ms     INTEGER GENERATED ALWAYS AS
                    (EXTRACT(EPOCH FROM (ended_at - started_at)) * 1000)::int STORED
);

CREATE TABLE token_usage (
  id              TEXT PRIMARY KEY,                  -- ulid
  session_id      TEXT NOT NULL,
  turn_index      INTEGER NOT NULL,
  model           TEXT NOT NULL,                     -- claude-opus-4-7, …
  input_tokens    INTEGER NOT NULL,
  cached_tokens   INTEGER NOT NULL DEFAULT 0,        -- prompt-cache hits
  output_tokens   INTEGER NOT NULL,
  cost_usd        NUMERIC(10,6) NOT NULL,            -- computed from the model price list
  recorded_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE session_rollup (
  session_id      TEXT PRIMARY KEY,
  started_at      TIMESTAMPTZ NOT NULL,
  last_seen_at    TIMESTAMPTZ NOT NULL,
  total_turns     INTEGER NOT NULL DEFAULT 0,
  total_tools     INTEGER NOT NULL DEFAULT 0,
  total_cost_usd  NUMERIC(10,6) NOT NULL DEFAULT 0,
  project_path    TEXT,
  user_email      TEXT
);

CREATE INDEX tool_call_session_idx ON tool_call (session_id, turn_index);
CREATE INDEX tool_call_name_idx    ON tool_call (tool_name, started_at DESC);
CREATE INDEX token_usage_session_idx ON token_usage (session_id, turn_index);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;tool_call&lt;/code&gt; is one row per &lt;code&gt;PostToolUse&lt;/code&gt; event. &lt;code&gt;token_usage&lt;/code&gt; is one row per assistant turn (the model API returns the counts in its response, which the harness exposes via &lt;code&gt;Stop&lt;/code&gt; and &lt;code&gt;PostToolUse&lt;/code&gt; payloads on the Anthropic adapter). &lt;code&gt;session_rollup&lt;/code&gt; is maintained by trigger so the dashboard reads constant-time per session.&lt;/p&gt;
&lt;h2&gt;The PostToolUse hook&lt;/h2&gt;
&lt;p&gt;The hook is the audit-log hook from the previous post with three extra columns and a token-usage insert in the same transaction. The idempotency key is the same &lt;code&gt;sha256(session_id + tool_use_id)&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# ~/.claude/hooks/observe.sh
set -euo pipefail

payload=&amp;quot;$(cat)&amp;quot;

session_id=&amp;quot;$(jq  -r &amp;#39;.session_id&amp;#39;                &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
turn_index=&amp;quot;$(jq  -r &amp;#39;.turn_index // 0&amp;#39;           &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
tool_use_id=&amp;quot;$(jq -r &amp;#39;.tool_use_id // .id // &amp;quot;&amp;quot;&amp;#39;  &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
tool_name=&amp;quot;$(jq   -r &amp;#39;.tool_name // &amp;quot;&amp;quot;&amp;#39;           &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
tool_input=&amp;quot;$(jq    &amp;#39;.tool_input // {}&amp;#39;           &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
is_error=&amp;quot;$(jq    -r &amp;#39;.is_error // false&amp;#39;         &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
started_at=&amp;quot;$(jq  -r &amp;#39;.started_at&amp;#39;                &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
ended_at=&amp;quot;$(jq    -r &amp;#39;.ended_at&amp;#39;                  &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;

# Token usage is only present on Stop and on the turn-ending PostToolUse;
# pull it defensively.
model=&amp;quot;$(jq         -r &amp;#39;.usage.model // &amp;quot;&amp;quot;&amp;#39;         &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
in_tokens=&amp;quot;$(jq     -r &amp;#39;.usage.input_tokens   // 0&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
cached_tokens=&amp;quot;$(jq -r &amp;#39;.usage.cached_tokens  // 0&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
out_tokens=&amp;quot;$(jq    -r &amp;#39;.usage.output_tokens  // 0&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;

tool_id=&amp;quot;$(ulid)&amp;quot;
idem_key=&amp;quot;$(printf &amp;#39;%s:%s&amp;#39; &amp;quot;$session_id&amp;quot; &amp;quot;$tool_use_id&amp;quot; | sha256sum | awk &amp;#39;{print $1}&amp;#39;)&amp;quot;
input_size=&amp;quot;$(printf &amp;#39;%s&amp;#39; &amp;quot;$tool_input&amp;quot; | wc -c)&amp;quot;

# Price table lives in the hook so a model-list change is one diff.
cost_usd=&amp;quot;$(python3 - &amp;lt;&amp;lt;PY
prices = {
  &amp;quot;claude-opus-4-7&amp;quot;:   (15.00 / 1e6, 75.00 / 1e6, 1.50 / 1e6),  # in, out, cached-read
  &amp;quot;claude-sonnet-4-6&amp;quot;: ( 3.00 / 1e6, 15.00 / 1e6, 0.30 / 1e6),
  &amp;quot;claude-haiku-4-5&amp;quot;:  ( 0.80 / 1e6,  4.00 / 1e6, 0.08 / 1e6),
}
m = &amp;quot;$model&amp;quot;
if m not in prices or ($in_tokens + $out_tokens) == 0:
  print(&amp;quot;0&amp;quot;)
else:
  pi, po, pc = prices[m]
  print(f&amp;quot;{$in_tokens * pi + $out_tokens * po + $cached_tokens * pc:.6f}&amp;quot;)
PY
)&amp;quot;

curl -sS --fail --max-time 1 \
  -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;$REDDB_URL/tx&amp;quot; \
  -d &amp;quot;$(jq -nc \
        --arg tid       &amp;quot;$tool_id&amp;quot; \
        --arg sid       &amp;quot;$session_id&amp;quot; \
        --argjson turn  &amp;quot;$turn_index&amp;quot; \
        --arg tname     &amp;quot;$tool_name&amp;quot; \
        --argjson isz   &amp;quot;$input_size&amp;quot; \
        --argjson err   &amp;quot;$is_error&amp;quot; \
        --arg sat       &amp;quot;$started_at&amp;quot; \
        --arg eat       &amp;quot;$ended_at&amp;quot; \
        --arg model     &amp;quot;$model&amp;quot; \
        --argjson it    &amp;quot;$in_tokens&amp;quot; \
        --argjson ct    &amp;quot;$cached_tokens&amp;quot; \
        --argjson ot    &amp;quot;$out_tokens&amp;quot; \
        --arg cost      &amp;quot;$cost_usd&amp;quot; \
        --arg ikey      &amp;quot;$idem_key&amp;quot; \
        &amp;#39;{
          statements: [
            { sql: &amp;quot;INSERT INTO tool_call (id, session_id, turn_index, tool_name, tool_input_size, is_error, started_at, ended_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (id) DO NOTHING&amp;quot;,
              args: [$tid, $sid, $turn, $tname, $isz, $err, $sat, $eat] },
            { sql: &amp;quot;INSERT INTO hook_idempotency (key, event_id) VALUES ($1,$2) ON CONFLICT (key) DO NOTHING&amp;quot;,
              args: [$ikey, $tid] },
            { sql: &amp;quot;INSERT INTO token_usage (id, session_id, turn_index, model, input_tokens, cached_tokens, output_tokens, cost_usd) SELECT ulid(), $1, $2, $3, $4, $5, $6, $7 WHERE $3 &amp;lt;&amp;gt; &amp;#39;&amp;#39;&amp;quot;,
              args: [$sid, $turn, $model, $it, $ct, $ot, $cost] }
          ]
        }&amp;#39;)&amp;quot; &amp;gt;/dev/null || true

echo &amp;#39;{&amp;quot;continue&amp;quot;:true}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--max-time 1&lt;/code&gt; + &lt;code&gt;|| true&lt;/code&gt; — the agent never blocks on telemetry. If RedDB is unreachable for a second, that one event is lost. A &lt;code&gt;Stop&lt;/code&gt; hook can backfill from the harness&amp;#39;s local JSONL transcript if you need full coverage.&lt;/li&gt;
&lt;li&gt;The third statement is gated on &lt;code&gt;$model &amp;lt;&amp;gt; &amp;#39;&amp;#39;&lt;/code&gt;. Most &lt;code&gt;PostToolUse&lt;/code&gt; events do not carry usage data — only the turn-closing one does. The conditional &lt;code&gt;INSERT … SELECT … WHERE&lt;/code&gt; skips the row at SQL level rather than branching in bash.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wire it up in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;hooks&amp;quot;: {
    &amp;quot;PostToolUse&amp;quot;: [
      { &amp;quot;matcher&amp;quot;: &amp;quot;.*&amp;quot;, &amp;quot;command&amp;quot;: &amp;quot;~/.claude/hooks/observe.sh&amp;quot; }
    ],
    &amp;quot;Stop&amp;quot;: [
      { &amp;quot;matcher&amp;quot;: &amp;quot;.*&amp;quot;, &amp;quot;command&amp;quot;: &amp;quot;~/.claude/hooks/observe.sh&amp;quot; }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The rollup trigger&lt;/h2&gt;
&lt;p&gt;Reading raw &lt;code&gt;tool_call&lt;/code&gt; and &lt;code&gt;token_usage&lt;/code&gt; for &amp;quot;show me last sprint&amp;#39;s cost by user&amp;quot; is fine at 100k rows and miserable at 10M. A trigger keeps &lt;code&gt;session_rollup&lt;/code&gt; warm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE OR REPLACE FUNCTION bump_rollup() RETURNS trigger AS $$
BEGIN
  INSERT INTO session_rollup (session_id, started_at, last_seen_at, total_turns, total_tools, total_cost_usd)
  VALUES (NEW.session_id, NEW.started_at, NEW.ended_at, 0, 1, 0)
  ON CONFLICT (session_id) DO UPDATE
     SET last_seen_at = GREATEST(session_rollup.last_seen_at, EXCLUDED.last_seen_at),
         total_tools  = session_rollup.total_tools + 1;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER tool_call_rollup AFTER INSERT ON tool_call
FOR EACH ROW EXECUTE FUNCTION bump_rollup();

CREATE OR REPLACE FUNCTION bump_cost() RETURNS trigger AS $$
BEGIN
  UPDATE session_rollup
     SET total_cost_usd = total_cost_usd + NEW.cost_usd,
         total_turns    = GREATEST(total_turns, NEW.turn_index + 1)
   WHERE session_id = NEW.session_id;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER token_usage_rollup AFTER INSERT ON token_usage
FOR EACH ROW EXECUTE FUNCTION bump_cost();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;session_rollup&lt;/code&gt; is now the queryable summary. The raw tables stay for drill-down.&lt;/p&gt;
&lt;h2&gt;The five queries that earn the hook&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 1. Sprint spend by user.
SELECT user_email,
       SUM(total_cost_usd)::numeric(10,2) AS cost_usd,
       COUNT(*)                            AS sessions
  FROM session_rollup
 WHERE started_at &amp;gt;= date_trunc(&amp;#39;day&amp;#39;, now()) - interval &amp;#39;14 days&amp;#39;
 GROUP BY user_email
 ORDER BY cost_usd DESC;

-- 2. Top tools by total token-equivalent cost (rough — tools don&amp;#39;t pay tokens
--    directly, but tool_input bytes correlate with the prompt cost they cause).
SELECT tool_name,
       COUNT(*)                                          AS calls,
       SUM(tool_input_size)                              AS bytes_into_prompt,
       SUM(tool_input_size) * 0.000003                   AS approx_cost_usd
  FROM tool_call
 WHERE started_at &amp;gt;= now() - interval &amp;#39;7 days&amp;#39;
 GROUP BY tool_name
 ORDER BY approx_cost_usd DESC
 LIMIT 10;

-- 3. p99 latency by tool, last 24h.
SELECT tool_name,
       percentile_disc(0.50) WITHIN GROUP (ORDER BY duration_ms) AS p50,
       percentile_disc(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99,
       COUNT(*)                                                   AS n
  FROM tool_call
 WHERE started_at &amp;gt;= now() - interval &amp;#39;24 hours&amp;#39;
 GROUP BY tool_name
 ORDER BY p99 DESC;

-- 4. Sessions over a $5 budget, this week.
SELECT session_id, user_email, total_cost_usd, total_turns, total_tools, project_path
  FROM session_rollup
 WHERE started_at &amp;gt;= date_trunc(&amp;#39;week&amp;#39;, now())
   AND total_cost_usd &amp;gt; 5.00
 ORDER BY total_cost_usd DESC
 LIMIT 25;

-- 5. Before/after comparison for a hook change deployed at 2026-05-20.
SELECT date_trunc(&amp;#39;day&amp;#39;, started_at) AS day,
       AVG(total_cost_usd)::numeric(10,4) AS avg_session_cost,
       COUNT(*)                            AS sessions
  FROM session_rollup
 WHERE started_at &amp;gt;= &amp;#39;2026-05-13&amp;#39;
 GROUP BY day
 ORDER BY day;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each one is the smallest possible question to ask of the schema. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on a year of single-user data on commodity hardware runs all five in under 50ms thanks to the rollup table and the two indexes on &lt;code&gt;tool_call&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;A dashboard sketch&lt;/h2&gt;
&lt;p&gt;The five queries above are also the five panels of a sensible &amp;quot;agent observability&amp;quot; board. Sketched in Grafana terms but the pattern translates to anything that speaks SQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;┌──────────────────────────────────────────────────────────────────┐
│  Sprint spend by user        │  Top tools by approx cost          │
│  (bar — query 1)             │  (bar — query 2)                   │
├──────────────────────────────┼────────────────────────────────────┤
│  p50 / p99 by tool, 24h      │  Sessions over $5 this week        │
│  (heatmap — query 3)         │  (table — query 4)                 │
├──────────────────────────────┴────────────────────────────────────┤
│  Daily average session cost (line — query 5, annotated with        │
│  vertical markers at each hook deploy)                             │
└────────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The vertical annotations on the last panel are the highest-leverage piece. Every time you deploy a new hook, skill, or system prompt, drop a marker. The line either bends down (the change paid off) or it does not. Most teams ship agent changes blind because the feedback loop is &amp;quot;vibes&amp;quot;. This panel is the feedback loop.&lt;/p&gt;
&lt;h2&gt;What this buys you&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Sprint spend by user&lt;/td&gt;
&lt;td&gt;Ask Anthropic billing CSV, grep for emails&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT … FROM session_rollup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which tools dominate latency&lt;/td&gt;
&lt;td&gt;Read transcripts, eyeball&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;percentile_disc&lt;/code&gt; query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did the new skill make turns cheaper&lt;/td&gt;
&lt;td&gt;Hope so&lt;/td&gt;
&lt;td&gt;Annotated time-series, signed answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why did session X cost $40&lt;/td&gt;
&lt;td&gt;Open the transcript, scroll&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT * FROM tool_call WHERE session_id = …&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The hook is ~50 lines. The schema is three tables and two triggers. The dashboard is one Grafana JSON away. The payoff is that &amp;quot;how is the agent doing&amp;quot; stops being a vibes question and starts being a query.&lt;/p&gt;
&lt;h2&gt;What is next in this pillar&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The same &lt;code&gt;tool_call&lt;/code&gt; table powers a &lt;a href=&quot;/blog&quot;&gt;context-window economics&lt;/a&gt; post: knowing the bytes-into-prompt per tool lets you compute the dollar cost of re-reading a file vs. caching it in RedDB.&lt;/li&gt;
&lt;li&gt;A &lt;a href=&quot;/blog&quot;&gt;sub-agent dispatch queue&lt;/a&gt; post that reuses &lt;code&gt;tool_call.tool_name = &amp;#39;Task&amp;#39;&lt;/code&gt; rows to track parent/child runtime and cost across processes.&lt;/li&gt;
&lt;li&gt;An MCP-server post that exposes &lt;code&gt;observability.*&lt;/code&gt; to other CLI agents — Cursor and Codex emit the same tool-call shape, so the schema generalises.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a href=&quot;/blog/rss.xml&quot;&gt;Atom feed&lt;/a&gt; carries them when they land.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/mcp-server-for-reddb</id>
    <title>An MCP server for RedDB — one database, every agent CLI</title>
    <link href="https://reddb.io/blog/mcp-server-for-reddb"/>
    <updated>2026-05-26</updated>
    <published>2026-05-26</published>
    <author><name>RedDB team</name></author>
    <summary>The Model Context Protocol lets every modern agent CLI — Claude Code, Codex, Cursor, Gemini CLI — share the same toolbelt. Wrap RedDB once and you give all of them agent memory, document storage, and semantic search through a single endpoint. Manifest, server, wiring.</summary>
    <content type="html">&lt;h2&gt;Why MCP is the right seam&lt;/h2&gt;
&lt;p&gt;Earlier posts in this pillar wired RedDB into Claude Code directly: a &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;&lt;code&gt;SessionStart&lt;/code&gt; hook&lt;/a&gt; for retrieval, a &lt;a href=&quot;/blog/remember-this-skill&quot;&gt;skill&lt;/a&gt; for writes, &lt;a href=&quot;/blog/slash-commands-with-memory&quot;&gt;slash commands&lt;/a&gt; for human-driven edits. Each of those is a &lt;em&gt;Claude Code-shaped&lt;/em&gt; integration — a hook config, a &lt;code&gt;SKILL.md&lt;/code&gt;, a markdown file in &lt;code&gt;~/.claude/commands/&lt;/code&gt;. None of it survives a port to Codex or Cursor.&lt;/p&gt;
&lt;p&gt;The Model Context Protocol moves the seam one level down. An MCP server exposes a typed set of tools over stdio (or HTTP/SSE), and any MCP-aware host can mount it. Write the integration once, register it in every host&amp;#39;s config, and the same &lt;code&gt;memory.write&lt;/code&gt; tool is available from every agent CLI you run. The host&amp;#39;s harness handles the rest — tool advertisement, schema validation, call routing.&lt;/p&gt;
&lt;p&gt;For RedDB this maps cleanly. The database already speaks four data models (KV, document, vector, graph); MCP wants a flat list of tools with JSON Schema. We pick the four operations that pay rent in a daily agent loop:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP tool&lt;/th&gt;
&lt;th&gt;RedDB operation&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;memory.write&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSERT&lt;/code&gt; into &lt;code&gt;memory&lt;/code&gt; table with embedding&lt;/td&gt;
&lt;td&gt;Capturing a fact mid-turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;memory.search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT … ORDER BY embedding &amp;lt;=&amp;gt; $1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pulling top-k context on demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;doc.upsert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Document store write, keyed by path&lt;/td&gt;
&lt;td&gt;Saving a generated artifact (spec, ADR)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vector.search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Raw HNSW query against a named index&lt;/td&gt;
&lt;td&gt;RAG over a corpus the agent doesn&amp;#39;t own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Everything else (transactions, graph traversals, multi-statement SQL) stays behind the server. Tools should be small.&lt;/p&gt;
&lt;h2&gt;The manifest&lt;/h2&gt;
&lt;p&gt;MCP servers are described by a tiny JSON manifest. The host reads it once and uses the &lt;code&gt;tools&lt;/code&gt; list to populate the model&amp;#39;s tool registry on every turn. Schemas are JSON Schema 2020-12.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;reddb&amp;quot;,
  &amp;quot;version&amp;quot;: &amp;quot;0.1.0&amp;quot;,
  &amp;quot;description&amp;quot;: &amp;quot;RedDB memory and document storage for agent CLIs.&amp;quot;,
  &amp;quot;tools&amp;quot;: [
    {
      &amp;quot;name&amp;quot;: &amp;quot;memory.write&amp;quot;,
      &amp;quot;description&amp;quot;: &amp;quot;Persist a short fact the agent wants to remember across sessions. Embeds the body and writes a row to the memory table.&amp;quot;,
      &amp;quot;inputSchema&amp;quot;: {
        &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
        &amp;quot;required&amp;quot;: [&amp;quot;body&amp;quot;],
        &amp;quot;properties&amp;quot;: {
          &amp;quot;body&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot;, &amp;quot;maxLength&amp;quot;: 2000 },
          &amp;quot;tags&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;array&amp;quot;, &amp;quot;items&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; } },
          &amp;quot;project&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; }
        }
      }
    },
    {
      &amp;quot;name&amp;quot;: &amp;quot;memory.search&amp;quot;,
      &amp;quot;description&amp;quot;: &amp;quot;Semantic search over previously remembered facts. Returns top-k rows ranked by cosine similarity.&amp;quot;,
      &amp;quot;inputSchema&amp;quot;: {
        &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
        &amp;quot;required&amp;quot;: [&amp;quot;query&amp;quot;],
        &amp;quot;properties&amp;quot;: {
          &amp;quot;query&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; },
          &amp;quot;k&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;integer&amp;quot;, &amp;quot;minimum&amp;quot;: 1, &amp;quot;maximum&amp;quot;: 20, &amp;quot;default&amp;quot;: 5 },
          &amp;quot;project&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; }
        }
      }
    },
    {
      &amp;quot;name&amp;quot;: &amp;quot;doc.upsert&amp;quot;,
      &amp;quot;description&amp;quot;: &amp;quot;Write or replace a document at the given path. Body is plain text or markdown.&amp;quot;,
      &amp;quot;inputSchema&amp;quot;: {
        &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
        &amp;quot;required&amp;quot;: [&amp;quot;path&amp;quot;, &amp;quot;body&amp;quot;],
        &amp;quot;properties&amp;quot;: {
          &amp;quot;path&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; },
          &amp;quot;body&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; }
        }
      }
    },
    {
      &amp;quot;name&amp;quot;: &amp;quot;vector.search&amp;quot;,
      &amp;quot;description&amp;quot;: &amp;quot;Raw similarity search against a named HNSW index. Use for RAG over corpora not owned by the agent (codebases, docs).&amp;quot;,
      &amp;quot;inputSchema&amp;quot;: {
        &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
        &amp;quot;required&amp;quot;: [&amp;quot;index&amp;quot;, &amp;quot;query&amp;quot;],
        &amp;quot;properties&amp;quot;: {
          &amp;quot;index&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; },
          &amp;quot;query&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot; },
          &amp;quot;k&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;integer&amp;quot;, &amp;quot;minimum&amp;quot;: 1, &amp;quot;maximum&amp;quot;: 50, &amp;quot;default&amp;quot;: 10 }
        }
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few choices worth flagging. &lt;code&gt;project&lt;/code&gt; is optional on &lt;code&gt;memory.*&lt;/code&gt; because the server defaults it from an environment variable — the host sets &lt;code&gt;REDDB_PROJECT=rdb-lair&lt;/code&gt; once and every call inherits it. The model never has to remember which project it&amp;#39;s in. &lt;code&gt;body&lt;/code&gt; is capped at 2000 characters; longer artifacts go through &lt;code&gt;doc.upsert&lt;/code&gt;. &lt;code&gt;vector.search&lt;/code&gt; doesn&amp;#39;t auto-embed — the index name dictates the embedding model, and the server handles it server-side, so the model passes a plain string.&lt;/p&gt;
&lt;h2&gt;The server&lt;/h2&gt;
&lt;p&gt;A minimal implementation in Node, using the official &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; and &lt;code&gt;pg&lt;/code&gt; for RedDB&amp;#39;s Postgres-wire protocol. Roughly 80 lines.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { Server } from &amp;quot;@modelcontextprotocol/sdk/server/index.js&amp;quot;
import { StdioServerTransport } from &amp;quot;@modelcontextprotocol/sdk/server/stdio.js&amp;quot;
import { CallToolRequestSchema, ListToolsRequestSchema } from &amp;quot;@modelcontextprotocol/sdk/types.js&amp;quot;
import pg from &amp;quot;pg&amp;quot;
import { readFileSync } from &amp;quot;node:fs&amp;quot;

const manifest = JSON.parse(readFileSync(new URL(&amp;quot;./manifest.json&amp;quot;, import.meta.url), &amp;quot;utf8&amp;quot;))
const project = process.env.REDDB_PROJECT ?? &amp;quot;default&amp;quot;
const db = new pg.Pool({ connectionString: process.env.REDDB_URL })

async function embed(text: string): Promise&amp;lt;number[]&amp;gt; {
  const r = await fetch(&amp;quot;https://api.voyageai.com/v1/embeddings&amp;quot;, {
    method: &amp;quot;POST&amp;quot;,
    headers: {
      &amp;quot;content-type&amp;quot;: &amp;quot;application/json&amp;quot;,
      authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
    },
    body: JSON.stringify({ model: &amp;quot;voyage-3&amp;quot;, input: text }),
  })
  const json = await r.json()
  return json.data[0].embedding
}

const handlers: Record&amp;lt;string, (args: any) =&amp;gt; Promise&amp;lt;unknown&amp;gt;&amp;gt; = {
  &amp;quot;memory.write&amp;quot;: async ({ body, tags = [], project: p = project }) =&amp;gt; {
    const v = await embed(body)
    const { rows } = await db.query(
      &amp;quot;INSERT INTO memory (project, body, tags, embedding) VALUES ($1,$2,$3,$4) RETURNING id, created_at&amp;quot;,
      [p, body, tags, JSON.stringify(v)],
    )
    return rows[0]
  },
  &amp;quot;memory.search&amp;quot;: async ({ query, k = 5, project: p = project }) =&amp;gt; {
    const v = await embed(query)
    const { rows } = await db.query(
      `SELECT id, body, tags, created_at, 1 - (embedding &amp;lt;=&amp;gt; $1) AS score
       FROM memory
       WHERE project = $2 AND deleted_at IS NULL
       ORDER BY embedding &amp;lt;=&amp;gt; $1
       LIMIT $3`,
      [JSON.stringify(v), p, k],
    )
    return rows
  },
  &amp;quot;doc.upsert&amp;quot;: async ({ path, body }) =&amp;gt; {
    const { rows } = await db.query(
      `INSERT INTO doc (path, body, project) VALUES ($1,$2,$3)
       ON CONFLICT (project, path) DO UPDATE SET body = EXCLUDED.body, updated_at = now()
       RETURNING path, updated_at`,
      [path, body, project],
    )
    return rows[0]
  },
  &amp;quot;vector.search&amp;quot;: async ({ index, query, k = 10 }) =&amp;gt; {
    const { rows } = await db.query(
      &amp;quot;SELECT * FROM reddb.vector_search($1, $2, $3)&amp;quot;,
      [index, query, k],
    )
    return rows
  },
}

const server = new Server({ name: manifest.name, version: manifest.version }, { capabilities: { tools: {} } })

server.setRequestHandler(ListToolsRequestSchema, async () =&amp;gt; ({ tools: manifest.tools }))

server.setRequestHandler(CallToolRequestSchema, async (req) =&amp;gt; {
  const fn = handlers[req.params.name]
  if (!fn) throw new Error(`unknown tool: ${req.params.name}`)
  const result = await fn(req.params.arguments ?? {})
  return { content: [{ type: &amp;quot;text&amp;quot;, text: JSON.stringify(result) }] }
})

await server.connect(new StdioServerTransport())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Things to notice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Schema lives in &lt;code&gt;manifest.json&lt;/code&gt;, not in code.&lt;/strong&gt; The &lt;code&gt;ListTools&lt;/code&gt; handler ships the manifest verbatim. One file is the source of truth; hosts and humans read the same thing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;embed&lt;/code&gt; is one HTTP call.&lt;/strong&gt; No batching, no retries. The model only calls these tools when it has decided a fact is worth a round-trip; latency budget is generous.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Errors propagate.&lt;/strong&gt; If the database rejects the insert, the SDK turns the thrown &lt;code&gt;Error&lt;/code&gt; into a tool error the model sees on the next turn. No silent failures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;vector.search&lt;/code&gt; defers to a SQL function.&lt;/strong&gt; The server doesn&amp;#39;t pick an embedding model; the function &lt;code&gt;reddb.vector_search&lt;/code&gt; looks up the index&amp;#39;s configured model and does the right thing. Keeps the tool surface tiny.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run it once locally with &lt;code&gt;node --env-file=.env server.js&lt;/code&gt;. Wrong env var names will surface as a connect-time error; nothing about an MCP server is mysterious once you&amp;#39;ve started it.&lt;/p&gt;
&lt;h2&gt;Wiring into Claude Code&lt;/h2&gt;
&lt;p&gt;Claude Code reads MCP servers from &lt;code&gt;~/.claude/mcp.json&lt;/code&gt; (global) or &lt;code&gt;.mcp.json&lt;/code&gt; at the repo root (per-project). A project-scoped config keeps secrets pinned to one tree:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;mcpServers&amp;quot;: {
    &amp;quot;reddb&amp;quot;: {
      &amp;quot;command&amp;quot;: &amp;quot;node&amp;quot;,
      &amp;quot;args&amp;quot;: [&amp;quot;./tools/reddb-mcp/server.js&amp;quot;],
      &amp;quot;env&amp;quot;: {
        &amp;quot;REDDB_URL&amp;quot;: &amp;quot;postgres://reddb@localhost:5432/lair&amp;quot;,
        &amp;quot;REDDB_PROJECT&amp;quot;: &amp;quot;rdb-lair&amp;quot;,
        &amp;quot;VOYAGE_API_KEY&amp;quot;: &amp;quot;${env:VOYAGE_API_KEY}&amp;quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;${env:…}&lt;/code&gt; references inherit from the user&amp;#39;s shell, so the file is checkable into the repo without leaking secrets. After saving, restart the harness and &lt;code&gt;/mcp&lt;/code&gt; lists the server with its four tools. The model can now call &lt;code&gt;memory.write&lt;/code&gt; directly — no skill, no slash command, no hook.&lt;/p&gt;
&lt;p&gt;The same config syntax works for Codex (&lt;code&gt;~/.codex/mcp.json&lt;/code&gt;) and Cursor (settings UI, JSON-equivalent). Gemini CLI uses a slightly different shape but the same &lt;code&gt;command&lt;/code&gt;/&lt;code&gt;args&lt;/code&gt;/&lt;code&gt;env&lt;/code&gt; triplet. One server, four CLIs.&lt;/p&gt;
&lt;h2&gt;What you gave up, and what you bought&lt;/h2&gt;
&lt;p&gt;You gave up Claude Code-specific affordances. A skill can include a long &lt;code&gt;SKILL.md&lt;/code&gt; describing &lt;em&gt;when&lt;/em&gt; to use it; an MCP tool gets one &lt;code&gt;description&lt;/code&gt; line. A slash command can be invoked deterministically by the user; an MCP tool is only ever called by the model. If your write path needs human-in-the-loop confirmation, the slash command from &lt;a href=&quot;/blog/slash-commands-with-memory&quot;&gt;the D4 post&lt;/a&gt; is still the right tool.&lt;/p&gt;
&lt;p&gt;What you bought is portability and a single audit point. Every read and every write goes through one process; logging it is a &lt;code&gt;console.error&lt;/code&gt; away. Every CLI on the team can share one memory store without each engineer maintaining four parallel hook configs. And the next agent CLI someone in the org wants to try — the one that hasn&amp;#39;t been built yet — gets RedDB for free, the day it ships MCP support.&lt;/p&gt;
&lt;p&gt;That is the whole pitch: the database is the integration, the protocol is the bus, the CLIs are interchangeable.&lt;/p&gt;
&lt;h2&gt;Next&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/blog/multi-agent-shared-memory&quot;&gt;Multi-agent shared memory&lt;/a&gt; — the same MCP server, but with several agents writing into it concurrently and a conflict-resolution doc keeping them honest.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/agent-observability-traces-tokens-costs&quot;&gt;Agent observability&lt;/a&gt; — wrap the MCP server with a tool-call audit log and build the &amp;quot;what did Claude Code cost me last sprint&amp;quot; query.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/remember-this-skill</id>
    <title>Building a &quot;remember this&quot; skill end-to-end</title>
    <link href="https://reddb.io/blog/remember-this-skill"/>
    <updated>2026-05-24</updated>
    <published>2026-05-24</published>
    <author><name>RedDB team</name></author>
    <summary>A 30-minute walkthrough — turn a Claude Code skill into a durable note-taker. The skill captures the current conversation slice, embeds it, writes it to RedDB, and surfaces it on the next SessionStart. Every file you need, in order.</summary>
    <content type="html">&lt;h2&gt;What we&amp;#39;re building&lt;/h2&gt;
&lt;p&gt;Three earlier posts in this pillar set the table:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;RedDB as Claude Code&amp;#39;s memory backend&lt;/a&gt; — schema, &lt;code&gt;SessionStart&lt;/code&gt; hook, retrieval query.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/slash-commands-with-memory&quot;&gt;Slash commands with memory&lt;/a&gt; — &lt;code&gt;/remember&lt;/code&gt;, &lt;code&gt;/recall&lt;/code&gt;, &lt;code&gt;/forget&lt;/code&gt; as the human-driven write path.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/skills-as-data&quot;&gt;Skills as data&lt;/a&gt; — skill metadata and runs as queryable rows.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A slash command needs a human to type it. A &lt;code&gt;SessionStart&lt;/code&gt; hook needs a session boundary. Neither catches &lt;em&gt;the moment in the middle of a turn where a fact deserves to be remembered&lt;/em&gt; — a correction, a constraint, a piece of domain language the agent just learned. That is what a skill is for: the harness picks it up by description, the agent invokes it without ceremony, the side effect lands in the database.&lt;/p&gt;
&lt;p&gt;By the end of this post you have a skill called &lt;code&gt;remember-this&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reader installs three files (&lt;code&gt;SKILL.md&lt;/code&gt;, &lt;code&gt;capture.sh&lt;/code&gt;, an embedder).&lt;/li&gt;
&lt;li&gt;Mid-turn, agent decides &amp;quot;this is worth keeping&amp;quot; and invokes the skill with a short body.&lt;/li&gt;
&lt;li&gt;Skill embeds the body, writes it to the &lt;code&gt;memory&lt;/code&gt; table from D1, returns a confirmation.&lt;/li&gt;
&lt;li&gt;Next session, the &lt;code&gt;SessionStart&lt;/code&gt; hook surfaces the row in context if it scores against the active task.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Total moving parts: one markdown file, one shell script, one HTTP call. No new infrastructure beyond the RedDB instance from D1.&lt;/p&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A RedDB instance reachable from your workstation. The examples assume &lt;code&gt;http://localhost:7878&lt;/code&gt; and a bearer token in &lt;code&gt;$REDDB_TOKEN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;memory&lt;/code&gt; table from the D1 post. Repeated here so this post stands alone:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE memory (
  id          TEXT PRIMARY KEY,
  kind        TEXT NOT NULL,
  scope       TEXT NOT NULL,
  body        TEXT NOT NULL,
  embedding   VECTOR(1024) NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ
);

CREATE INDEX memory_scope_idx     ON memory (scope, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX memory_embedding_idx ON memory USING hnsw (embedding vector_cosine_ops) WHERE deleted_at IS NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;An embedding endpoint. The examples call &lt;code&gt;voyage-3&lt;/code&gt; via the Voyage HTTP API; swap in any 1024-dim model and the code is the same. Set &lt;code&gt;$VOYAGE_KEY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Claude Code installed with skills enabled (any recent release).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;File 1 — &lt;code&gt;SKILL.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Drop at &lt;code&gt;~/.claude/skills/remember-this/SKILL.md&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
name: remember-this
description: Persist a fact, correction, or constraint into long-term memory mid-turn. Use when the user states a preference, corrects an approach, names a non-obvious constraint, or shares a piece of domain knowledge that should outlive the current session. Do not invoke for ephemeral task state, code changes, or anything already obvious from the repo. Invoke at most once per logical fact.
---

# remember-this

When this skill matches, capture the fact you just learned.

## Steps

1. Decide what to save. One memory equals one self-contained fact. Strip surrounding chat. Rephrase in third person if the user said &amp;quot;I&amp;quot; — future-you will not know who &amp;quot;I&amp;quot; was.
2. Pick a `kind`:
   - `user` — durable facts about the user (role, expertise, tools they own)
   - `feedback` — corrections to your approach, with the *why*
   - `project` — facts about the current project that are not derivable from the code
   - `reference` — pointers to external systems (Linear projects, Grafana boards, Slack channels)
3. Pick a `scope`:
   - `global` — true regardless of project
   - the current working directory — true here only
4. Run the capture:

   ```bash
   ~/.claude/skills/remember-this/capture.sh \
     --kind=&amp;lt;kind&amp;gt; \
     --scope=&amp;lt;scope&amp;gt; \
     --body=&amp;quot;&amp;lt;the fact, one sentence&amp;gt;&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;5&quot;&gt;
&lt;li&gt;Report back to the user in one line: &lt;code&gt;Remembered (&amp;lt;kind&amp;gt;, &amp;lt;scope&amp;gt;): &amp;lt;body&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Do not&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Save the verbatim user message. Save the &lt;em&gt;fact extracted from it&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Save anything that would be obvious from reading &lt;code&gt;git log&lt;/code&gt; or &lt;code&gt;CLAUDE.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Save more than one row per turn. If two facts appeared, invoke twice with two bodies.&lt;/li&gt;
&lt;li&gt;Invoke when the user is mid-task and just venting. Memory is for things that change future behavior.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;
Two things the description does, that matter for trigger quality:

- It tells the harness *when not* to fire. Skills with vague descriptions fire on every turn and the user disables them within a day.
- It encodes the unit of work: one fact per invocation. The agent will otherwise dump entire chat windows into a single row and the embedding becomes useless.

## File 2 — `capture.sh`

Drop at `~/.claude/skills/remember-this/capture.sh`. `chmod +x` it.

```bash
#!/usr/bin/env bash
set -euo pipefail

KIND=user
SCOPE=$(pwd)
BODY=

for arg in &amp;quot;$@&amp;quot;; do
  case &amp;quot;$arg&amp;quot; in
    --kind=*)  KIND=&amp;quot;${arg#--kind=}&amp;quot;  ;;
    --scope=*) SCOPE=&amp;quot;${arg#--scope=}&amp;quot; ;;
    --body=*)  BODY=&amp;quot;${arg#--body=}&amp;quot;  ;;
    *) echo &amp;quot;unknown arg: $arg&amp;quot; &amp;gt;&amp;amp;2; exit 2 ;;
  esac
done

if [[ -z &amp;quot;$BODY&amp;quot; ]]; then
  echo &amp;quot;--body is required&amp;quot; &amp;gt;&amp;amp;2
  exit 2
fi

case &amp;quot;$KIND&amp;quot; in
  user|feedback|project|reference) ;;
  *) echo &amp;quot;invalid --kind: $KIND&amp;quot; &amp;gt;&amp;amp;2; exit 2 ;;
esac

ID=$(python3 -c &amp;#39;import secrets,time;print(f&amp;quot;{int(time.time()*1000):x}{secrets.token_hex(6)}&amp;quot;)&amp;#39;)

EMBEDDING=$(
  curl -sS https://api.voyageai.com/v1/embeddings \
    -H &amp;quot;Authorization: Bearer ${VOYAGE_KEY}&amp;quot; \
    -H &amp;#39;Content-Type: application/json&amp;#39; \
    -d &amp;quot;$(jq -nc --arg input &amp;quot;$BODY&amp;quot; &amp;#39;{model:&amp;quot;voyage-3&amp;quot;, input:[$input]}&amp;#39;)&amp;quot; \
  | jq -c &amp;#39;.data[0].embedding&amp;#39;
)

curl -sS -X POST &amp;quot;${REDDB_URL:-http://localhost:7878}/sql&amp;quot; \
  -H &amp;quot;Authorization: Bearer ${REDDB_TOKEN}&amp;quot; \
  -H &amp;#39;Content-Type: application/json&amp;#39; \
  -d &amp;quot;$(jq -nc \
        --arg id &amp;quot;$ID&amp;quot; \
        --arg kind &amp;quot;$KIND&amp;quot; \
        --arg scope &amp;quot;$SCOPE&amp;quot; \
        --arg body &amp;quot;$BODY&amp;quot; \
        --argjson emb &amp;quot;$EMBEDDING&amp;quot; \
        &amp;#39;{sql:&amp;quot;INSERT INTO memory (id,kind,scope,body,embedding) VALUES ($1,$2,$3,$4,$5)&amp;quot;, params:[$id,$kind,$scope,$body,$emb]}&amp;#39;)&amp;quot; \
  &amp;gt;/dev/null

echo &amp;quot;ok $ID&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script is intentionally small. The contract with the model is &amp;quot;exit zero on success, non-zero with a message on failure&amp;quot;, so any breakage shows up in the agent&amp;#39;s turn as an error it can recover from (re-invoke, or apologise and continue). Three things to notice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; makes silent failure impossible. A skill that pretends to save and doesn&amp;#39;t is the worst class of bug — the user trusts the memory exists and finds out three weeks later it never did.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ID&lt;/code&gt; is a millisecond-precision timestamp prefix plus six random bytes. Sortable, unique, no extra ULID dependency.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;INSERT&lt;/code&gt; is parameterised. The body comes from a language model; treat it like user input from the open internet.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;File 3 — nothing (read-back already exists)&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;SessionStart&lt;/code&gt; hook from D1 already reads top-K rows from &lt;code&gt;memory&lt;/code&gt; scoped to the project. Once &lt;code&gt;capture.sh&lt;/code&gt; writes a row, the next session sees it without any change to the read path.&lt;/p&gt;
&lt;p&gt;If the D1 hook is not in place, the minimal version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# ~/.claude/hooks/session-start.sh
EMBED=$(printf &amp;#39;%s&amp;#39; &amp;quot;$CLAUDE_PROJECT_DIR&amp;quot; | embed-cli)
curl -sS &amp;quot;${REDDB_URL}/sql&amp;quot; \
  -H &amp;quot;Authorization: Bearer ${REDDB_TOKEN}&amp;quot; \
  -d &amp;quot;$(jq -nc --arg s &amp;quot;$CLAUDE_PROJECT_DIR&amp;quot; --argjson e &amp;quot;$EMBED&amp;quot; \
        &amp;#39;{sql:&amp;quot;SELECT body FROM memory WHERE scope IN ($1,$&amp;#39;global&amp;#39;) AND deleted_at IS NULL ORDER BY embedding &amp;lt;=&amp;gt; $2 LIMIT 8&amp;quot;, params:[$s,$e]}&amp;#39;)&amp;quot; \
  | jq -r &amp;#39;.rows[] | &amp;quot;- &amp;quot; + .body&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That output is appended to the session&amp;#39;s initial system message. The agent now opens every turn with the most relevant rows from prior conversations.&lt;/p&gt;
&lt;h2&gt;Testing it&lt;/h2&gt;
&lt;p&gt;Start a fresh session in any project. Steer the agent into territory worth remembering:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;I&amp;#39;m a Go engineer working on the React side of this repo for the first time. Frame frontend explanations in terms of backend analogues.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A correctly-tuned &lt;code&gt;remember-this&lt;/code&gt; should fire once and produce something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Remembered (user, /home/you/work/this-repo):
  User is a Go engineer new to React in this repo;
  prefers frontend concepts framed as backend analogues.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify directly against the database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, kind, scope, body
FROM memory
WHERE scope = &amp;#39;/home/you/work/this-repo&amp;#39;
ORDER BY created_at DESC
LIMIT 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then start a &lt;em&gt;new&lt;/em&gt; session in the same directory. The &lt;code&gt;SessionStart&lt;/code&gt; hook should pull the row, and a question like &amp;quot;how should I structure this component?&amp;quot; should get answered in backend-analogue terms without you re-stating the preference.&lt;/p&gt;
&lt;h2&gt;What goes wrong in week one&lt;/h2&gt;
&lt;p&gt;Three failure modes, observed across the first dozen installs:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Over-firing.&lt;/strong&gt; The skill description is the only thing the harness sees. If it reads as &amp;quot;save anything interesting&amp;quot;, the agent saves chat noise. Tighten the description until the skill fires roughly once per session of real work. Re-read the &lt;em&gt;Do not&lt;/em&gt; block above — most of it exists because of an earlier over-firing rev.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Embedding cost surprise.&lt;/strong&gt; Every &lt;code&gt;/remember&lt;/code&gt; is one embedding call. At Voyage&amp;#39;s price it is fractions of a cent, but it is non-zero and it lives on the read path of every future session. If &lt;code&gt;memory&lt;/code&gt; grows past ~10k rows for a single scope, prune. A &lt;code&gt;/forget --before=30d --kind=feedback&lt;/code&gt; cleanup pass once a quarter keeps the index hot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scope confusion.&lt;/strong&gt; &lt;code&gt;scope=global&lt;/code&gt; looks tempting and is almost always wrong. A preference that holds for one project rarely holds for the next. Default to the project path; promote to global only after the same fact has been re-saved across three projects.&lt;/p&gt;
&lt;h2&gt;Why this shape&lt;/h2&gt;
&lt;p&gt;The skill is twenty-odd lines of markdown plus a thirty-line shell script. It deliberately does not include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A daemon. The skill runs synchronously on the agent&amp;#39;s turn. Latency is one HTTP roundtrip to the embedder and one to RedDB, well under a second.&lt;/li&gt;
&lt;li&gt;A queue. If the write fails, the agent sees the non-zero exit and tells the user. No silent retry, no dropped rows.&lt;/li&gt;
&lt;li&gt;An ORM. The SQL is short enough to read; the parameter binding is the only safety bit that matters.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The whole pillar&amp;#39;s argument is that a relational + vector database is a sufficient memory substrate for agent CLIs. This skill is the smallest end-to-end demonstration of that claim: one table, three files, real recall on the next session.&lt;/p&gt;
&lt;h2&gt;Next&lt;/h2&gt;
&lt;p&gt;The next post in this pillar takes the same &lt;code&gt;memory&lt;/code&gt; table and adds the observability layer — every tool call, token, and cost landing in RedDB via a &lt;code&gt;PostToolUse&lt;/code&gt; hook, queryable as &amp;quot;which skill cost me the most last sprint&amp;quot; and &amp;quot;p99 latency by tool&amp;quot;. The &lt;code&gt;remember-this&lt;/code&gt; skill becomes one row among thousands in that view; the database stops being a memory store and starts being the agent&amp;#39;s substrate.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/slash-commands-with-memory</id>
    <title>Slash commands with memory — commands that learn between sessions</title>
    <link href="https://reddb.io/blog/slash-commands-with-memory"/>
    <updated>2026-05-22</updated>
    <published>2026-05-22</published>
    <author><name>RedDB team</name></author>
    <summary>A Claude Code slash command is a markdown file with a shell expansion. Wire that shell call to RedDB and the command stops being a stateless macro — /remember, /forget, /recall become a tiny CRUD app the agent uses for itself.</summary>
    <content type="html">&lt;h2&gt;Stateless by default&lt;/h2&gt;
&lt;p&gt;A Claude Code slash command is, mechanically, a markdown file in &lt;code&gt;~/.claude/commands/&lt;/code&gt;. When the user types &lt;code&gt;/foo&lt;/code&gt;, the harness loads &lt;code&gt;foo.md&lt;/code&gt;, expands any &lt;code&gt;!shell&lt;/code&gt; lines, substitutes &lt;code&gt;$ARGUMENTS&lt;/code&gt;, and feeds the result to the model as the next user turn. That is the whole feature.&lt;/p&gt;
&lt;p&gt;Useful, but the surface is intentionally thin: a command is a &lt;em&gt;function call&lt;/em&gt;, not an &lt;em&gt;object with state&lt;/em&gt;. Two consequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Anything the command &amp;quot;knows&amp;quot; has to be encoded in its prompt or computed by its shell expansion every time it runs.&lt;/li&gt;
&lt;li&gt;Two invocations of the same command, ten minutes apart, share nothing. The harness has no slot for command-local state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For commands that do one thing — &lt;code&gt;/init&lt;/code&gt;, &lt;code&gt;/test&lt;/code&gt;, &lt;code&gt;/review&lt;/code&gt; — the statelessness is fine. For commands that should accumulate context across a project&amp;#39;s lifetime — anything in the &amp;quot;memory&amp;quot; family — it is the wrong default. A &lt;code&gt;/remember&lt;/code&gt; that forgets between sessions is just a comment.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;/blog/claude-code-memory-backend&quot;&gt;previous post in this pillar&lt;/a&gt; introduced a &lt;code&gt;memory&lt;/code&gt; table and a &lt;code&gt;SessionStart&lt;/code&gt; hook that reads top-K rows into context on boot. This post finishes the picture: three slash commands — &lt;code&gt;/remember&lt;/code&gt;, &lt;code&gt;/recall&lt;/code&gt;, &lt;code&gt;/forget&lt;/code&gt; — that turn that table into something the agent can write, query, and prune from inside a turn.&lt;/p&gt;
&lt;h2&gt;The schema, recapped&lt;/h2&gt;
&lt;p&gt;Same table as before. Repeated here so this post stands alone:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE memory (
  id          TEXT PRIMARY KEY,           -- ulid
  kind        TEXT NOT NULL,              -- user | feedback | project | reference
  scope       TEXT NOT NULL,              -- global | &amp;lt;project-path&amp;gt; | &amp;lt;session-id&amp;gt;
  body        TEXT NOT NULL,
  embedding   VECTOR(1024) NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ                  -- soft delete; /forget never DROPs
);

CREATE INDEX memory_scope_idx     ON memory (scope, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX memory_embedding_idx ON memory USING hnsw (embedding vector_cosine_ops) WHERE deleted_at IS NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two changes from the D1 post:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;deleted_at&lt;/code&gt; is nullable and exclusive in the indexes. &lt;code&gt;/forget&lt;/code&gt; flips the column; it does not &lt;code&gt;DELETE&lt;/code&gt;. Memory is auditable; deletions are recoverable.&lt;/li&gt;
&lt;li&gt;The indexes are partial. A pruned row stops costing query time the moment it is forgotten.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;code&gt;/remember&lt;/code&gt; — the write&lt;/h2&gt;
&lt;p&gt;The D1 post showed a minimal version. The production-shape one is below. Drop at &lt;code&gt;~/.claude/commands/remember.md&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
description: Persist a fact, preference, or correction into long-term memory
argument-hint: [--kind=feedback] [--scope=project|global] &amp;lt;body...&amp;gt;
---

Save the following to memory. Parse flags from `$ARGUMENTS`; default `kind=user`, `scope=project` (the current working directory).

After the write succeeds, echo back the id and kind so the user has a handle for `/forget`.

!set -euo pipefail; \
  body=&amp;quot;$ARGUMENTS&amp;quot;; \
  kind=&amp;quot;$(printf &amp;#39;%s&amp;#39; &amp;quot;$body&amp;quot; | grep -oP -- &amp;#39;--kind=\K\S+&amp;#39; || echo user)&amp;quot;; \
  scope=&amp;quot;$(printf &amp;#39;%s&amp;#39; &amp;quot;$body&amp;quot; | grep -oP -- &amp;#39;--scope=\K\S+&amp;#39; || echo &amp;quot;$PWD&amp;quot;)&amp;quot;; \
  clean=&amp;quot;$(printf &amp;#39;%s&amp;#39; &amp;quot;$body&amp;quot; | sed -E &amp;#39;s/--(kind|scope)=\S+ ?//g&amp;#39;)&amp;quot;; \
  curl -sS --fail --max-time 3 \
    -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
    -H &amp;quot;Content-Type: application/json&amp;quot; \
    &amp;quot;$REDDB_URL/memory/insert&amp;quot; \
    -d &amp;quot;$(jq -nc \
          --arg kind  &amp;quot;$kind&amp;quot; \
          --arg scope &amp;quot;$scope&amp;quot; \
          --arg body  &amp;quot;$clean&amp;quot; \
          &amp;#39;{kind:$kind, scope:$scope, body:$body}&amp;#39;)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The expansion shells out, the API computes the embedding server-side, the row commits, and the JSON comes back as part of the user turn the model sees. The model then narrates the success (&amp;quot;Saved as &lt;code&gt;mem_01H7…&lt;/code&gt; (kind=feedback)&amp;quot;) because the prompt told it to.&lt;/p&gt;
&lt;p&gt;The reason &lt;code&gt;/remember&lt;/code&gt; is one curl call and not three is the same reason &lt;code&gt;MEMORY.md&lt;/code&gt; lost: a database is transactional. Insert + embed + index either all commit or none of them do. There is no failure mode where the row is present but unembeddable.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;/recall&lt;/code&gt; — the read&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;/recall &amp;lt;query&amp;gt;&lt;/code&gt; is the symmetric command: turn a natural-language question into a vector search, return the top-K body strings, and let the model integrate them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;~/.claude/commands/recall.md&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
description: Search long-term memory for facts relevant to the current task
argument-hint: &amp;lt;natural-language query&amp;gt;
---

The user wants you to consult prior memory. The block below is the top 5 results by semantic similarity, ordered most relevant first. Treat them as established context for the rest of this turn; if any contradicts the user&amp;#39;s current message, mention the conflict before acting.

!curl -sS --fail --max-time 2 \
  -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;$REDDB_URL/memory/search&amp;quot; \
  -d &amp;quot;$(jq -nc --arg q &amp;quot;$ARGUMENTS&amp;quot; --arg scope &amp;quot;$PWD&amp;quot; \
        &amp;#39;{query:$q, scope:[$scope,&amp;quot;global&amp;quot;], top_k:5}&amp;#39;)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server-side SQL is the obvious thing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, kind, body, 1 - (embedding &amp;lt;=&amp;gt; embed($1)) AS score
FROM memory
WHERE deleted_at IS NULL
  AND scope = ANY($2)
ORDER BY embedding &amp;lt;=&amp;gt; embed($1)
LIMIT $3;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two design notes worth lingering on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;scope = ANY($2)&lt;/code&gt; makes the query naturally federated. The command passes &lt;code&gt;[$PWD, &amp;quot;global&amp;quot;]&lt;/code&gt;, so project-local memories and user-global memories are pooled into one ranked list. Cross-project memory works without extra commands.&lt;/li&gt;
&lt;li&gt;The prompt tells the model to surface contradictions. &lt;code&gt;/recall&lt;/code&gt; is not just retrieval; it is retrieval the model is expected to reason about. A memory that says &amp;quot;we never use ORM migrations on this repo&amp;quot; should make the model push back when the user asks for a migration script, not silently obey.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;code&gt;/forget&lt;/code&gt; — the prune&lt;/h2&gt;
&lt;p&gt;Two shapes, depending on how the user invokes it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/forget &amp;lt;id&amp;gt;&lt;/code&gt; — soft-delete a specific row by id, used after &lt;code&gt;/remember&lt;/code&gt; returns &lt;code&gt;mem_01H7…&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/forget &amp;lt;query&amp;gt;&lt;/code&gt; — semantic forget. Find the top match, show it to the user for confirmation, only then flip &lt;code&gt;deleted_at&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;~/.claude/commands/forget.md&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
description: Remove a memory by id, or find one by description and confirm before pruning
argument-hint: &amp;lt;id | natural-language description&amp;gt;
---

If `$ARGUMENTS` looks like a ulid (matches `^mem_[0-9A-Z]{26}$`), soft-delete it directly and report the id back.

Otherwise, search for the closest matching memory and show the user its body and id; do not delete until the user confirms with &amp;quot;yes&amp;quot; or &amp;quot;forget it&amp;quot;.

!arg=&amp;quot;$ARGUMENTS&amp;quot;; \
  if [[ &amp;quot;$arg&amp;quot; =~ ^mem_[0-9A-Z]{26}$ ]]; then \
    curl -sS --fail --max-time 2 \
      -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
      -X POST &amp;quot;$REDDB_URL/memory/$arg/forget&amp;quot;; \
  else \
    curl -sS --fail --max-time 2 \
      -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
      -H &amp;quot;Content-Type: application/json&amp;quot; \
      &amp;quot;$REDDB_URL/memory/search&amp;quot; \
      -d &amp;quot;$(jq -nc --arg q &amp;quot;$arg&amp;quot; --arg scope &amp;quot;$PWD&amp;quot; \
            &amp;#39;{query:$q, scope:[$scope,&amp;quot;global&amp;quot;], top_k:1}&amp;#39;)&amp;quot;; \
  fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two-step &amp;quot;find, confirm, delete&amp;quot; flow is the part that matters. A vector search is fuzzy by design; deleting on the first match would let &lt;code&gt;/forget vue preferences&lt;/code&gt; quietly remove a feedback row about &lt;em&gt;React&lt;/em&gt; preferences whose embedding sat slightly closer to the query than the actual Vue one. Putting the model in the loop costs one round-trip and prevents the failure entirely.&lt;/p&gt;
&lt;p&gt;The soft-delete itself is a one-line update on the API side:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE memory
SET deleted_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, kind, body;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;RETURNING&lt;/code&gt; is there so the command can echo what it pruned. If you ever want an &lt;code&gt;/unforget&lt;/code&gt;, the same row is still there — just flip &lt;code&gt;deleted_at&lt;/code&gt; back to &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;State across sessions&lt;/h2&gt;
&lt;p&gt;The three commands share nothing in the harness. They share the &lt;code&gt;memory&lt;/code&gt; table. That is what gives them coherence: writing &lt;code&gt;/remember … &amp;#39;use pnpm, never npm&amp;#39;&lt;/code&gt; in one session means a &lt;code&gt;SessionStart&lt;/code&gt; hook in the next session pulls the row into the system reminder, and the model will reach for &lt;code&gt;pnpm&lt;/code&gt; without being told twice.&lt;/p&gt;
&lt;p&gt;The lifecycle, as a sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Session A: user runs &lt;code&gt;/remember --kind=feedback &amp;#39;use pnpm, never npm&amp;#39;&lt;/code&gt;. Row commits.&lt;/li&gt;
&lt;li&gt;Session A ends.&lt;/li&gt;
&lt;li&gt;Session B boots. The &lt;code&gt;SessionStart&lt;/code&gt; hook from D1 queries &lt;code&gt;memory&lt;/code&gt; for top-K by scope, gets the pnpm row, injects it as context.&lt;/li&gt;
&lt;li&gt;User in session B asks the model to install a dependency. Model uses &lt;code&gt;pnpm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Session C, six weeks later: the rule has stopped applying. User runs &lt;code&gt;/forget pnpm preference&lt;/code&gt;. &lt;code&gt;/forget&lt;/code&gt; finds the row, the model shows it, the user confirms. Row&amp;#39;s &lt;code&gt;deleted_at&lt;/code&gt; flips.&lt;/li&gt;
&lt;li&gt;Session D: the row is no longer in the partial indexes; the model has no record of it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each step is independent, each step is durable, and the only thing that crosses the boundary between sessions is the table.&lt;/p&gt;
&lt;h2&gt;What this buys, in one table&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;th&gt;Stateless command&lt;/th&gt;
&lt;th&gt;Command + &lt;code&gt;memory&lt;/code&gt; table&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Persist a fact from a turn&lt;/td&gt;
&lt;td&gt;impossible&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/remember&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read prior facts in a future turn&lt;/td&gt;
&lt;td&gt;re-read &lt;code&gt;MEMORY.md&lt;/code&gt; blindly&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/recall&lt;/code&gt; ranks by similarity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prune a stale fact&lt;/td&gt;
&lt;td&gt;edit a markdown file by hand&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/forget&lt;/code&gt; with confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-project recall&lt;/td&gt;
&lt;td&gt;symlink markdown files&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scope=ANY([project, &amp;quot;global&amp;quot;])&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit who/when/why&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git blame&lt;/code&gt; if committed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt; + &lt;code&gt;deleted_at&lt;/code&gt; are columns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The three command files together are about 40 lines of markdown. The SQL is four statements. The state is one table. Compared to the workaround of &amp;quot;edit a markdown file and hope the model re-reads it,&amp;quot; it is hard to overstate how much smaller the surface gets when the slash command is a UI over a database instead of a macro.&lt;/p&gt;
&lt;h2&gt;What is next in this pillar&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;An MCP server that exposes &lt;code&gt;memory.write&lt;/code&gt; / &lt;code&gt;memory.search&lt;/code&gt; / &lt;code&gt;memory.forget&lt;/code&gt; as MCP tools, so every MCP-compatible CLI (Cursor, Codex, Gemini CLI) writes into the same table Claude Code&amp;#39;s slash commands do.&lt;/li&gt;
&lt;li&gt;A tutorial on building a full &lt;code&gt;remember-this&lt;/code&gt; skill that captures interesting conversation snippets automatically — same table, different write path.&lt;/li&gt;
&lt;li&gt;The context-window-economics post: how much does swapping &lt;code&gt;MEMORY.md&lt;/code&gt; for a &lt;code&gt;/recall&lt;/code&gt; actually save, per turn, in tokens and dollars?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want to be told when those land, grab the &lt;a href=&quot;/blog/rss.xml&quot;&gt;Atom feed&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/hooks-transactional-audit</id>
    <title>Hooks that mutate state — making Claude Code hooks transactional with RedDB</title>
    <link href="https://reddb.io/blog/hooks-transactional-audit"/>
    <updated>2026-05-20</updated>
    <published>2026-05-20</published>
    <author><name>RedDB team</name></author>
    <summary>A PostToolUse audit-log hook looks trivial until the agent crashes mid-turn, the network blips, or Slack 503s. Wrap the side effects in a RedDB transaction with idempotency keys and a saga for the external call, and the hook stays honest.</summary>
    <content type="html">&lt;h2&gt;What a hook actually does&lt;/h2&gt;
&lt;p&gt;Claude Code fires a handful of events at the shell — &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;, a few more — and lets you bind each one to a command. The pattern most teams reach for first is an audit log: every tool call gets a row somewhere, so you can later answer &amp;quot;what did the agent touch on this branch?&amp;quot; without re-reading the transcript.&lt;/p&gt;
&lt;p&gt;Sketched as a one-liner, the hook looks fine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
payload=&amp;quot;$(cat)&amp;quot;
curl -sS &amp;quot;$REDDB_URL/audit/insert&amp;quot; -d &amp;quot;$payload&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Production-fine it is not. Three failure modes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The hook is killed mid-flight.&lt;/strong&gt; The agent crashed, the user hit Ctrl-C, the laptop slept. The row may or may not have been written; nothing in the system knows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The harness retries.&lt;/strong&gt; Claude Code does not today, but a queue in front of the hook (k8s, systemd, your own wrapper) will. Two identical inserts arrive and you have phantom audit rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The hook fans out to a second system.&lt;/strong&gt; Post the same audit line to Slack, write a Jira ticket, push to Datadog — and now you have a distributed transaction with no coordinator.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A single RedDB transaction solves (1) and (2). A saga solves (3). The combined recipe fits in one hook script.&lt;/p&gt;
&lt;h2&gt;The schema&lt;/h2&gt;
&lt;p&gt;One table for the audit row, one table for the idempotency keys, one table for the saga state. All three are append-only except &lt;code&gt;saga.status&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE audit_event (
  id            TEXT PRIMARY KEY,                -- ulid, generated client-side
  session_id    TEXT NOT NULL,
  hook_event    TEXT NOT NULL,                   -- PostToolUse, Stop, …
  tool_name     TEXT,                            -- Read, Bash, Skill, …
  tool_input    JSONB NOT NULL,
  is_error      BOOLEAN NOT NULL DEFAULT false,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE hook_idempotency (
  key           TEXT PRIMARY KEY,                -- hash(session_id + tool_use_id)
  event_id      TEXT NOT NULL REFERENCES audit_event(id),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE saga (
  id            TEXT PRIMARY KEY,                -- ulid
  event_id      TEXT NOT NULL REFERENCES audit_event(id),
  step          TEXT NOT NULL,                   -- &amp;#39;slack&amp;#39;, &amp;#39;datadog&amp;#39;, …
  status        TEXT NOT NULL,                   -- pending | done | failed | compensated
  attempt       INTEGER NOT NULL DEFAULT 0,
  next_run_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  payload       JSONB NOT NULL,
  last_error    TEXT
);

CREATE INDEX audit_session_idx ON audit_event (session_id, created_at DESC);
CREATE INDEX saga_pending_idx  ON saga (status, next_run_at) WHERE status = &amp;#39;pending&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;hook_idempotency.key&lt;/code&gt; is what makes the hook safe to retry. The Claude Code payload includes a &lt;code&gt;tool_use_id&lt;/code&gt; per call; the hash of &lt;code&gt;session_id + tool_use_id&lt;/code&gt; is unique per invocation and stable across retries. Two hook executions with the same key see a PK violation on the second insert and bail out cleanly.&lt;/p&gt;
&lt;h2&gt;The transactional insert&lt;/h2&gt;
&lt;p&gt;RedDB exposes single-statement transactions via a &lt;code&gt;/tx&lt;/code&gt; endpoint that accepts an array of SQL statements and applies them atomically. The hook posts both rows in one call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# ~/.claude/hooks/audit-log.sh
set -euo pipefail

payload=&amp;quot;$(cat)&amp;quot;

session_id=&amp;quot;$(jq -r &amp;#39;.session_id&amp;#39;                &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
hook_event=&amp;quot;$(jq -r &amp;#39;.hook_event_name&amp;#39;           &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
tool_use_id=&amp;quot;$(jq -r &amp;#39;.tool_use_id // .id // &amp;quot;&amp;quot;&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
tool_name=&amp;quot;$(jq  -r &amp;#39;.tool_name // &amp;quot;&amp;quot;&amp;#39;           &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
tool_input=&amp;quot;$(jq  &amp;#39;.tool_input // {}&amp;#39;            &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
is_error=&amp;quot;$(jq  -r &amp;#39;.is_error // false&amp;#39;          &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;

event_id=&amp;quot;$(ulid)&amp;quot;
idem_key=&amp;quot;$(printf &amp;#39;%s:%s&amp;#39; &amp;quot;$session_id&amp;quot; &amp;quot;$tool_use_id&amp;quot; | sha256sum | awk &amp;#39;{print $1}&amp;#39;)&amp;quot;

curl -sS --fail --max-time 1 \
  -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;$REDDB_URL/tx&amp;quot; \
  -d &amp;quot;$(jq -nc \
        --arg eid       &amp;quot;$event_id&amp;quot; \
        --arg sid       &amp;quot;$session_id&amp;quot; \
        --arg ev        &amp;quot;$hook_event&amp;quot; \
        --arg tname     &amp;quot;$tool_name&amp;quot; \
        --argjson tin   &amp;quot;$tool_input&amp;quot; \
        --argjson err   &amp;quot;$is_error&amp;quot; \
        --arg ikey      &amp;quot;$idem_key&amp;quot; \
        &amp;#39;{
          statements: [
            { sql: &amp;quot;INSERT INTO audit_event (id, session_id, hook_event, tool_name, tool_input, is_error) VALUES ($1,$2,$3,$4,$5,$6)&amp;quot;,
              args: [$eid, $sid, $ev, $tname, $tin, $err] },
            { sql: &amp;quot;INSERT INTO hook_idempotency (key, event_id) VALUES ($1,$2) ON CONFLICT (key) DO NOTHING&amp;quot;,
              args: [$ikey, $eid] }
          ]
        }&amp;#39;)&amp;quot; &amp;gt;/dev/null || true

echo &amp;#39;{&amp;quot;continue&amp;quot;:true}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details earn their lines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The two &lt;code&gt;INSERT&lt;/code&gt;s share one transaction. Either both land or neither does — no orphaned &lt;code&gt;audit_event&lt;/code&gt; row pointing at no idempotency key, and no key pointing at a non-existent event.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ON CONFLICT (key) DO NOTHING&lt;/code&gt; makes the hook re-entrant. A retried call inserts the &lt;code&gt;audit_event&lt;/code&gt; row, sees the idempotency conflict, and the whole transaction rolls back. The earlier row stays as the canonical one.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--max-time 1&lt;/code&gt; plus &lt;code&gt;|| true&lt;/code&gt;. Audit logging must never block the agent. If RedDB is down for a second, the hook drops that one event rather than stalling the turn. The reason this is acceptable is that the next subsection picks the dropped event back up.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your environment cannot tolerate even a dropped event, the cure is to write to a local SQLite WAL first and let a background process drain it into RedDB. That moves the durability boundary one hop closer to the agent at the cost of a second store.&lt;/p&gt;
&lt;h2&gt;Idempotency, demonstrated&lt;/h2&gt;
&lt;p&gt;A quick way to convince yourself the recipe is correct is to invoke the hook twice with the same payload and check the count:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ echo &amp;quot;$payload&amp;quot; | ~/.claude/hooks/audit-log.sh
$ echo &amp;quot;$payload&amp;quot; | ~/.claude/hooks/audit-log.sh

$ psql -c &amp;quot;SELECT count(*) FROM audit_event
           WHERE id IN (
             SELECT event_id FROM hook_idempotency
             WHERE key = &amp;#39;$(printf %s &amp;quot;$session_id:$tool_use_id&amp;quot; | sha256sum | awk &amp;quot;{print \$1}&amp;quot;)&amp;#39;
           )&amp;quot;
 count
-------
     1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One row, not two. The second hook&amp;#39;s transaction was rolled back by the idempotency conflict.&lt;/p&gt;
&lt;h2&gt;The saga: fanning out to external systems&lt;/h2&gt;
&lt;p&gt;The audit row is durable now. What about the Slack post that some teams want for every error tool call? You cannot put a Slack &lt;code&gt;POST&lt;/code&gt; inside the RedDB transaction — different system, no two-phase commit. The standard answer is a saga: record the &lt;em&gt;intent&lt;/em&gt; atomically with the audit row, run the external call separately, mark the saga step &lt;code&gt;done&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;, and let a compensator deal with permanent failures.&lt;/p&gt;
&lt;p&gt;Extend the transaction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsonc&quot;&gt;{
  &amp;quot;statements&amp;quot;: [
    { &amp;quot;sql&amp;quot;: &amp;quot;INSERT INTO audit_event …&amp;quot;, &amp;quot;args&amp;quot;: [/* … */] },
    { &amp;quot;sql&amp;quot;: &amp;quot;INSERT INTO hook_idempotency …&amp;quot;, &amp;quot;args&amp;quot;: [/* … */] },
    { &amp;quot;sql&amp;quot;: &amp;quot;INSERT INTO saga (id, event_id, step, status, payload) VALUES ($1, $2, &amp;#39;slack&amp;#39;, &amp;#39;pending&amp;#39;, $3)&amp;quot;,
      &amp;quot;args&amp;quot;: [/* saga_id */, /* event_id */, /* slack payload */] }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the audit row, the idempotency key, and the saga step land together or not at all. A separate worker drains the saga table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- The worker pulls one pending step at a time with row-level locking
-- so multiple workers can run safely.
BEGIN;

SELECT id, step, payload, attempt
  FROM saga
 WHERE status = &amp;#39;pending&amp;#39;
   AND next_run_at &amp;lt;= now()
 ORDER BY next_run_at
 LIMIT 1
   FOR UPDATE SKIP LOCKED;

-- worker runs the step…

-- On success:
UPDATE saga SET status = &amp;#39;done&amp;#39; WHERE id = $1;

-- On retryable failure (network blip, 5xx):
UPDATE saga
   SET attempt = attempt + 1,
       next_run_at = now() + (interval &amp;#39;1 second&amp;#39; * power(2, attempt)),
       last_error = $2
 WHERE id = $1;

-- On terminal failure (e.g. 4xx after N attempts):
UPDATE saga SET status = &amp;#39;failed&amp;#39;, last_error = $2 WHERE id = $1;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is the load-bearing piece. It turns the table into a work queue without a second piece of infrastructure. The worker holds a lock on the row it picked, every other worker skips that row, and the exponential backoff (&lt;code&gt;power(2, attempt)&lt;/code&gt;) limits the damage a flapping downstream can do.&lt;/p&gt;
&lt;p&gt;What about the compensator? If a Slack post fails terminally, the audit row should not be deleted — the tool call really did happen. The compensation is to write a &lt;em&gt;second&lt;/em&gt; audit row noting the failed notification, which is itself a transactional insert:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO audit_event (id, session_id, hook_event, tool_name, tool_input)
VALUES (ulid(), $1, &amp;#39;SagaFailed&amp;#39;, &amp;#39;slack&amp;#39;,
        jsonb_build_object(&amp;#39;original_event&amp;#39;, $2, &amp;#39;reason&amp;#39;, $3));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The invariant the saga upholds is &lt;em&gt;no silent loss&lt;/em&gt;. Either the external system gets the message, or there is a permanent record of why it did not.&lt;/p&gt;
&lt;h2&gt;What this buys you&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Naïve hook&lt;/th&gt;
&lt;th&gt;Transactional hook&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Hook killed mid-write&lt;/td&gt;
&lt;td&gt;Partial state, no record&lt;/td&gt;
&lt;td&gt;Either fully applied or nothing at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Harness retries&lt;/td&gt;
&lt;td&gt;Duplicate rows&lt;/td&gt;
&lt;td&gt;Idempotency PK rejects the second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RedDB momentarily unreachable&lt;/td&gt;
&lt;td&gt;Lost row, no log&lt;/td&gt;
&lt;td&gt;Lost row, logged in stderr; replay job picks it up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack/Jira/Datadog 503&lt;/td&gt;
&lt;td&gt;Hook hangs the turn, or row missing&lt;/td&gt;
&lt;td&gt;Saga step retried with backoff out-of-band&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack 401 (permanently broken)&lt;/td&gt;
&lt;td&gt;Silent failure&lt;/td&gt;
&lt;td&gt;&lt;code&gt;saga.status = &amp;#39;failed&amp;#39;&lt;/code&gt; + compensating audit row&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The hook script grew from one &lt;code&gt;curl&lt;/code&gt; to about thirty lines. The schema grew by two tables. In exchange the audit log becomes the thing it claims to be — a trustworthy record of every tool call, with a clean story for every failure mode the network and the harness can throw at it.&lt;/p&gt;
&lt;h2&gt;What is next in this pillar&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;/remember&lt;/code&gt;-shaped slash command that uses the same idempotency table so re-issuing a memory write does not create duplicate rows.&lt;/li&gt;
&lt;li&gt;Cross-session task queues built on the same &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; pattern, so AFK agents can resume from a checkpoint after a crash.&lt;/li&gt;
&lt;li&gt;An MCP server that exposes &lt;code&gt;audit.*&lt;/code&gt; to every MCP-compatible CLI, so the same transactional guarantees apply when Cursor or Codex writes through.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a href=&quot;/blog/rss.xml&quot;&gt;Atom feed&lt;/a&gt; is the way to be told when those land.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/skills-as-data</id>
    <title>Skills as data — storing metadata, runs, and learned refinements in RedDB</title>
    <link href="https://reddb.io/blog/skills-as-data"/>
    <updated>2026-05-18</updated>
    <published>2026-05-18</published>
    <author><name>RedDB team</name></author>
    <summary>Claude Code skills are static SKILL.md files today. Promote them to first-class data — metadata, execution traces, success rates — and the agent can pick the right skill for a new task with a SQL query instead of a keyword-match heuristic.</summary>
    <content type="html">&lt;h2&gt;The flat-file problem, take two&lt;/h2&gt;
&lt;p&gt;The previous post in this pillar made the case for putting agent memory in RedDB instead of &lt;code&gt;MEMORY.md&lt;/code&gt;. The same argument applies to skills, and the failure modes rhyme.&lt;/p&gt;
&lt;p&gt;A Claude Code skill lives at &lt;code&gt;~/.claude/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt;. Its frontmatter declares a &lt;code&gt;name&lt;/code&gt; and a &lt;code&gt;description&lt;/code&gt;, and the harness uses the description as a coarse-grained trigger — when the user says something that matches the description, the skill is suggested. A typical install has dozens of skills (&lt;code&gt;tdd&lt;/code&gt;, &lt;code&gt;harden&lt;/code&gt;, &lt;code&gt;optimize&lt;/code&gt;, &lt;code&gt;caveman&lt;/code&gt;, &lt;code&gt;huashu-design&lt;/code&gt;, …) and the trigger logic is plain prompt engineering: &amp;quot;here are the skills, here are their descriptions, pick one.&amp;quot;&lt;/p&gt;
&lt;p&gt;Three things that approach cannot do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rank by past success.&lt;/strong&gt; If &lt;code&gt;optimize&lt;/code&gt; produces good output 9 times out of 10 on Vue work and &lt;code&gt;polish&lt;/code&gt; only 3 of 10, the harness has no way to know.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compose.&lt;/strong&gt; Two skills that frequently run in sequence (&lt;code&gt;tdd&lt;/code&gt; → &lt;code&gt;code-review-and-quality&lt;/code&gt;) are not linked anywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Improve over time.&lt;/strong&gt; A skill that failed because its trigger description was too vague does not get fixed by the system that observed the failure.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Treat skills as rows in a database and all three become normal queries.&lt;/p&gt;
&lt;h2&gt;Schema&lt;/h2&gt;
&lt;p&gt;Four tables. Names match what Claude Code already exposes so the mapping is obvious.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- One row per installed skill. Mirrors the SKILL.md frontmatter.
CREATE TABLE skill (
  id            TEXT PRIMARY KEY,             -- e.g. &amp;quot;tdd&amp;quot;
  version       TEXT NOT NULL,                -- semver of the SKILL.md
  description   TEXT NOT NULL,                -- the frontmatter description
  triggers      TEXT[] NOT NULL DEFAULT &amp;#39;{}&amp;#39;, -- keywords / patterns
  embedding     VECTOR(1024) NOT NULL,        -- embedded description+triggers
  installed_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- One row per invocation. Append-only.
CREATE TABLE skill_run (
  id          TEXT PRIMARY KEY,               -- ulid
  skill_id    TEXT NOT NULL REFERENCES skill(id),
  session_id  TEXT NOT NULL,
  args        JSONB NOT NULL,                 -- the task the skill received
  started_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  ended_at    TIMESTAMPTZ,
  outcome     TEXT,                           -- success | failed | abandoned
  notes       TEXT                            -- model&amp;#39;s post-run reflection
);

-- Learned refinements. Updated whenever a run produces a new lesson.
CREATE TABLE skill_lesson (
  id          TEXT PRIMARY KEY,
  skill_id    TEXT NOT NULL REFERENCES skill(id),
  pattern     TEXT NOT NULL,                  -- &amp;quot;when args.lang=rust, prefer X&amp;quot;
  weight      REAL NOT NULL DEFAULT 1.0,
  embedding   VECTOR(1024) NOT NULL,
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Edges between skills that ran in sequence within one session.
CREATE TABLE skill_edge (
  from_skill  TEXT NOT NULL REFERENCES skill(id),
  to_skill    TEXT NOT NULL REFERENCES skill(id),
  count       INTEGER NOT NULL DEFAULT 1,
  PRIMARY KEY (from_skill, to_skill)
);

CREATE INDEX skill_run_skill_idx ON skill_run (skill_id, started_at DESC);
CREATE INDEX skill_embedding_idx ON skill USING hnsw (embedding vector_cosine_ops);
CREATE INDEX skill_lesson_embedding_idx ON skill_lesson USING hnsw (embedding vector_cosine_ops);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The four tables cover the three things the flat-file world cannot: &lt;code&gt;skill_run&lt;/code&gt; for ranking by outcome, &lt;code&gt;skill_edge&lt;/code&gt; for composition, &lt;code&gt;skill_lesson&lt;/code&gt; for refinements.&lt;/p&gt;
&lt;h2&gt;The fit query&lt;/h2&gt;
&lt;p&gt;This is where the database earns its keep. Given a new task description, return the top-K skills ranked by a blend of &lt;em&gt;semantic fit&lt;/em&gt; and &lt;em&gt;past success rate&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH q AS (SELECT embed($1) AS v),
stats AS (
  SELECT
    skill_id,
    count(*) FILTER (WHERE outcome = &amp;#39;success&amp;#39;)::float
      / NULLIF(count(*), 0)                              AS success_rate,
    count(*)                                             AS runs
  FROM skill_run
  WHERE started_at &amp;gt; now() - interval &amp;#39;90 days&amp;#39;
  GROUP BY skill_id
)
SELECT
  s.id,
  s.description,
  1 - (s.embedding &amp;lt;=&amp;gt; q.v)                              AS fit,
  COALESCE(st.success_rate, 0.5)                         AS success_rate,
  COALESCE(st.runs, 0)                                   AS runs,
  -- blend: fit dominates when runs are low, success rate matters when we have data.
  (1 - (s.embedding &amp;lt;=&amp;gt; q.v)) * 0.6
    + COALESCE(st.success_rate, 0.5) * 0.4               AS score
FROM skill s, q
LEFT JOIN stats st ON st.skill_id = s.id
ORDER BY score DESC
LIMIT $2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notes worth reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;COALESCE(success_rate, 0.5)&lt;/code&gt; — a skill with no history gets a neutral prior, so a new skill is not punished for being new.&lt;/li&gt;
&lt;li&gt;The 60/40 blend is a knob. If the install is brand-new, push it to 80/20 toward semantic fit. After a few hundred runs, slide it to 40/60.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;interval &amp;#39;90 days&amp;#39;&lt;/code&gt; decays the relevance of ancient runs without explicit pruning.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Wiring it into Claude Code&lt;/h2&gt;
&lt;p&gt;The harness fires three events that are useful here: &lt;code&gt;SessionStart&lt;/code&gt; (boot), &lt;code&gt;PreToolUse&lt;/code&gt;/&lt;code&gt;PostToolUse&lt;/code&gt; (every tool call, including the &lt;code&gt;Skill&lt;/code&gt; tool), and &lt;code&gt;Stop&lt;/code&gt; (turn end). One hook per event is enough.&lt;/p&gt;
&lt;h3&gt;1. Record installations on &lt;code&gt;SessionStart&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;A short bash hook syncs installed skills into the &lt;code&gt;skill&lt;/code&gt; table whenever a session boots. Drop it at &lt;code&gt;~/.claude/hooks/skills-sync.sh&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
set -euo pipefail

for dir in ~/.claude/skills/*/; do
  name=&amp;quot;$(basename &amp;quot;$dir&amp;quot;)&amp;quot;
  meta=&amp;quot;$dir/SKILL.md&amp;quot;
  [[ -f &amp;quot;$meta&amp;quot; ]] || continue

  # Parse frontmatter (description, version) with yq.
  desc=&amp;quot;$(yq -r &amp;#39;.description // &amp;quot;&amp;quot;&amp;#39; &amp;quot;$meta&amp;quot;)&amp;quot;
  ver=&amp;quot;$(yq -r &amp;#39;.version // &amp;quot;0.0.0&amp;quot;&amp;#39; &amp;quot;$meta&amp;quot;)&amp;quot;

  curl -sS --fail --max-time 2 \
    -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
    -H &amp;quot;Content-Type: application/json&amp;quot; \
    &amp;quot;$REDDB_URL/skill/upsert&amp;quot; \
    -d &amp;quot;$(jq -nc \
          --arg id &amp;quot;$name&amp;quot; \
          --arg ver &amp;quot;$ver&amp;quot; \
          --arg desc &amp;quot;$desc&amp;quot; \
          &amp;#39;{id:$id, version:$ver, description:$desc}&amp;#39;)&amp;quot; &amp;gt;/dev/null
done

# Return empty hookSpecificOutput — this hook is a side effect.
echo &amp;#39;{&amp;quot;hookSpecificOutput&amp;quot;:{&amp;quot;hookEventName&amp;quot;:&amp;quot;SessionStart&amp;quot;,&amp;quot;additionalContext&amp;quot;:&amp;quot;&amp;quot;}}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RedDB computes the embedding server-side at upsert time, so the hook stays one curl call per skill. Two seconds is enough budget for a few dozen skills; if you have hundreds, switch to a single bulk POST.&lt;/p&gt;
&lt;h3&gt;2. Log every run via &lt;code&gt;PostToolUse&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;The Claude Code harness emits a &lt;code&gt;PostToolUse&lt;/code&gt; event for every tool call. The payload includes the tool name and its arguments. Match on &lt;code&gt;Skill&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;hooks&amp;quot;: {
    &amp;quot;PostToolUse&amp;quot;: [
      {
        &amp;quot;matcher&amp;quot;: &amp;quot;Skill&amp;quot;,
        &amp;quot;hooks&amp;quot;: [
          { &amp;quot;type&amp;quot;: &amp;quot;command&amp;quot;, &amp;quot;command&amp;quot;: &amp;quot;~/.claude/hooks/skill-run-log.sh&amp;quot; }
        ]
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# ~/.claude/hooks/skill-run-log.sh
set -euo pipefail

# The harness pipes the tool-use payload as JSON on stdin.
payload=&amp;quot;$(cat)&amp;quot;
skill_id=&amp;quot;$(jq -r &amp;#39;.tool_input.skill&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
session_id=&amp;quot;$(jq -r &amp;#39;.session_id&amp;#39;    &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
args=&amp;quot;$(jq    &amp;#39;.tool_input.args // {}&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;
outcome=&amp;quot;$(jq -r &amp;#39;if .is_error then &amp;quot;failed&amp;quot; else &amp;quot;success&amp;quot; end&amp;#39; &amp;lt;&amp;lt;&amp;lt;&amp;quot;$payload&amp;quot;)&amp;quot;

curl -sS --fail --max-time 1 \
  -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;$REDDB_URL/skill_run/insert&amp;quot; \
  -d &amp;quot;$(jq -nc \
        --arg sid &amp;quot;$skill_id&amp;quot; \
        --arg ses &amp;quot;$session_id&amp;quot; \
        --argjson args &amp;quot;$args&amp;quot; \
        --arg outcome &amp;quot;$outcome&amp;quot; \
        &amp;#39;{skill_id:$sid, session_id:$ses, args:$args, outcome:$outcome}&amp;#39;)&amp;quot; \
  &amp;gt;/dev/null || true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trailing &lt;code&gt;|| true&lt;/code&gt; is deliberate: a logging hook must not block the agent if RedDB is briefly unreachable. The &lt;code&gt;--max-time 1&lt;/code&gt; budget keeps the hook invisible to the user.&lt;/p&gt;
&lt;h3&gt;3. Build skill edges in a &lt;code&gt;Stop&lt;/code&gt; hook&lt;/h3&gt;
&lt;p&gt;When the turn ends, look at the ordered list of skill runs in this session and bump &lt;code&gt;skill_edge.count&lt;/code&gt; for each adjacent pair. A single SQL statement on the API side handles the upsert:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO skill_edge (from_skill, to_skill, count)
VALUES ($1, $2, 1)
ON CONFLICT (from_skill, to_skill)
DO UPDATE SET count = skill_edge.count + 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After a week the &lt;code&gt;skill_edge&lt;/code&gt; table is a usable Markov chain: given the user just ran &lt;code&gt;tdd&lt;/code&gt;, what comes next? &lt;code&gt;code-review-and-quality&lt;/code&gt; with weight 0.6, &lt;code&gt;git-workflow-and-versioning&lt;/code&gt; with 0.3.&lt;/p&gt;
&lt;h2&gt;A &lt;code&gt;/suggest-skill&lt;/code&gt; slash command&lt;/h2&gt;
&lt;p&gt;Put the fit query behind a slash command and the model can call it directly:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;~/.claude/commands/suggest-skill.md&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
description: Suggest the best-fitting skills for a task using past run data
argument-hint: &amp;lt;task description...&amp;gt;
---

Here are the top 5 skills ranked by semantic fit blended with 90-day success rate. Pick one and invoke it — do not ask the user to confirm if the top score is above 0.75.

!curl -sS --fail \
  -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;$REDDB_URL/skill/suggest&amp;quot; \
  -d &amp;quot;$(jq -nc --arg q &amp;quot;$*&amp;quot; &amp;#39;{query:$q, top_k:5}&amp;#39;)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Usage in a session:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/suggest-skill the alignment between cards in the pricing grid feels off on mobile
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model gets a JSON list back, reads the top entry (probably &lt;code&gt;polish&lt;/code&gt; or &lt;code&gt;frontend-design&lt;/code&gt;), and invokes it without a round-trip. The keyword &amp;quot;alignment&amp;quot; did not have to be in any skill&amp;#39;s static description — the embedding does the work.&lt;/p&gt;
&lt;h2&gt;Learned refinements&lt;/h2&gt;
&lt;p&gt;Skills get better when the system that runs them remembers which patterns worked. Add a &lt;code&gt;Stop&lt;/code&gt; hook that asks the model itself to write a one-line lesson when a run was particularly good or bad:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Pseudo: read last skill_run for this session, if outcome=success
# and notes is non-empty, write a skill_lesson row.
curl -sS \
  -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;$REDDB_URL/skill_lesson/upsert&amp;quot; \
  -d &amp;quot;$(jq -nc \
        --arg sid &amp;quot;$skill_id&amp;quot; \
        --arg pat &amp;quot;$lesson&amp;quot; \
        &amp;#39;{skill_id:$sid, pattern:$pat}&amp;#39;)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At suggestion time, join &lt;code&gt;skill_lesson&lt;/code&gt; into the fit query and surface the top-1 lesson alongside the skill name:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT s.id,
       s.description,
       (SELECT pattern FROM skill_lesson l
        WHERE l.skill_id = s.id
        ORDER BY l.embedding &amp;lt;=&amp;gt; q.v ASC
        LIMIT 1)                                       AS hint,
       …
FROM skill s, q
…
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;code&gt;/suggest-skill&lt;/code&gt; returns not just &lt;em&gt;which&lt;/em&gt; skill, but &lt;em&gt;which lesson&lt;/em&gt; applies. A skill that learned &amp;quot;when args.lang=rust prefer &lt;code&gt;cargo nextest&lt;/code&gt; over &lt;code&gt;cargo test&lt;/code&gt;&amp;quot; feeds that hint back to the model before it invokes.&lt;/p&gt;
&lt;h2&gt;What you get&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Flat-file world&lt;/th&gt;
&lt;th&gt;Skills-as-data&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Which skill fits this task?&lt;/td&gt;
&lt;td&gt;string match on description&lt;/td&gt;
&lt;td&gt;vector search blended with success rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which skills compose well?&lt;/td&gt;
&lt;td&gt;folklore&lt;/td&gt;
&lt;td&gt;&lt;code&gt;skill_edge&lt;/code&gt; Markov chain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which skill is unreliable lately?&lt;/td&gt;
&lt;td&gt;feel&lt;/td&gt;
&lt;td&gt;&lt;code&gt;success_rate &amp;lt; 0.5 AND runs &amp;gt; 20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why did &lt;code&gt;optimize&lt;/code&gt; fail last Tuesday?&lt;/td&gt;
&lt;td&gt;not recorded&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT … FROM skill_run WHERE …&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Total wiring: four tables, three hooks, one slash command, one API. The skills themselves do not change — they are still SKILL.md files on disk. The harness still reads them. The database sits beside the harness and turns every invocation into a row, every row into ranking signal, and every ranking signal into a better suggestion the next time around.&lt;/p&gt;
&lt;h2&gt;What is next in this pillar&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A walkthrough of building a &lt;code&gt;remember-this&lt;/code&gt; skill end-to-end in 30 minutes — same shape, applied to conversation snippets.&lt;/li&gt;
&lt;li&gt;Hooks that mutate persistent state, made transactional so a half-finished &lt;code&gt;skill_run&lt;/code&gt; insert does not leave you with a phantom row when the agent crashes mid-turn.&lt;/li&gt;
&lt;li&gt;An MCP server that exposes &lt;code&gt;skill.*&lt;/code&gt; and &lt;code&gt;memory.*&lt;/code&gt; to every MCP-compatible CLI, so Cursor and Codex can read the same tables Claude Code writes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want to be told when those land, grab the &lt;a href=&quot;/blog/rss.xml&quot;&gt;Atom feed&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/agent-sdk-on-reddb-custom-agents</id>
    <title>Agent SDK on RedDB: building custom agents with native persistence</title>
    <link href="https://reddb.io/blog/agent-sdk-on-reddb-custom-agents"/>
    <updated>2026-05-17</updated>
    <published>2026-05-17</published>
    <author><name>RedDB team</name></author>
    <summary>A walk through the four surfaces of a small Agent SDK — tool registry, memory adapter, conversation log, eval hooks — backed by RedDB instead of a stack of services. Ends with a working code-review bot in under 300 lines that remembers what it has reviewed, per repository.</summary>
    <content type="html">&lt;p&gt;If you have built an agent on top of a hosted CLI like Claude Code, you have probably hit the wall where the wrapper stops fitting. The conversation log is somebody else&amp;#39;s schema. The tool registry is a JSON file in a config directory. The memory is a markdown file the host parses on startup. The eval hooks, if they exist, run after the fact and write to a separate store. It works until you want to ask &amp;quot;what is the second-most-common false positive my review bot has produced on this repo over the last 90 days,&amp;quot; at which point you discover the data lives in four places and none of them join.&lt;/p&gt;
&lt;p&gt;This post is about the alternative: a small, opinionated Agent SDK whose four surfaces all sit on a single RedDB instance. Not a framework — there is no scheduler, no agent-of-agents abstraction, no DSL. Just the four pieces every agent project ends up reimplementing badly, given names and a storage contract. The point is that once those four surfaces share a database, the queries that used to require glue code become one statement.&lt;/p&gt;
&lt;p&gt;We end with a code-review bot. Under 300 lines, including the SDK plumbing, that remembers every review it has ever produced per repository, learns from human overrides, and lets you ask &amp;quot;what did I get wrong&amp;quot; in SQL.&lt;/p&gt;
&lt;h2&gt;The four surfaces&lt;/h2&gt;
&lt;p&gt;The SDK exposes exactly four things to the agent author. Adding a fifth is the moment you should reach for a framework instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tool registry.&lt;/strong&gt; A typed catalogue of the tools the agent can call. Tools are rows, not files. Each row stores the JSON schema for arguments, the handler reference, the rate-limit budget, and a hash of the implementation so the eval layer can tell when a tool&amp;#39;s behaviour changed underneath an old eval run.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memory adapter.&lt;/strong&gt; The agent&amp;#39;s read/write surface for facts it wants to remember. The SDK does not assume a single shape of memory — episodic, semantic, and procedural memory each have their own table, with the access patterns described in &lt;a href=&quot;/blog/agent-memory-layer-on-reddb&quot;&gt;Building an agent memory layer on RedDB&lt;/a&gt;. The adapter is a thin facade that hides which table a &lt;code&gt;remember(...)&lt;/code&gt; call lands in, based on the kind of fact.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conversation log.&lt;/strong&gt; Append-only stream of every turn: user input, model output, tool call, tool result, error. Indexed by conversation id, by time, and (for the entries with embedded content) by vector. The log is the source of truth — everything else, including the memory tables, is materialised from it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Eval hooks.&lt;/strong&gt; Two callbacks: &lt;code&gt;onTurnComplete(turn)&lt;/code&gt; and &lt;code&gt;onConversationComplete(conv)&lt;/code&gt;. The SDK does not run evals; it gives the eval author a deterministic place to write feedback rows that join back to the conversation. Evals run as separate workers reading from the same database. There is no &amp;quot;send results to the eval service&amp;quot; step — the results are already there.&lt;/p&gt;
&lt;p&gt;Four surfaces, one database, no service mesh.&lt;/p&gt;
&lt;h2&gt;The storage contract&lt;/h2&gt;
&lt;p&gt;The SDK ships with a migration that creates five tables. Names matter — the queries in the rest of this post and in the code-review bot below reference them.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 1. The conversation log. Append-only.
CREATE TABLE agent_turns (
  id            uuid PRIMARY KEY,
  conversation  uuid NOT NULL,
  agent         text NOT NULL,
  kind          text NOT NULL,  -- &amp;#39;user&amp;#39; | &amp;#39;model&amp;#39; | &amp;#39;tool_call&amp;#39; | &amp;#39;tool_result&amp;#39; | &amp;#39;error&amp;#39;
  payload       jsonb NOT NULL,
  embedding     vector(1024),
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX agent_turns_conv_time ON agent_turns (conversation, created_at);
CREATE INDEX agent_turns_embedding ON agent_turns USING hnsw (embedding vector_cosine_ops);

-- 2. The tool registry.
CREATE TABLE agent_tools (
  name          text PRIMARY KEY,
  agent         text NOT NULL,
  schema        jsonb NOT NULL,
  impl_hash     text NOT NULL,
  rate_budget   jsonb NOT NULL,  -- { window_seconds, max_calls }
  updated_at    timestamptz NOT NULL DEFAULT now()
);

-- 3. Memory: episodic, semantic, procedural.
CREATE TABLE agent_episodic  ( /* see agent-memory-layer-on-reddb */ );
CREATE TABLE agent_semantic  ( /* see agent-memory-layer-on-reddb */ );
CREATE TABLE agent_procedural( /* see agent-memory-layer-on-reddb */ );

-- 4. Eval feedback. Joins back to a specific turn.
CREATE TABLE agent_evals (
  id            uuid PRIMARY KEY,
  turn_id       uuid NOT NULL REFERENCES agent_turns (id) ON DELETE CASCADE,
  evaluator     text NOT NULL,    -- &amp;#39;human&amp;#39;, &amp;#39;rubric:v3&amp;#39;, &amp;#39;llm-judge:gpt-4o&amp;#39;
  verdict       text NOT NULL,    -- &amp;#39;good&amp;#39; | &amp;#39;bad&amp;#39; | &amp;#39;partial&amp;#39;
  rationale     text,
  payload       jsonb,
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX agent_evals_turn ON agent_evals (turn_id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The five tables are the entirety of the SDK&amp;#39;s storage surface. Everything else — replay, ranking, &amp;quot;what reviews has this bot done on this repo,&amp;quot; &amp;quot;which tool calls produced bad outcomes last week&amp;quot; — is one or two joins on this schema. The shape is deliberately boring.&lt;/p&gt;
&lt;p&gt;A few decisions worth pointing at:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;agent_turns.embedding&lt;/code&gt; is &lt;em&gt;on the turn row&lt;/em&gt;, not in a sidecar table. The same write that logs the model&amp;#39;s output also writes its embedding, in one transaction. There is no two-step &amp;quot;log then embed&amp;quot; that can fail halfway.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_tools.impl_hash&lt;/code&gt; exists so the eval layer can refuse to compare a current verdict against a historical one when the tool implementation has changed. Without it, eval drift is silent.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agent_evals.turn_id&lt;/code&gt; is a foreign key with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;. A retention policy on &lt;code&gt;agent_turns&lt;/code&gt; automatically prunes the evals that referenced them, so you do not end up with orphan rows pointing at conversations that have aged out.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The SDK shape, in TypeScript&lt;/h2&gt;
&lt;p&gt;The code below is the entire user-facing surface. It is small on purpose; the value is in the storage contract above, not in the wrapper.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { RedDB } from &amp;#39;@reddb/client&amp;#39;;
import { embed } from &amp;#39;@reddb/embed&amp;#39;;

export type Tool&amp;lt;A, R&amp;gt; = {
  name: string;
  schema: object;                       // JSON Schema for `args`
  handler: (args: A, ctx: Ctx) =&amp;gt; Promise&amp;lt;R&amp;gt;;
  rateBudget?: { windowSeconds: number; maxCalls: number };
};

export type EvalHooks = {
  onTurnComplete?: (turn: Turn) =&amp;gt; Promise&amp;lt;void&amp;gt;;
  onConversationComplete?: (conv: Conversation) =&amp;gt; Promise&amp;lt;void&amp;gt;;
};

export class Agent {
  constructor(
    private db: RedDB,
    private name: string,
    private tools: Tool&amp;lt;any, any&amp;gt;[],
    private hooks: EvalHooks = {},
  ) {}

  async register() {
    await this.db.tx(async (tx) =&amp;gt; {
      for (const t of this.tools) {
        await tx.upsert(&amp;#39;agent_tools&amp;#39;, {
          name: t.name,
          agent: this.name,
          schema: t.schema,
          impl_hash: hashFn(t.handler),
          rate_budget: t.rateBudget ?? { windowSeconds: 60, maxCalls: 30 },
        });
      }
    });
  }

  async run(conversation: string, userInput: string, ctx: Ctx): Promise&amp;lt;string&amp;gt; {
    // Append user turn (with embedding) in one transaction.
    const userEmb = await embed(userInput);
    await this.db.insert(&amp;#39;agent_turns&amp;#39;, {
      conversation, agent: this.name, kind: &amp;#39;user&amp;#39;,
      payload: { text: userInput }, embedding: userEmb,
    });

    // Hand the conversation to the model. Loop until the model stops
    // requesting tools. Each tool call and result is its own logged turn.
    let modelOut: ModelTurn;
    while (true) {
      modelOut = await callModel(this.db, this.name, conversation, this.tools);
      const t = await this.logTurn(conversation, &amp;#39;model&amp;#39;, modelOut);
      await this.hooks.onTurnComplete?.(t);

      if (!modelOut.toolCall) break;

      const tool = this.tools.find((x) =&amp;gt; x.name === modelOut.toolCall!.name);
      if (!tool) throw new Error(`unknown tool: ${modelOut.toolCall.name}`);

      await this.checkRateBudget(tool);
      const result = await tool.handler(modelOut.toolCall.args, ctx);

      const callTurn   = await this.logTurn(conversation, &amp;#39;tool_call&amp;#39;,   modelOut.toolCall);
      const resultTurn = await this.logTurn(conversation, &amp;#39;tool_result&amp;#39;, result);
      await this.hooks.onTurnComplete?.(callTurn);
      await this.hooks.onTurnComplete?.(resultTurn);
    }

    await this.hooks.onConversationComplete?.(
      await this.loadConversation(conversation),
    );
    return modelOut.text ?? &amp;#39;&amp;#39;;
  }

  // ...logTurn, loadConversation, checkRateBudget elided; ~40 LOC each
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the whole SDK. &lt;code&gt;register()&lt;/code&gt; writes the tool catalogue. &lt;code&gt;run()&lt;/code&gt; walks one turn through the model, calling tools until the model stops asking. Every turn is logged with its embedding in the same write, and the eval hooks fire at the boundaries the eval layer cares about. There is no scheduler, no retry policy, no per-tool dependency injection — those belong to the agent author, not the framework.&lt;/p&gt;
&lt;p&gt;The memory adapter is intentionally not in the class. It is a separate helper the tool handlers use directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const memory = {
  remember:   (db: RedDB, kind: &amp;#39;episodic&amp;#39;|&amp;#39;semantic&amp;#39;|&amp;#39;procedural&amp;#39;, row: object) =&amp;gt; ...,
  recall:     (db: RedDB, kind: ..., query: { text?: string; subjects?: string[] }) =&amp;gt; ...,
  forget:     (db: RedDB, kind: ..., predicate: object) =&amp;gt; ...,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tools write to memory when they have something worth remembering. The SDK does not guess for them.&lt;/p&gt;
&lt;h2&gt;A code-review bot, end to end&lt;/h2&gt;
&lt;p&gt;The bot is one file. It reads a pull request, walks the diff hunk by hunk, asks the model for review comments on each hunk, posts them, and — the part that matters — remembers what it has reviewed per repository so it does not produce the same comment twice and so the human override signal feeds back into future reviews.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// review-bot.ts — ~250 LOC including the SDK calls above
import { Agent, memory } from &amp;#39;./sdk&amp;#39;;
import { RedDB } from &amp;#39;@reddb/client&amp;#39;;
import { github } from &amp;#39;./github&amp;#39;;   // small wrapper around @octokit/rest

const db = new RedDB(process.env.REDDB_URL!);

const tools = [
  {
    name: &amp;#39;fetch_pr_diff&amp;#39;,
    schema: { type: &amp;#39;object&amp;#39;, properties: { repo: {type:&amp;#39;string&amp;#39;}, pr: {type:&amp;#39;number&amp;#39;} }, required:[&amp;#39;repo&amp;#39;,&amp;#39;pr&amp;#39;] },
    handler: async ({ repo, pr }, ctx) =&amp;gt; {
      const cached = await memory.recall(db, &amp;#39;procedural&amp;#39;, { subjects: [`gh:${repo}#${pr}`] });
      if (cached.length) return cached[0].value;

      const diff = await github.diff(repo, pr);
      await memory.remember(db, &amp;#39;procedural&amp;#39;, {
        cache_key: `gh:${repo}#${pr}`, value: diff, expires_at: in1h(),
      });
      return diff;
    },
  },
  {
    name: &amp;#39;prior_findings&amp;#39;,
    schema: { type: &amp;#39;object&amp;#39;, properties: { repo:{type:&amp;#39;string&amp;#39;}, hunk_hash:{type:&amp;#39;string&amp;#39;} }, required:[&amp;#39;repo&amp;#39;,&amp;#39;hunk_hash&amp;#39;] },
    // The whole point of the bot: do not repeat yourself.
    handler: async ({ repo, hunk_hash }) =&amp;gt; {
      return await memory.recall(db, &amp;#39;semantic&amp;#39;, {
        subjects: [`repo:${repo}`, `hunk:${hunk_hash}`],
      });
    },
  },
  {
    name: &amp;#39;post_review_comment&amp;#39;,
    schema: { type: &amp;#39;object&amp;#39;, properties: {
      repo:{type:&amp;#39;string&amp;#39;}, pr:{type:&amp;#39;number&amp;#39;}, path:{type:&amp;#39;string&amp;#39;},
      line:{type:&amp;#39;number&amp;#39;}, body:{type:&amp;#39;string&amp;#39;}, hunk_hash:{type:&amp;#39;string&amp;#39;},
    }, required: [&amp;#39;repo&amp;#39;,&amp;#39;pr&amp;#39;,&amp;#39;path&amp;#39;,&amp;#39;line&amp;#39;,&amp;#39;body&amp;#39;,&amp;#39;hunk_hash&amp;#39;] },
    handler: async (a) =&amp;gt; {
      const url = await github.postReviewComment(a);
      // Remember the finding so prior_findings will surface it next time.
      await memory.remember(db, &amp;#39;semantic&amp;#39;, {
        subjects: [`repo:${a.repo}`, `hunk:${a.hunk_hash}`, `pr:${a.pr}`],
        text: a.body, attributes: { path: a.path, line: a.line, url },
      });
      return { url };
    },
  },
];

const hooks = {
  // When a human resolves or dismisses a comment, write an eval row so
  // future runs of the bot can see which findings were accepted.
  onTurnComplete: async (turn) =&amp;gt; {
    if (turn.kind !== &amp;#39;tool_result&amp;#39;) return;
    if (turn.payload?.toolName !== &amp;#39;post_review_comment&amp;#39;) return;
    const override = await github.checkComment(turn.payload.url);
    if (override?.dismissedBy) {
      await db.insert(&amp;#39;agent_evals&amp;#39;, {
        turn_id: turn.id,
        evaluator: &amp;#39;human&amp;#39;,
        verdict: &amp;#39;bad&amp;#39;,
        rationale: override.reason ?? null,
      });
    }
  },
};

const agent = new Agent(db, &amp;#39;code-reviewer&amp;#39;, tools, hooks);
await agent.register();

export async function review(repo: string, pr: number) {
  const conv = newUuid();
  return agent.run(conv, `Review PR ${repo}#${pr}. Skip findings that match prior accepted findings on the same hunk.`, { repo, pr });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The body of the bot is small because everything that would normally be glue — caching the diff, storing findings, recording human overrides, joining all three for the next run — is just a table write. The line that matters is the one in &lt;code&gt;prior_findings&lt;/code&gt;: every review the bot has ever posted on this repository, for this hunk, is one &lt;code&gt;recall&lt;/code&gt; away. The bot can decline to repeat itself without anyone writing a deduplication service.&lt;/p&gt;
&lt;h3&gt;Asking &amp;quot;what did I get wrong&amp;quot;&lt;/h3&gt;
&lt;p&gt;This is the query that motivated the whole exercise. Given the schema above, the answer is two joins:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  t.payload -&amp;gt;&amp;gt; &amp;#39;body&amp;#39;      AS comment_body,
  t.payload -&amp;gt;&amp;gt; &amp;#39;path&amp;#39;      AS path,
  count(*)                  AS times_dismissed,
  max(e.rationale)          AS last_reason
FROM agent_turns t
JOIN agent_evals e ON e.turn_id = t.id
WHERE t.agent = &amp;#39;code-reviewer&amp;#39;
  AND t.kind  = &amp;#39;tool_result&amp;#39;
  AND t.payload -&amp;gt;&amp;gt; &amp;#39;toolName&amp;#39; = &amp;#39;post_review_comment&amp;#39;
  AND e.verdict = &amp;#39;bad&amp;#39;
  AND t.payload -&amp;gt;&amp;gt; &amp;#39;repo&amp;#39; = $1
  AND t.created_at &amp;gt; now() - interval &amp;#39;90 days&amp;#39;
GROUP BY 1, 2
ORDER BY times_dismissed DESC
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The most-dismissed comments per repo over the last quarter. No exporters, no warehouse, no scheduled job that flattens the data into a third store. The conversation log is the source of truth and the analytics surface, because they are the same table.&lt;/p&gt;
&lt;h3&gt;Cross-conversation learning&lt;/h3&gt;
&lt;p&gt;The second query the bot benefits from is one the SDK enables for free. &amp;quot;Find a review I have written on this repo whose target hunk is semantically similar to the one I am about to review, and which a human accepted.&amp;quot; That is hybrid search on &lt;code&gt;agent_turns&lt;/code&gt; joined to &lt;code&gt;agent_evals&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT t.payload, 1 - (t.embedding &amp;lt;=&amp;gt; $1) AS sim
FROM agent_turns t
LEFT JOIN agent_evals e ON e.turn_id = t.id
WHERE t.agent = &amp;#39;code-reviewer&amp;#39;
  AND t.payload -&amp;gt;&amp;gt; &amp;#39;repo&amp;#39; = $2
  AND (e.verdict IS NULL OR e.verdict &amp;lt;&amp;gt; &amp;#39;bad&amp;#39;)
ORDER BY t.embedding &amp;lt;=&amp;gt; $1
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bot can prefer past wording that survived human review and avoid wording that did not. Without the embedding on the turn row, this query is a fan-out to a separate vector store with a separate consistency story; with it, it is a single planner call. The shape of the query is the planner walkthrough from &lt;a href=&quot;/blog/hybrid-search-done-right&quot;&gt;Hybrid search done right&lt;/a&gt; applied to this exact use case.&lt;/p&gt;
&lt;h2&gt;What you give up&lt;/h2&gt;
&lt;p&gt;The SDK is small because it leaves a lot to the author. That is the trade.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No retry policy.&lt;/strong&gt; If a tool&amp;#39;s network call fails, the handler decides whether to retry. The SDK logs every attempt as its own &lt;code&gt;tool_call&lt;/code&gt; / &lt;code&gt;tool_result&lt;/code&gt; turn, so observability is preserved, but it will not retry on your behalf.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No scheduler.&lt;/strong&gt; One &lt;code&gt;run()&lt;/code&gt; call equals one conversation. Concurrency and queuing are outside the SDK. (For agents that need a queue, see &lt;a href=&quot;/blog/sub-agent-dispatch-queue&quot;&gt;Sub-agent dispatch queue&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No prompt templates.&lt;/strong&gt; &lt;code&gt;callModel&lt;/code&gt; is a leaf the author owns. The SDK does not impose a prompt structure; it imposes a storage structure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No multi-agent coordination.&lt;/strong&gt; Two agents talking to each other write to the same &lt;code&gt;agent_turns&lt;/code&gt; table under different &lt;code&gt;agent&lt;/code&gt; values, but the SDK does not route messages between them. That is the topic of &lt;a href=&quot;/blog/multi-agent-shared-memory&quot;&gt;Multi-agent shared memory&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are deliberate omissions. Every one of them is something teams want to customise per project, and every one of them is something frameworks get wrong by trying to standardise.&lt;/p&gt;
&lt;h2&gt;Why this works on RedDB specifically&lt;/h2&gt;
&lt;p&gt;Every claim in this post — embeddings on the turn row, hybrid lexical+vector retrieval against the log, foreign-keyed eval rows that survive retention pruning, one-statement analytics across log and evals — depends on three database properties: a single transaction can write a row and its embedding; the query planner can mix lexical, vector, and structured predicates in one plan; and the same instance is the operational store and the analytics surface.&lt;/p&gt;
&lt;p&gt;You can build the same SDK on a stack of Postgres + Redis + a vector service + a warehouse. People do. The cost is the integration code that keeps the four stores in agreement, and the queries you no longer write because they would require denormalising across all of them. The SDK above is small because that integration code does not exist.&lt;/p&gt;
&lt;p&gt;If you want to try this against your own agent, the migration and the SDK scaffold above are enough to start. Point a fresh RedDB at it, swap in your model call, and the first &lt;code&gt;run()&lt;/code&gt; will land a row in &lt;code&gt;agent_turns&lt;/code&gt; that you can query in the same shell you are running the bot from. The five-table shape is the contract. Everything else is yours.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/chunking-inside-the-engine</id>
    <title>Chunking inside the engine: when the DB owns segmentation</title>
    <link href="https://reddb.io/blog/chunking-inside-the-engine"/>
    <updated>2026-05-17</updated>
    <published>2026-05-17</published>
    <author><name>RedDB team</name></author>
    <summary>Most RAG stacks chunk in Python glue between Postgres and a vector store. The result is a second pipeline that drifts. This post walks through what it looks like when chunking is a declarative rule attached to a column, reranking is a query operator, and the engine — not the application — owns segmentation.</summary>
    <content type="html">&lt;p&gt;The standard RAG stack chunks documents in application code. A worker reads a row from Postgres, runs it through a chunker — recursive character splitter, semantic chunker, whatever this quarter&amp;#39;s library prefers — embeds each chunk, and writes the results to the vector store. The chunker is two hundred lines of Python that nobody on the team wrote, that everyone is afraid to touch, and that disagrees in subtle ways with whatever the next service in the pipeline expects.&lt;/p&gt;
&lt;p&gt;This post is about what happens when that chunker moves into the database. Not as a stored procedure that imitates the Python — as a declarative rule attached to the column it segments. The rule runs inside the same transaction as the write, the chunks are queryable rows, and reranking — the other half of retrieval that usually lives in application code — becomes a query operator the planner can reason about. The comparison point is the same one we keep coming back to: an external pipeline that you have to keep in sync with the data, versus a property of the column itself.&lt;/p&gt;
&lt;h2&gt;What &amp;quot;chunking in app code&amp;quot; actually costs&lt;/h2&gt;
&lt;p&gt;If you have built a RAG pipeline, the diagram is familiar. A document arrives. A worker picks it up, applies a chunking function, embeds each chunk, writes the chunks to a vector store, optionally writes back a denormalised pointer table to the source database so you can join chunks back to documents. At query time, the application embeds the question, asks the vector store for top-k chunks, fetches the source documents from the primary store, optionally reranks the chunks with a second model, and returns the result.&lt;/p&gt;
&lt;p&gt;Every arrow in that pipeline is a place chunking can disagree with the data:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The document changes; the chunks have not been recomputed yet. The retriever returns chunks whose source paragraph no longer exists. We covered this drift window in &lt;a href=&quot;/blog/rag-without-second-database&quot;&gt;a previous post&lt;/a&gt; — chunking is where the window is widest, because chunking is the most expensive step in the pipeline and therefore the most likely to lag.&lt;/li&gt;
&lt;li&gt;The chunking function changes — a new splitter, a new overlap setting, a new maximum size. Now half the corpus is chunked one way and half is chunked another way, and the retrieved top-k mixes the two with no way to tell which is which.&lt;/li&gt;
&lt;li&gt;The chunking function and the &lt;em&gt;retrieval&lt;/em&gt; code disagree about how to reassemble chunks back into context. The retriever returns chunk 5 of document 17; the assembler asks for &amp;quot;the paragraph around chunk 5&amp;quot;; the chunker&amp;#39;s notion of &amp;quot;around&amp;quot; was never written down anywhere because it was implicit in the Python.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these is a class of failure that the application is structurally bad at preventing, because the application does not own the storage and does not see the writes. The database does.&lt;/p&gt;
&lt;h2&gt;Declarative chunking, attached to the column&lt;/h2&gt;
&lt;p&gt;The mental shift is the same one we made for embeddings. Stop thinking of &amp;quot;the chunks for document 17&amp;quot; as a separate object that has to be kept in sync. Start thinking of chunking as a property of the column the chunks come from — a derived view that the engine maintains automatically, the same way it maintains an index.&lt;/p&gt;
&lt;p&gt;The shape is a &lt;code&gt;CHUNK BY&lt;/code&gt; clause on the column definition:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE documents (
  id          UUID PRIMARY KEY,
  tenant_id   UUID NOT NULL,
  title       TEXT NOT NULL,
  body        TEXT NOT NULL CHUNK BY (
    strategy  =&amp;gt; &amp;#39;recursive&amp;#39;,
    max_tokens =&amp;gt; 512,
    overlap    =&amp;gt; 64,
    separators =&amp;gt; ARRAY[E&amp;#39;\n\n&amp;#39;, E&amp;#39;\n&amp;#39;, &amp;#39;. &amp;#39;, &amp;#39; &amp;#39;],
    embed_with =&amp;gt; &amp;#39;text-embedding-3-large&amp;#39;
  ),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;CHUNK BY&lt;/code&gt; clause is the contract. The engine guarantees that for every committed row of &lt;code&gt;documents&lt;/code&gt;, there exists a corresponding set of rows in a derived &lt;code&gt;documents__body_chunks&lt;/code&gt; relation, each with the segmented text and its embedding, and that the set is consistent with the &lt;code&gt;body&lt;/code&gt; value at the row&amp;#39;s current visible version. There is no worker, no queue, no outbox. The chunking and embedding happen inline with the write — or, for large documents, asynchronously with the row marked &lt;code&gt;chunks_pending = true&lt;/code&gt; until the derived relation catches up, in which case the engine refuses to return that row from a retrieval query that requires chunks.&lt;/p&gt;
&lt;p&gt;The derived relation is a first-class table you can query directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT c.ordinal, c.text, c.embedding
FROM documents__body_chunks c
WHERE c.document_id = $1
ORDER BY c.ordinal;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It has its own HNSW index built and maintained by the engine. It has a foreign key to the parent document that the engine cannot let you violate. Deleting the parent deletes the chunks. Rewriting &lt;code&gt;body&lt;/code&gt; re-runs the chunker and re-embeds, all inside the writing transaction, all visible atomically to the next reader.&lt;/p&gt;
&lt;p&gt;The point is not that the syntax is shorter. The point is that the chunking function is part of the schema, which means it is part of &lt;code&gt;EXPLAIN&lt;/code&gt;, it is part of migrations, it is part of the backup, and most importantly it is part of the &lt;em&gt;contract&lt;/em&gt; the engine enforces. A row of &lt;code&gt;documents&lt;/code&gt; whose &lt;code&gt;body&lt;/code&gt; has been written but whose chunks have not yet been generated is a row in a defined state (&lt;code&gt;chunks_pending&lt;/code&gt;) with defined visibility rules. It is not &amp;quot;a bug we will find in three weeks when a customer reports stale retrieval&amp;quot;.&lt;/p&gt;
&lt;h2&gt;What &amp;quot;consistent with the current value&amp;quot; actually means&lt;/h2&gt;
&lt;p&gt;The hardest part of chunking-as-derived-data is not running the chunker. It is the invariant that the chunks for row 17 always reflect the &lt;em&gt;current committed&lt;/em&gt; &lt;code&gt;body&lt;/code&gt; of row 17, for every transactional reader, under concurrent writes.&lt;/p&gt;
&lt;p&gt;The engine handles this the way it handles any derived state: the chunks live under the same MVCC regime as the row they derive from. When a transaction writes a new &lt;code&gt;body&lt;/code&gt;, it also writes the new chunk set, both tagged with the same commit LSN. Readers at an earlier snapshot see the old body and the old chunks. Readers at the new snapshot see the new body and the new chunks. There is no window — not zero-with-an-asterisk, just zero — in which a retrieval query can join the new body to old chunks or vice versa. This falls out of having a &lt;a href=&quot;/blog/one-wal-four-data-models&quot;&gt;single WAL across data models&lt;/a&gt;: the document write, the chunk inserts, the chunk deletes, and the vector index updates all serialise through the same ordered byte stream.&lt;/p&gt;
&lt;p&gt;For large bodies the chunking and embedding are too expensive to run inline. The engine has two modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;CHUNK BY (..., commit_mode =&amp;gt; &amp;#39;inline&amp;#39;)&lt;/code&gt;&lt;/strong&gt; — chunking runs inside the writing transaction. The transaction does not commit until the chunks are computed and indexed. Visibility is bound to the row&amp;#39;s commit. Latency is bounded by the embedder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;CHUNK BY (..., commit_mode =&amp;gt; &amp;#39;async&amp;#39;)&lt;/code&gt;&lt;/strong&gt; — the row commits with &lt;code&gt;chunks_pending = true&lt;/code&gt;. Chunking runs in a background lane backed by the same WAL. Until it completes, retrieval queries that require chunks treat the row as not-yet-visible (they do &lt;em&gt;not&lt;/em&gt; return stale chunks from the previous version of the row). The default for columns wider than a configurable threshold.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;async&lt;/code&gt; is the realistic default for production. The guarantee it gives is not &amp;quot;the chunks are always up to date&amp;quot; — that is a lie any architecture would have to tell — but rather &amp;quot;either you see the new row with its new chunks, or you see the old row with its old chunks, never a mix.&amp;quot; That is the only guarantee an application can actually program against.&lt;/p&gt;
&lt;h2&gt;Reranking as a query operator&lt;/h2&gt;
&lt;p&gt;Chunking is half of the retrieval story. The other half is reranking — the second-pass scoring that takes the top-N candidates from the first-pass retriever and re-orders them with a more expensive model. In a stitched stack, reranking is application code: fetch top-50 from the vector store, send them to the rerank API, sort by the new scores, take the top-5.&lt;/p&gt;
&lt;p&gt;Once chunks are rows in the engine, reranking can be an operator the planner schedules:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, document_id, text, score
FROM documents__body_chunks
WHERE tenant_id = $1
ORDER BY rerank(
  query    =&amp;gt; $2,
  text     =&amp;gt; text,
  model    =&amp;gt; &amp;#39;bge-reranker-v2&amp;#39;,
  first_pass =&amp;gt; hybrid(
    lexical =&amp;gt; bm25(text, $2),
    vector  =&amp;gt; 1 - (embedding &amp;lt;=&amp;gt; $3),
    weights =&amp;gt; (0.4, 0.6)
  ),
  first_k  =&amp;gt; 50
) DESC
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;rerank()&lt;/code&gt; is recognised by the planner the same way &lt;code&gt;hybrid()&lt;/code&gt; is — see the &lt;a href=&quot;/blog/hybrid-search-done-right&quot;&gt;hybrid search post&lt;/a&gt; for the underlying machinery. The planner treats it as a two-stage top-k: the inner &lt;code&gt;first_pass&lt;/code&gt; produces a candidate set of size &lt;code&gt;first_k&lt;/code&gt; using the cheap hybrid score; the outer reranker is called once per candidate to produce the final score; the operator returns the top &lt;code&gt;LIMIT&lt;/code&gt; by reranked score.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; on that statement makes the staging visible:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Limit  (cost=2104.50..2104.55 rows=10 width=160) (actual rows=10 loops=1)
  -&amp;gt;  Rerank Top-K  k=10  model=bge-reranker-v2
        first_k: 50  (capped from 100 by rerank_budget_ms=200)
        Per-candidate cost: 3.8 ms (calibrated)
        -&amp;gt;  Hybrid Top-K  k=50  weights=(0.40, 0.60)
              ... (see hybrid-search post)
        Reranked: 50 candidates, 10 returned
        Budget used: 190 ms of 200 ms
 Planning Time: 0.42 ms
 Execution Time: 214.6 ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The planner pieces that matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;first_k&lt;/code&gt; is chosen by the planner, not the application.&lt;/strong&gt; A larger &lt;code&gt;first_k&lt;/code&gt; improves the chance the right answer is in the candidate set but spends more reranker calls. The planner picks the largest &lt;code&gt;first_k&lt;/code&gt; that fits the per-query &lt;code&gt;rerank_budget_ms&lt;/code&gt;, given the calibrated per-candidate cost of the chosen reranker. The application sets a latency budget; the engine spends it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The reranker model is named in the query and resolved like any other dependency.&lt;/strong&gt; Models are registered in a &lt;code&gt;rerankers&lt;/code&gt; catalog (same shape as the &lt;code&gt;embedders&lt;/code&gt; catalog) with their endpoint, calibrated latency, and pricing. Schema migrations against the model version are the same machinery as any other schema migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The first-pass scoring is reused, not recomputed.&lt;/strong&gt; Both the lexical and vector sub-scores from the &lt;code&gt;hybrid()&lt;/code&gt; first pass are carried on the candidate rows. The reranker sees them as features it can use (&lt;code&gt;bge-reranker-v2&lt;/code&gt; ignores them; some rerankers consume them as a prior). Either way, no work is repeated.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The application-side equivalent of all this is the recurring bug where the candidate-set size is hard-coded to 50, the reranker takes 6 ms per call instead of the 3 ms it took six months ago, the P95 latency on the retrieval endpoint quietly drifts past the SLO, and nobody notices until a sales engineer complains. With the operator, the budget is the configured value, and the planner adjusts &lt;code&gt;first_k&lt;/code&gt; to fit. The number tuned for last quarter&amp;#39;s reranker is not in your code at all.&lt;/p&gt;
&lt;h2&gt;What changes for the application&lt;/h2&gt;
&lt;p&gt;Two things that used to be the application&amp;#39;s job are now the engine&amp;#39;s.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Chunking is no longer an out-of-band pipeline.&lt;/strong&gt; The chunking function is a column property. Re-chunking after a strategy change is a schema migration: &lt;code&gt;ALTER COLUMN body CHUNK BY (...)&lt;/code&gt; re-runs the chunker against every existing row, in the background, against the same WAL, with the same MVCC guarantees as any other migration. The day you change &lt;code&gt;max_tokens&lt;/code&gt; from 512 to 768 is not the day you stand up a parallel pipeline and forklift. It is the day you write one DDL statement and watch a progress counter.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reranking is no longer two round trips and a sort in application code.&lt;/strong&gt; The reranker is named in the query, the budget is named in the configuration, and the candidate-set size is something the planner chooses. The half-page of TypeScript that used to call the rerank API and resort the results — gone. So is the entire class of bug where the resort used the wrong score field.&lt;/p&gt;
&lt;p&gt;What you give up: control. If your chunker is doing something genuinely custom — semantic chunking by paragraph topic, code-aware chunking by AST boundaries, layout-aware chunking from PDF tokens — you cannot express it as &lt;code&gt;strategy =&amp;gt; &amp;#39;recursive&amp;#39;&lt;/code&gt;. The escape hatch is to register a chunker as a UDF and use &lt;code&gt;strategy =&amp;gt; &amp;#39;udf:my_chunker&amp;#39;&lt;/code&gt;, but UDF chunkers run in a sandbox with bounded CPU and memory and cannot make network calls. For most teams the four built-in strategies (&lt;code&gt;fixed&lt;/code&gt;, &lt;code&gt;recursive&lt;/code&gt;, &lt;code&gt;sentence&lt;/code&gt;, &lt;code&gt;markdown&lt;/code&gt;) cover the corpus; for teams that need more, the UDF escape hatch exists but the bounds are real.&lt;/p&gt;
&lt;h2&gt;When you would still pick the stitched stack&lt;/h2&gt;
&lt;p&gt;There are real cases where moving chunking into the engine is the wrong call.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Your chunker calls a remote service per chunk.&lt;/strong&gt; A document-AI service that returns layout-aware spans, an LLM that produces summary chunks, anything that makes a network hop per chunk — these cannot run inside a database transaction without inverting your latency profile. Keep the chunker external, treat its output as authoritative, write the chunks as plain rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You re-chunk constantly as part of an experiment.&lt;/strong&gt; If you are sweeping chunking strategies as part of a retrieval-eval loop, you want chunking to be a function you call from a notebook, not a column property you migrate. The engine&amp;#39;s path is for the chunker you have decided on, not the one you are still searching for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The chunker is the product.&lt;/strong&gt; If your differentiation is a proprietary chunking strategy you ship as a library, the strategy belongs in your code, not in your customer&amp;#39;s database. The engine path is for teams whose differentiation is what they do with the chunks, not how they make them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For everything else — the long tail of &amp;quot;we just need recursive 512/64 chunks with cosine recall and a reranker&amp;quot; — the stitched stack is paying ongoing operational cost for flexibility nobody is using. The schema column gives you the same chunks with no pipeline, no drift, and a query planner that can see what you are asking for.&lt;/p&gt;
&lt;p&gt;That is the whole pitch. Chunking is a property of the column it segments. Reranking is an operator the planner schedules against a budget. The Python glue layer that used to live between Postgres and a vector store does not get smaller — it does not exist.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/hybrid-search-done-right</id>
    <title>Hybrid search done right: lexical, vector, and filter in one plan</title>
    <link href="https://reddb.io/blog/hybrid-search-done-right"/>
    <updated>2026-05-17</updated>
    <published>2026-05-17</published>
    <author><name>RedDB team</name></author>
    <summary>A walk through the RedDB query planner fusing BM25, vector similarity, and a structured filter into a single execution plan — with EXPLAIN output, the cost model behind it, and what happens on the edge cases that trip up naive two-stage rerankers.</summary>
    <content type="html">&lt;p&gt;If you have ever wired up &amp;quot;hybrid search&amp;quot; by hand, you know the shape: send the same query to a full-text index, send it again to a vector index, post-process the two result sets in application code, then filter the merged list against your structured predicates. It works. It is also three round trips, two scoring functions that disagree about what &amp;quot;relevant&amp;quot; means, and a filter step that runs after the top-k has already been chosen — which is the wrong order if your filter is even slightly selective.&lt;/p&gt;
&lt;p&gt;This post is about how the RedDB query planner avoids that shape. Lexical, vector, and filter are not three pipelines glued together at the application layer; they are three access paths the optimizer can mix inside one plan. The interesting part is not that the syntax is shorter. The interesting part is what the planner does when one of the three legs is degenerate — when the lexical match is empty, when the vector recall is low, when the filter is selective enough to dominate the cost model. That is where the naive stitched-together version silently returns the wrong rows.&lt;/p&gt;
&lt;h2&gt;The query, as written&lt;/h2&gt;
&lt;p&gt;The user-facing API is one statement. A tenant-scoped retrieval for a customer support agent, matching on the question text both lexically and semantically, restricted to documents the asking user is allowed to see and to a recency window:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, document_id, body, score
FROM chunks
WHERE tenant_id = $1
  AND acl_subjects &amp;amp;&amp;amp; $2          -- array overlap with the caller&amp;#39;s groups
  AND created_at &amp;gt; now() - interval &amp;#39;180 days&amp;#39;
  AND match(body, $3)             -- BM25 lexical predicate
ORDER BY
  hybrid(
    lexical =&amp;gt; bm25(body, $3),
    vector  =&amp;gt; 1 - (embedding &amp;lt;=&amp;gt; $4),
    weights =&amp;gt; (0.4, 0.6)
  ) DESC
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;$3&lt;/code&gt; is the query string; &lt;code&gt;$4&lt;/code&gt; is its embedding. &lt;code&gt;hybrid()&lt;/code&gt; is not a UDF that runs after the rows have been collected — it is a planner-recognised scoring construct, the same way &lt;code&gt;ORDER BY ... LIMIT k&lt;/code&gt; over a vector distance is recognised as a top-k operator rather than a sort. The distinction matters; we will come back to it.&lt;/p&gt;
&lt;h2&gt;What the planner actually builds&lt;/h2&gt;
&lt;p&gt;Run &lt;code&gt;EXPLAIN (analyze, costs, format text)&lt;/code&gt; on the statement above against a 24M-chunk corpus and you get something close to this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Limit  (cost=1842.10..1842.15 rows=20 width=128) (actual rows=20 loops=1)
  -&amp;gt;  Hybrid Top-K  k=20  weights=(0.40, 0.60)
        Score: 0.40 * bm25(body, $3) + 0.60 * (1 - cosine(embedding, $4))
        -&amp;gt;  Filtered Candidate Stream  (predicate-pushdown=true)
              Filter: tenant_id = $1
                      AND acl_subjects &amp;amp;&amp;amp; $2
                      AND created_at &amp;gt; &amp;#39;2025-11-18 00:00:00&amp;#39;
              -&amp;gt;  Bitmap-AND
                    -&amp;gt;  Lexical Index Scan  on chunks_body_fts
                          Recheck Cond: match(body, $3)
                          Heap Blocks: exact=4112
                          Rows: 38,221
                    -&amp;gt;  Vector ANN Scan  on chunks_embedding_hnsw
                          ef_search: 96 (auto-tuned for k=20)
                          Probe Rows: 240
                          Rows: 218
              Bitmap-AND output: 412 candidate rows
        Rescored: 412 rows  (lexical + vector, with stored sub-scores)
        Buffers: shared hit=1894
 Planning Time: 0.31 ms
 Execution Time: 11.4 ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things are worth pointing at.&lt;/p&gt;
&lt;p&gt;First, the filter is &lt;strong&gt;pushed down to candidate generation&lt;/strong&gt;, not applied after the top-k. The lexical scan and the ANN scan each emit row identifiers; the structured filter is evaluated as those identifiers stream out of the indexes, before the bitmap is intersected. If &lt;code&gt;tenant_id = $1&lt;/code&gt; cuts the corpus from 24M to 80k chunks, the ANN scan only walks the part of the HNSW graph that points into that tenant&amp;#39;s slice — the index stores a per-row tenant id in the leaf payload so the graph traversal can skip out-of-tenant nodes without a separate lookup.&lt;/p&gt;
&lt;p&gt;Second, &lt;strong&gt;the two candidate streams are intersected, not unioned&lt;/strong&gt;. The naive recipe — &amp;quot;take top-100 lexical, take top-100 vector, merge by some weighted score&amp;quot; — unions and reranks. That is the wrong default for a system where both signals are meant to point at the same row. A union biases towards documents that are strong on one signal and absent on the other, which is exactly the kind of result a human reviewing the output calls &amp;quot;irrelevant but on-topic&amp;quot;. The planner intersects by default and falls back to a union only when the intersection is too small to fill the requested &lt;code&gt;k&lt;/code&gt; (see the edge cases below).&lt;/p&gt;
&lt;p&gt;Third, &lt;strong&gt;scoring happens once, on the surviving candidates&lt;/strong&gt;. &lt;code&gt;bm25(body, $3)&lt;/code&gt; is computed during the lexical index scan and the sub-score is carried on the candidate row; &lt;code&gt;cosine(embedding, $4)&lt;/code&gt; is computed during the ANN scan and similarly carried. The &lt;code&gt;Hybrid Top-K&lt;/code&gt; operator combines the two stored sub-scores with the requested weights and runs a bounded heap of size &lt;code&gt;k&lt;/code&gt;. There is no recompute, no second pass over the corpus, and no application-layer merge.&lt;/p&gt;
&lt;p&gt;The cost model is what makes this stable under changing data shape. The planner estimates the selectivity of each leg — filter, lexical, vector — and chooses both the join order of the bitmaps and the &lt;code&gt;ef_search&lt;/code&gt; parameter for the ANN scan to satisfy a target recall. It is the same machinery that picks an index in a normal OLTP planner, with two additions: an approximate-recall estimator for the ANN side, and a Zipfian estimator for term frequency on the lexical side.&lt;/p&gt;
&lt;h2&gt;What changes when a leg is degenerate&lt;/h2&gt;
&lt;p&gt;The interesting failure modes of hybrid search are not &amp;quot;everything works and the score is slightly off&amp;quot;. They are the cases where one of the three legs collapses. The planner has a defined behaviour for each.&lt;/p&gt;
&lt;h3&gt;Empty lexical match&lt;/h3&gt;
&lt;p&gt;The user asks something the corpus has no lexical overlap with. Common when the query is a paraphrase, an acronym not present in the documents, or a different language than the indexed text. The lexical leaf returns zero rows.&lt;/p&gt;
&lt;p&gt;A naive merge would now return whatever the vector leg found, ranked purely by cosine — which is fine, except that the weighted score &lt;code&gt;0.4 * bm25 + 0.6 * cosine&lt;/code&gt; collapses to &lt;code&gt;0.6 * cosine&lt;/code&gt; for every row, and you have silently changed your ranking function in the worst possible way: the API still claims to be hybrid.&lt;/p&gt;
&lt;p&gt;The planner handles this explicitly. When the lexical bitmap is empty and the vector bitmap is non-empty, the &lt;code&gt;Hybrid Top-K&lt;/code&gt; operator switches to the vector-only score &lt;em&gt;and&lt;/em&gt; emits a runtime annotation on the result set:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Hybrid Top-K  k=20  weights=(0.40, 0.60)
  Degraded: lexical_empty -&amp;gt; vector_only
  Rescored: 218 rows  (vector sub-score only; lexical sub-score = 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application sees a &lt;code&gt;degraded&lt;/code&gt; field on the response envelope (&lt;code&gt;{ degraded: &amp;quot;lexical_empty&amp;quot; }&lt;/code&gt;). It is not an error — the rows are still useful — but the caller now has the signal it needs to, for example, prompt the agent for a clarifying question, or to suppress the citation footnotes that pretend the lexical signal participated.&lt;/p&gt;
&lt;h3&gt;Low-recall vector match&lt;/h3&gt;
&lt;p&gt;Pathological the other direction: the ANN scan returns very few candidates, either because the embedding is far from any cluster the index has visited at the current &lt;code&gt;ef_search&lt;/code&gt;, or because the filter eliminated most of the ANN&amp;#39;s neighbourhood. The planner notices the candidate count is below a configurable floor (&lt;code&gt;hybrid.vector_recall_floor&lt;/code&gt;, default &lt;code&gt;0.5 * k&lt;/code&gt;) and re-runs the ANN scan with &lt;code&gt;ef_search * 2&lt;/code&gt;, up to a configurable ceiling. This is cheap because the HNSW state from the first pass is cached; the second pass continues the traversal from where the first stopped, rather than starting from the entry point.&lt;/p&gt;
&lt;p&gt;If after the ceiling the vector candidate count is still below the floor, the operator widens the candidate set by &lt;em&gt;unioning&lt;/em&gt; the lexical bitmap rather than intersecting — and again annotates the result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Hybrid Top-K  k=20  weights=(0.40, 0.60)
  Degraded: low_vector_recall -&amp;gt; union(lexical, vector)
  ef_search escalation: 96 -&amp;gt; 192 -&amp;gt; 384 (ceiling)
  Rescored: 184 rows
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that the score formula is unchanged — both sub-scores are still combined with the original weights — but the candidate set is now a union, which is the right default when the planner knows the intersection is artificially small.&lt;/p&gt;
&lt;h3&gt;Filter-dominant queries&lt;/h3&gt;
&lt;p&gt;The third edge case is the one that quietly destroys naive stacks. The filter is highly selective: a tenant with 200 chunks, or a date window that excludes 99% of the corpus. A bolt-on hybrid stack first asks the lexical index for top-100 and the vector index for top-100, then filters down — and ends up with a handful of rows, or zero, despite the underlying tenant slice containing perfectly good matches.&lt;/p&gt;
&lt;p&gt;The planner detects this from the filter&amp;#39;s estimated selectivity. When the filter cardinality is below a threshold relative to &lt;code&gt;k&lt;/code&gt; (default: &lt;code&gt;filter_rows &amp;lt; 50 * k&lt;/code&gt;), the plan inverts: a &lt;em&gt;filter-first&lt;/em&gt; execution that materialises the filtered slice and then runs lexical + vector scoring across it as a sequential pass, rather than going through the indexes. The EXPLAIN output for that variant looks different:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Hybrid Top-K  k=20  weights=(0.40, 0.60)
  Strategy: filter_first  (filter_rows=147 &amp;lt; 50*k=1000)
  -&amp;gt;  Filtered Materialize  rows=147
        Filter: tenant_id = $1 AND acl_subjects &amp;amp;&amp;amp; $2 AND created_at &amp;gt; ...
        -&amp;gt;  Index Scan on chunks_tenant_btree
  Rescored: 147 rows  (lexical + vector, computed on-the-fly)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lexical and vector scores are computed at scoring time rather than read from the index. That is more CPU per row, but the absolute row count is small, so the trade is correct. It is also the only variant that is guaranteed to &lt;em&gt;find&lt;/em&gt; the right answer when the filter is selective — the index-first variants can miss rows entirely when neither the top-100 lexical nor the top-100 vector intersect the filtered slice.&lt;/p&gt;
&lt;h2&gt;The cost model, briefly&lt;/h2&gt;
&lt;p&gt;Three estimates drive the strategy choice:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Filter selectivity.&lt;/strong&gt; Standard stats — histograms, MCVs, distinct-count — same as any planner. Tenant id and time windows are usually well-estimated; ACL overlaps are estimated as a function of the average subject-set size.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lexical cardinality.&lt;/strong&gt; Estimated from per-term document frequencies. A query of three rare terms might return 800 rows; a query of three stopwords-after-filtering might return zero, which short-circuits the lexical leg.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector recall at a given &lt;code&gt;ef_search&lt;/code&gt;.&lt;/strong&gt; Estimated from a small offline calibration that the indexer runs once per index build and refreshes on compaction. The estimator maps &lt;code&gt;ef_search&lt;/code&gt; to expected recall at &lt;code&gt;k&lt;/code&gt; for the corpus&amp;#39;s distance distribution; the planner inverts it to pick the smallest &lt;code&gt;ef_search&lt;/code&gt; that meets the target recall.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The strategy chosen is the one with the lowest expected cost subject to a hard recall floor. The recall floor is the protection against the &amp;quot;looks fast, returns garbage&amp;quot; failure mode: you can configure the floor down, but you cannot configure it away.&lt;/p&gt;
&lt;h2&gt;What this buys the application&lt;/h2&gt;
&lt;p&gt;Two things that used to be the application&amp;#39;s job are now the planner&amp;#39;s.&lt;/p&gt;
&lt;p&gt;The application no longer owns the merge. There is one ranked list returned by one statement, with one consistent score, with a &lt;code&gt;degraded&lt;/code&gt; annotation when the plan had to fall back. The half-page of TypeScript that used to weight two result sets, deduplicate, and reapply the filter — gone. So is the entire class of bug where the merge code drifted out of agreement with the index configuration.&lt;/p&gt;
&lt;p&gt;The application no longer owns the recall/latency trade-off per call site. &lt;code&gt;ef_search&lt;/code&gt;, the choice between intersect and union, the choice between index-first and filter-first — these are all decisions the planner makes per query, based on the statistics of the actual data and the actual query. The configuration you ship is a recall floor and a latency budget, not a set of magic numbers tuned for last quarter&amp;#39;s corpus.&lt;/p&gt;
&lt;p&gt;If you are running a hybrid stack today and have ever had to explain why your top result on a slightly rephrased question is suddenly missing — or why a narrow tenant returns zero hits despite obviously having matching documents — one of the three edge cases above is the explanation. The fix is not a better merge function in application code. The fix is to push the merge into the same plan as the scans, so that the planner can see when a leg has degenerated and react accordingly.&lt;/p&gt;
&lt;p&gt;That is the planner work behind &lt;code&gt;hybrid()&lt;/code&gt;. The syntax is the boring part.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/one-wal-four-data-models</id>
    <title>One WAL, four data models: how cross-modality transactions actually work</title>
    <link href="https://reddb.io/blog/one-wal-four-data-models"/>
    <updated>2026-05-17</updated>
    <published>2026-05-17</published>
    <author><name>RedDB team</name></author>
    <summary>A deep dive into the shared write-ahead log that lets a document update, a vector insert, a KV write, and a blob commit land in the same transaction. Record layout, the fsync contention tradeoff, and the four mitigations we shipped before the tail latency became someone else&apos;s incident.</summary>
    <content type="html">&lt;p&gt;A previous post — &lt;a href=&quot;/blog/multi-model-storage-tradeoffs&quot;&gt;The tradeoffs we made to fit four engines in one&lt;/a&gt; — said the trick is a single write-ahead log. That sentence is correct, dramatically under-specified, and exactly the kind of thing storage engineers stop trusting after their third bad weekend. So this post is the long version. What the WAL actually contains, how a four-modality transaction lands in it, what the &lt;code&gt;fsync&lt;/code&gt; math looks like when a vector batch and a 50 MB blob arrive in the same group commit window, and the four mitigations that keep the cold-blob latency tail from eating the KV path.&lt;/p&gt;
&lt;p&gt;If you have never had to reason about a WAL, the short version is: it is the ordered byte stream that every durable change is written to before anything else is allowed to acknowledge the write. Crash recovery replays it. Replication reads it. Backup snapshots are aligned to positions in it. It is the single most load-bearing file in the engine. Putting four data models behind one of these is the design decision that everything else in RedDB falls out of.&lt;/p&gt;
&lt;h2&gt;What &amp;quot;one WAL&amp;quot; actually means&lt;/h2&gt;
&lt;p&gt;The temptation, when you have four engines, is to give each one its own log. Postgres has &lt;code&gt;pg_wal&lt;/code&gt;, you give the vector engine &lt;code&gt;vec_wal&lt;/code&gt;, S3 has its own internal journal, Redis has its AOF. Four logs, four &lt;code&gt;fsync&lt;/code&gt; queues, four recovery procedures. It composes — until you want a transaction that spans two of them. Then you are either writing two-phase commit by hand or you are pretending the consistency story is fine and hoping nobody graphs the divergence.&lt;/p&gt;
&lt;p&gt;A single WAL means there is exactly one ordered byte stream on disk, and every durable mutation to every modality is appended to it before the client sees an ack. A transaction is, physically, a contiguous run of records in that stream bracketed by a &lt;code&gt;BEGIN&lt;/code&gt; and a &lt;code&gt;COMMIT&lt;/code&gt; record sharing a transaction id. When the &lt;code&gt;COMMIT&lt;/code&gt; record&amp;#39;s LSN is durable — meaning &lt;code&gt;fsync&lt;/code&gt; returned on the WAL file up to at least that byte offset — the transaction is committed. All of its modality writes are committed atomically because they are all &lt;em&gt;before&lt;/em&gt; the &lt;code&gt;COMMIT&lt;/code&gt; in the same ordered file. There is no second log to wait on.&lt;/p&gt;
&lt;p&gt;This is how a single update can touch a document, append a vector, bump a KV counter and replace a blob and either all four of those changes survive a crash or none of them do. The atomicity is a property of byte ordering on disk, not a protocol.&lt;/p&gt;
&lt;h2&gt;Record layout&lt;/h2&gt;
&lt;p&gt;Every WAL record has the same envelope. The body differs per modality, but the envelope does not.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+----------+------+--------+---------+----------+-----------------+--------+
|   LSN    | size |  type  |  txn_id | modality |     payload     |  CRC32 |
+----------+------+--------+---------+----------+-----------------+--------+
| 8 bytes  |  4   |   1    |    8    |    1     | size - 22 bytes |   4    |
+----------+------+--------+---------+----------+-----------------+--------+
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;LSN&lt;/code&gt;&lt;/strong&gt; — Log Sequence Number, monotonic, assigned at append time. The recovery cursor, the replication cursor, the snapshot cursor are all LSNs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;size&lt;/code&gt;&lt;/strong&gt; — total record bytes including this header. Lets the replayer skip a corrupted record and continue if the CRC fails.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;type&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;BEGIN&lt;/code&gt;, &lt;code&gt;COMMIT&lt;/code&gt;, &lt;code&gt;ABORT&lt;/code&gt;, &lt;code&gt;WRITE&lt;/code&gt;, &lt;code&gt;CHECKPOINT&lt;/code&gt;, &lt;code&gt;BLOB_REF&lt;/code&gt;, &lt;code&gt;FENCE&lt;/code&gt;. The first three bracket transactions; &lt;code&gt;WRITE&lt;/code&gt; carries actual mutations; &lt;code&gt;CHECKPOINT&lt;/code&gt; records a fuzzy checkpoint LSN; &lt;code&gt;BLOB_REF&lt;/code&gt; is the sidecar pointer described below; &lt;code&gt;FENCE&lt;/code&gt; is a barrier the replay cursor will not cross until acknowledged by the modality engines (used for online schema changes).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;txn_id&lt;/code&gt;&lt;/strong&gt; — 64-bit transaction id. Every &lt;code&gt;WRITE&lt;/code&gt; carries the id of the transaction it belongs to.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;modality&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;DOC&lt;/code&gt;, &lt;code&gt;VEC&lt;/code&gt;, &lt;code&gt;KV&lt;/code&gt;, &lt;code&gt;BLOB&lt;/code&gt;, or &lt;code&gt;META&lt;/code&gt; (system records). The replayer dispatches to the right engine using this byte.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;payload&lt;/code&gt;&lt;/strong&gt; — modality-specific bytes. The four modalities encode their payload differently:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;DOC&lt;/code&gt; — collection id, document id, MVCC version, encoded value (MessagePack).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;VEC&lt;/code&gt; — collection id, vector id, dimensionality, raw &lt;code&gt;f32&lt;/code&gt; bytes, optional metadata blob. &lt;strong&gt;The HNSW graph mutation is not in the WAL.&lt;/strong&gt; It is recomputed at replay from the vector id and the existing graph state. This is the single most important encoding decision and we discuss it below.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;KV&lt;/code&gt; — namespace, key bytes, value bytes (or a tombstone marker), TTL.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BLOB&lt;/code&gt; — blob id, content hash, size, &lt;em&gt;and a sidecar offset&lt;/em&gt;. The blob&amp;#39;s actual bytes are not in this record. See the sidecar section.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;CRC32&lt;/code&gt;&lt;/strong&gt; — checksum over the entire envelope including the payload. A failed CRC during replay marks the end of the durable log; anything after is treated as torn.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A transaction touching all four modalities therefore produces, at minimum:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN(txn=42)
WRITE(txn=42, modality=DOC, ...)
WRITE(txn=42, modality=VEC, ...)
WRITE(txn=42, modality=KV,  ...)
WRITE(txn=42, modality=BLOB, blob_ref=offset_in_sidecar)
COMMIT(txn=42)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Six records, one ordered run, one &lt;code&gt;fsync&lt;/code&gt; to make the &lt;code&gt;COMMIT&lt;/code&gt; durable. The four modality engines apply their respective &lt;code&gt;WRITE&lt;/code&gt; records at replay time, in order, from the same cursor.&lt;/p&gt;
&lt;h2&gt;The lane diagram&lt;/h2&gt;
&lt;p&gt;What goes into the WAL is one question. What happens to those records on the way to and from disk is another. Here is the path:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   doc writer       vec writer       KV writer        blob writer
        |                |                |                 |
        v                v                v                 v
  +----------+     +----------+     +----------+     +-------------+
  |  encode  |     |  encode  |     |  encode  |     |  encode +   |
  |  payload |     |  payload |     |  payload |     |  hash blob  |
  +----------+     +----------+     +----------+     +------+------+
        \                \                /                 |
         \                \              /                  |
          \                \            /            +------+------+
           v                v          v             | blob bytes  |
        +---------------------------------+          |  to sidecar |
        |  shared in-memory WAL ring      |          |  (separate  |
        |  buffer (per-CPU sharded,       |          |   fsync     |
        |  global LSN assignment)         |          |   queue)    |
        +-------------------+-------------+          +------+------+
                            |                              |
                            v                              |
                  +---------------------+                  |
                  |   group commit      |  &amp;lt;--- blob ref ---+
                  |   coalescer         |
                  +---------+-----------+
                            |
                            v
                  +---------------------+
                  |   fsync(WAL file)   |
                  +---------+-----------+
                            |
                            v
                     clients ack&amp;#39;d
                  (up to COMMIT LSN)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details in that picture matter:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Per-CPU ring shards.&lt;/strong&gt; The in-memory ring is sharded by core to avoid the lock contention you get when sixteen writer threads all want to append at once. LSN assignment is the only globally-ordered step; payload encoding and CRC happen on the writer&amp;#39;s local shard before LSN handoff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The group commit coalescer.&lt;/strong&gt; Many transactions&amp;#39; &lt;code&gt;COMMIT&lt;/code&gt; records land in the same &lt;code&gt;fsync&lt;/code&gt;. A single &lt;code&gt;fsync&lt;/code&gt; becomes a single durability event for every transaction whose &lt;code&gt;COMMIT&lt;/code&gt; LSN is at or below the flushed byte offset. This is the standard Postgres / InnoDB trick and it is what keeps small-transaction throughput sane on slow disks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The blob sidecar.&lt;/strong&gt; Blob bytes never traverse the WAL ring. They go through a separate &lt;code&gt;fsync&lt;/code&gt; queue with a different priority. The WAL gets a tiny &lt;code&gt;BLOB_REF&lt;/code&gt; record that just points to where the bytes landed in the sidecar. We explain why below; it is the single biggest reason the cold-blob latency tail did not eat us.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Why the vector graph is not in the WAL&lt;/h2&gt;
&lt;p&gt;If you read the record layout carefully you will notice that a vector &lt;code&gt;WRITE&lt;/code&gt; carries the raw &lt;code&gt;f32&lt;/code&gt; bytes but no HNSW graph mutation. That is deliberate and it took us two redesigns to land on.&lt;/p&gt;
&lt;p&gt;An HNSW insert produces, in the worst case, mutations to several thousand graph edges across multiple layers. If we logged every edge mutation, the WAL bandwidth from a single 1024-dim vector insert would be 50–100× the bandwidth of the vector itself. Replay would be I/O bound on edge mutations instead of the engine&amp;#39;s own batched graph build.&lt;/p&gt;
&lt;p&gt;So the WAL stores only the input — the vector and its id — and the graph is treated as derived state. At replay time, the vector engine re-runs the insert against the graph it has in memory. This makes replay slower per vector than a &amp;quot;log everything&amp;quot; approach would be, but it makes the steady-state WAL write rate dominated by the vectors themselves, which is the bound you actually want.&lt;/p&gt;
&lt;p&gt;The cost: graph state is not point-in-time recoverable from the WAL alone. It is reconstructable from the WAL plus the most recent vector engine checkpoint. We checkpoint vector engines more aggressively than the other modalities (every five minutes by default, configurable) precisely because the replay cost is higher per record.&lt;/p&gt;
&lt;p&gt;The same trick — &amp;quot;log the input, recompute the derived state&amp;quot; — is why we do not log internal LSM compaction events for the document engine, only the logical writes. Document compaction state is rebuilt from checkpoint + WAL replay.&lt;/p&gt;
&lt;h2&gt;The fsync contention tradeoff&lt;/h2&gt;
&lt;p&gt;Now the part that bites. Every modality&amp;#39;s &lt;code&gt;COMMIT&lt;/code&gt; waits on the same &lt;code&gt;fsync&lt;/code&gt;. A &lt;code&gt;fsync&lt;/code&gt; is not free. On modern NVMe with the write cache flushed correctly (which you must, otherwise your &amp;quot;durable&amp;quot; log is a polite fiction), a single &lt;code&gt;fsync&lt;/code&gt; costs roughly 50–200 microseconds in the steady state, climbing to single-digit milliseconds when the device&amp;#39;s internal cache is dirty and being drained.&lt;/p&gt;
&lt;p&gt;Group commit amortises this for many concurrent transactions, so per-transaction &lt;code&gt;fsync&lt;/code&gt; cost is much lower than the raw call cost. But the ceiling on transactions per second is bounded by &lt;code&gt;fsync&lt;/code&gt; rate times group size. If your group size collapses — because writers are not arriving fast enough to coalesce, or because the records are huge and the group flush is slow — your tail latency grows.&lt;/p&gt;
&lt;p&gt;The pathological case is the one &lt;a href=&quot;/blog/multi-model-storage-tradeoffs&quot;&gt;the tradeoffs post&lt;/a&gt; named: a burst of blob writes lands in the same window as a steady stream of KV writes, and the KV transactions&amp;#39; &lt;code&gt;COMMIT&lt;/code&gt; is stuck waiting for the blob transactions&amp;#39; bytes to flush. That is what produced the 1.8 ms → 14 ms P99 jump we measured under load. The flush window grew because the bytes in the window grew, and small transactions paid the price for being in the same group as large ones.&lt;/p&gt;
&lt;p&gt;We are not going to pretend a shared WAL has no contention. It does. The four mitigations below are how we keep the contention from being your problem in the workloads we are appropriate for. Each one is a deliberate cost.&lt;/p&gt;
&lt;h2&gt;Mitigation 1 — Blob sidecar with pointer-only WAL records&lt;/h2&gt;
&lt;p&gt;The biggest single win. Blob bytes are not in the WAL. The WAL gets a &lt;code&gt;BLOB_REF&lt;/code&gt; record — 64 bytes including the envelope — that points to an offset and length in a separate blob sidecar file. The sidecar has its own &lt;code&gt;fsync&lt;/code&gt; queue, its own group commit coalescer, and crucially its own &lt;em&gt;priority&lt;/em&gt; relative to the WAL.&lt;/p&gt;
&lt;p&gt;The atomicity contract still works because we order the &lt;code&gt;fsync&lt;/code&gt;s: the blob sidecar &lt;code&gt;fsync&lt;/code&gt; for a transaction must return before the WAL &lt;code&gt;fsync&lt;/code&gt; that carries that transaction&amp;#39;s &lt;code&gt;COMMIT&lt;/code&gt;. So at recovery time, if you see a &lt;code&gt;COMMIT&lt;/code&gt; in the WAL, the bytes it references are guaranteed to be on disk in the sidecar. If the blob &lt;code&gt;fsync&lt;/code&gt; succeeded but the WAL &lt;code&gt;COMMIT&lt;/code&gt; &lt;code&gt;fsync&lt;/code&gt; did not, the transaction is rolled back and the blob bytes are orphaned, to be reclaimed by the sidecar&amp;#39;s GC pass. Orphan blobs are cheap to detect (sidecar offsets with no live WAL reference) and reclaim is bounded by checkpoint cadence.&lt;/p&gt;
&lt;p&gt;The cost: every blob write costs &lt;em&gt;two&lt;/em&gt; &lt;code&gt;fsync&lt;/code&gt;s instead of one. For a workload dominated by tiny KV writes with occasional large blobs, that is a great trade — small writes are no longer queued behind large ones, and the second &lt;code&gt;fsync&lt;/code&gt; for the blob is absorbed by a dedicated thread that the KV path never touches. For a workload of nothing but small blobs, it is a worse trade than putting them inline, and we set the inline-vs-sidecar threshold at 32 KB by default for that reason. Below 32 KB, blobs go through the WAL like everyone else.&lt;/p&gt;
&lt;h2&gt;Mitigation 2 — Group commit with adaptive batching&lt;/h2&gt;
&lt;p&gt;Group commit is not novel. The tuning is the part that matters.&lt;/p&gt;
&lt;p&gt;The coalescer has two thresholds: a time deadline (default 500 µs) and a byte budget (default 1 MB). A flush fires when either threshold is hit. On a cluster with high small-transaction throughput, the byte budget is what bounds latency — you accumulate 1 MB of &lt;code&gt;COMMIT&lt;/code&gt; records in well under 500 µs and flush. On a quiet cluster, the time deadline kicks in and a lone transaction waits at most 500 µs before its &lt;code&gt;COMMIT&lt;/code&gt; is durable.&lt;/p&gt;
&lt;p&gt;What we added on top is a feedback loop on the deadline. If the previous flush took longer than the deadline to &lt;em&gt;complete&lt;/em&gt; (because the disk was busy), the next deadline is doubled, up to a ceiling of 4 ms. If flushes are consistently completing well under the deadline, it is halved, down to a floor of 100 µs. This keeps the flush rate matched to what the disk can actually sustain, which prevents a death-spiral where each flush starts before the previous one finishes and the queue grows without bound.&lt;/p&gt;
&lt;p&gt;You can disable adaptive batching with &lt;code&gt;wal_group_commit_adaptive = false&lt;/code&gt; if you want predictable behaviour for benchmarks. Most operators leave it on.&lt;/p&gt;
&lt;h2&gt;Mitigation 3 — &lt;code&gt;wal_priority&lt;/code&gt; hint on the write path&lt;/h2&gt;
&lt;p&gt;Some writes are happy to commit a little later if it means small writes commit on time. Background ingestion, log-style audit writes, blob commits where the application has already shown the user &amp;quot;uploading…&amp;quot; — none of these need sub-millisecond commit latency.&lt;/p&gt;
&lt;p&gt;The write API takes an optional &lt;code&gt;wal_priority&lt;/code&gt; hint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;await db.transaction(async (tx) =&amp;gt; {
  await tx.docs.put(&amp;quot;conversations&amp;quot;, convId, conversation)
  await tx.kv.set(&amp;quot;counters/messages&amp;quot;, count)
  await tx.blobs.put(&amp;quot;attachments&amp;quot;, attachmentId, blobBytes, {
    wal_priority: &amp;quot;background&amp;quot;,  // &amp;lt;-- hint
  })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;code&gt;background&lt;/code&gt; priority transaction&amp;#39;s &lt;code&gt;COMMIT&lt;/code&gt; is allowed to coalesce into the &lt;em&gt;next&lt;/em&gt; group commit window instead of the current one. In practice that adds anywhere from 100 µs to 4 ms of latency to the background transaction, and it removes that transaction&amp;#39;s bytes from the group that the foreground transactions are queued behind. The application still sees an atomic transaction; the &lt;code&gt;fsync&lt;/code&gt; lane is just different.&lt;/p&gt;
&lt;p&gt;The hint is opt-in. We do not auto-classify writes as foreground or background — the application knows which user-visible latency budget the write is on, and we are not going to guess.&lt;/p&gt;
&lt;h2&gt;Mitigation 4 — Per-tenant WAL bandwidth quotas&lt;/h2&gt;
&lt;p&gt;In a multi-tenant deployment, a single noisy tenant can starve every other tenant by saturating the shared WAL. We measured this once on staging — a single customer&amp;#39;s bulk import drove P99 KV latency for &lt;em&gt;every other tenant on the node&lt;/em&gt; to 80 ms — and it convinced us to ship fair-share quotas before the next customer hit it in production.&lt;/p&gt;
&lt;p&gt;The quota is enforced at the WAL ring entry point. Each tenant has a configurable byte-per-second budget (defaults derived from the plan tier) and a burst allowance. When a tenant exceeds budget, their writes are queued in a per-tenant overflow ring that drains into the shared WAL only when shared bandwidth is available. Other tenants are not blocked by an overflowing tenant&amp;#39;s queue depth.&lt;/p&gt;
&lt;p&gt;The scheduling is weighted round-robin across tenant rings at the shared-ring entry, not strict priority, so a fully-loaded shared WAL still gives every active tenant proportional throughput rather than starving small tenants entirely. This is the model we mean when we say &amp;quot;fair-share&amp;quot; in the customer docs; the previous round-robin-only scheduler did starve small tenants under load, and we changed it after the same staging incident.&lt;/p&gt;
&lt;h2&gt;What this gets you&lt;/h2&gt;
&lt;p&gt;The point of all this machinery is that the application layer can write the obvious code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;await db.transaction(async (tx) =&amp;gt; {
  // Document write
  const conv = await tx.docs.put(&amp;quot;conversations&amp;quot;, convId, {
    title, participants, updated_at: now(),
  })
  // Vector insert (chat embedding)
  await tx.vecs.upsert(&amp;quot;conversations.embeddings&amp;quot;, convId, embedding)
  // KV counter
  await tx.kv.incr(`counters/messages/${userId}`)
  // Blob commit (attachment)
  await tx.blobs.put(&amp;quot;attachments&amp;quot;, attachmentId, fileBytes)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;…and have it be a single transaction. If the process dies between any two of those lines, none of them are visible. If it dies after the &lt;code&gt;COMMIT&lt;/code&gt; returns, all of them are. There is no two-phase commit between four engines. There is no compensating write to roll back the vector if the blob upload fails. There is one ordered log, one &lt;code&gt;fsync&lt;/code&gt;, one cursor.&lt;/p&gt;
&lt;p&gt;This is the thing the stitched stack — Postgres + pgvector + S3 + Redis — cannot give you. You can come close, with careful application-layer patterns and idempotency keys everywhere, and a lot of teams do. But the consistency you get is the consistency you write, not the consistency the storage gives you. The thing we are selling is the inversion of that.&lt;/p&gt;
&lt;h2&gt;When you should still pick a stitched stack&lt;/h2&gt;
&lt;p&gt;The shared WAL is the feature &lt;em&gt;and&lt;/em&gt; the cost. It is the right architecture for workloads where the seams between modalities are where the bugs live. It is the wrong architecture if the seams are not where your bugs live and your single-modality tuning headroom matters more.&lt;/p&gt;
&lt;p&gt;Concretely, do not buy us if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your workload is dominated by 100+ MB blob writes at high concurrency and you also need sub-5 ms reads on the same cluster. The blob sidecar mitigates this but does not eliminate it; the disk is still shared.&lt;/li&gt;
&lt;li&gt;You need to tune a vector workload to recall-at-99.5% on an unusual embedding model and you know which HNSW knobs you would change. We do not expose them per-collection.&lt;/li&gt;
&lt;li&gt;Your isolation requirements are strict serializable and the &lt;a href=&quot;/blog/snapshot-isolation-in-practice&quot;&gt;write-skew workarounds we already documented&lt;/a&gt; are not acceptable to your domain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For everyone else — which in our experience is most teams shipping AI-adjacent applications where the data crosses modalities on every request — the shared WAL is the boring choice. We meant it to be boring. Storage that surprises you is storage you cannot operate.&lt;/p&gt;
&lt;h2&gt;What lives in this post and what does not&lt;/h2&gt;
&lt;p&gt;This post is the WAL. Not the rest of the engine. If you want the surrounding context, three companion posts cover the pieces this one assumes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/blog/multi-model-storage-tradeoffs&quot;&gt;The tradeoffs we made to fit four engines in one&lt;/a&gt; — the bill for sharing a WAL, in language a buyer reads before signing.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/snapshot-isolation-in-practice&quot;&gt;Snapshot isolation, in practice&lt;/a&gt; — what the consistency model on top of this WAL actually gives an application, and what it does not.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/stop-stitching&quot;&gt;Stop stitching Postgres, pgvector, S3, and Redis&lt;/a&gt; — the case for why a shared WAL is worth its bill in the first place.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The deeper specs — the recovery state machine, the replication protocol that reads from the same LSN cursor, the snapshot-and-restore protocol that aligns to checkpoint LSNs — will get their own posts. They all build on the record layout above. If you only remember one diagram from this post, make it the lane diagram. Everything else is implementation.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/postgres-pgvector-to-reddb-migration-playbook</id>
    <title>Postgres + pgvector → RedDB: a migration playbook with wall-clock numbers</title>
    <link href="https://reddb.io/blog/postgres-pgvector-to-reddb-migration-playbook"/>
    <updated>2026-05-17</updated>
    <published>2026-05-17</published>
    <author><name>RedDB team</name></author>
    <summary>A step-by-step playbook for moving a production Postgres + pgvector workload to RedDB — dual-write, embedding backfill, query translation, cutover, rollback — with measured timings from a 12M-row test corpus and the three places teams trip over.</summary>
    <content type="html">&lt;p&gt;The most common RedDB migration we see is from Postgres with the &lt;code&gt;pgvector&lt;/code&gt; extension bolted on. The shape is familiar: a primary OLTP schema, an &lt;code&gt;embeddings&lt;/code&gt; table with an &lt;code&gt;HNSW&lt;/code&gt; or &lt;code&gt;IVFFlat&lt;/code&gt; index, a RAG pipeline that does a vector search joined back to source rows, and an ops team that has started to notice that every schema change to the embeddings table now takes a maintenance window. This post is the playbook we hand to teams making that move.&lt;/p&gt;
&lt;p&gt;Numbers below are from a representative test corpus we ran against ourselves before publishing: 12.4M rows in the source table, 1536-dim OpenAI-style embeddings, hot working set ~30 GB. Your numbers will differ — what matters is the &lt;em&gt;ratio&lt;/em&gt; between the phases, which is stable across the migrations we have shipped. Treat the absolute milliseconds and minutes as a sanity check, not a contract.&lt;/p&gt;
&lt;h2&gt;The shape of the source&lt;/h2&gt;
&lt;p&gt;Almost every Postgres + pgvector workload we have migrated has this skeleton:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE documents (
  id           bigserial PRIMARY KEY,
  tenant_id    text NOT NULL,
  uri          text NOT NULL,
  body         text NOT NULL,
  metadata     jsonb NOT NULL DEFAULT &amp;#39;{}&amp;#39;::jsonb,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE chunks (
  id           bigserial PRIMARY KEY,
  document_id  bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  chunk_idx    int NOT NULL,
  body         text NOT NULL,
  embedding    vector(1536),
  model        text NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX chunks_embedding_hnsw
  ON chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hot query is a tenant-scoped top-k followed by a join back to the source document:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT c.id, c.document_id, c.body, d.uri,
       1 - (c.embedding &amp;lt;=&amp;gt; $1) AS score
FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE c.tenant_id = $2
ORDER BY c.embedding &amp;lt;=&amp;gt; $1
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your workload looks meaningfully different from this — for example, you do not have a tenant column, or you store embeddings in a sibling service — most of what follows still applies, but adapt the dual-write step to your topology.&lt;/p&gt;
&lt;h2&gt;Phase 0 — pre-flight (1 day)&lt;/h2&gt;
&lt;p&gt;Before touching either database, write down four numbers. We have not yet seen a migration where these were &lt;em&gt;all&lt;/em&gt; known up front, and we have seen migrations stall when they were not.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Embedding cost to rebuild from scratch.&lt;/strong&gt; Rows × tokens-per-row × $/million-tokens. If this number is more than ~10% of one month of your inference budget, plan to copy embeddings, not regenerate them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read QPS, p50/p95/p99 latency.&lt;/strong&gt; From the actual production load balancer, not from a benchmark. The cutover budget is set by your p99.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write QPS, including the burstiest hour.&lt;/strong&gt; Dual-write must survive this without falling behind.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total bytes on disk for &lt;code&gt;chunks.embedding&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;SELECT pg_size_pretty(pg_total_relation_size(&amp;#39;chunks_embedding_hnsw&amp;#39;))&lt;/code&gt;). This sets the wall-clock for the bulk copy.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For our test corpus those numbers were: ~$1,800 to re-embed, 340 read QPS at p95 = 22 ms, write peak 90 QPS, embedding index = 41 GB. We copied embeddings rather than regenerating them. Almost every team should.&lt;/p&gt;
&lt;h2&gt;Phase 1 — schema in RedDB (1 hour)&lt;/h2&gt;
&lt;p&gt;The translation is almost mechanical because RedDB speaks the same SQL dialect for table DDL. Two real differences matter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE documents (
  id           bigserial PRIMARY KEY,
  tenant_id    text NOT NULL,
  uri          text NOT NULL,
  body         text NOT NULL,
  metadata     jsonb NOT NULL DEFAULT &amp;#39;{}&amp;#39;::jsonb,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE chunks (
  id           bigserial PRIMARY KEY,
  document_id  bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  tenant_id    text NOT NULL,          -- denormalised, see below
  chunk_idx    int NOT NULL,
  body         text NOT NULL,
  embedding    vector(1536),
  model        text NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX chunks_tenant_idx ON chunks (tenant_id);

CREATE INDEX chunks_embedding_ann
  ON chunks USING vector (embedding cosine)
  WITH (lists = 2048, ef_search = 64);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two differences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;tenant_id&lt;/code&gt; is denormalised onto &lt;code&gt;chunks&lt;/code&gt;.&lt;/strong&gt; In pgvector you can filter by tenant via a join, but the planner often picks the vector index first and applies the filter as a post-condition, which is slow when the tenant is selective. Denormalising the column lets RedDB push the predicate down into the ANN search.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The vector index is RedDB&amp;#39;s native ANN, not HNSW-via-extension.&lt;/strong&gt; The &lt;code&gt;WITH&lt;/code&gt; parameters look similar (&lt;code&gt;lists&lt;/code&gt;, &lt;code&gt;ef_search&lt;/code&gt;) but they mean different things — see &lt;a href=&quot;/blog/2026-05-12-rag-without-second-database&quot;&gt;the RAG-without-a-second-database post&lt;/a&gt; for the long-form on tuning. Default to the values above for 1536-dim, ~10M-vector workloads; revisit only if recall is off.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Build the schema, run smoke writes, and confirm the ANN index plan with &lt;code&gt;EXPLAIN&lt;/code&gt;. Do not run the migration until you can see the ANN index getting picked.&lt;/p&gt;
&lt;h2&gt;Phase 2 — dual-write (3–5 days, calendar)&lt;/h2&gt;
&lt;p&gt;The dual-write window is the lever you pay rent on for the entire migration. Keep it short.&lt;/p&gt;
&lt;p&gt;We dual-write at the application layer, not in the database, for two reasons: (a) you keep one transactional boundary per database, which means rollback is just &amp;quot;stop writing to RedDB&amp;quot;, and (b) you can shed RedDB writes if it falls behind without disturbing Postgres.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Pseudocode — adapt to your data access layer.
async function writeChunk(input: ChunkInput): Promise&amp;lt;void&amp;gt; {
  const id = await pg.insertChunkReturningId(input)        // source of truth

  // Best-effort secondary write. Failures go to a retry queue,
  // not back to the user.
  void reddbWriteQueue.enqueue({ id, input })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two non-obvious requirements:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The retry queue must preserve order per &lt;code&gt;document_id&lt;/code&gt;.&lt;/strong&gt; Out-of-order writes will leave you with a chunk in RedDB whose &lt;code&gt;chunk_idx&lt;/code&gt; is ahead of a chunk that has not landed yet, and your top-k will return holes. Partition the queue by &lt;code&gt;document_id&lt;/code&gt; (or by &lt;code&gt;id&lt;/code&gt;, if you never update chunks).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backfill from Postgres LSN, not from &lt;code&gt;created_at&lt;/code&gt;.&lt;/strong&gt; A &lt;code&gt;created_at&lt;/code&gt;-based backfill misses any row that was inserted between your initial &lt;code&gt;SELECT max(created_at)&lt;/code&gt; and the dual-write going live. Use &lt;code&gt;pg_export_snapshot()&lt;/code&gt; to anchor the bulk copy and start the live writer at the snapshot&amp;#39;s LSN.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For the 12M-row test corpus, dual-write ran for four calendar days. Lag stayed under 90 seconds at the worst (the daily reindex run on the source) and under 4 seconds at p95.&lt;/p&gt;
&lt;h2&gt;Phase 3 — bulk copy with embedding preservation (8–14 hours of wall-clock for ~10M rows)&lt;/h2&gt;
&lt;p&gt;This is the longest single step. The naïve approach — &lt;code&gt;SELECT * FROM chunks&lt;/code&gt; driving &lt;code&gt;INSERT INTO chunks&lt;/code&gt; over the wire — will work but takes about 4× longer than necessary and pegs both databases. Do the boring thing instead.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. Snapshot Postgres to a COPY-formatted dump with embeddings as bytea.
psql &amp;quot;$PG_URL&amp;quot; -c &amp;quot;\
  COPY (
    SELECT id, document_id, tenant_id, chunk_idx, body,
           embedding::text AS embedding_text, model, created_at
    FROM chunks
    WHERE created_at &amp;lt;= &amp;#39;${SNAPSHOT_TS}&amp;#39;
  ) TO STDOUT WITH (FORMAT csv, HEADER true)&amp;quot; \
  | zstd -3 -T0 &amp;gt; chunks-${SNAPSHOT_TS}.csv.zst

# 2. Stream into RedDB. Use COPY, not INSERT.
zstd -d -c chunks-${SNAPSHOT_TS}.csv.zst \
  | psql &amp;quot;$RDB_URL&amp;quot; -c &amp;quot;\
    COPY chunks (id, document_id, tenant_id, chunk_idx, body,
                 embedding, model, created_at)
    FROM STDIN WITH (FORMAT csv, HEADER true)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things we wish we had known the first time we did this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;COPY&lt;/code&gt; is the only thing that matters for throughput.&lt;/strong&gt; On the test corpus, &lt;code&gt;COPY&lt;/code&gt;-based bulk load ran at ~280k rows/min. The same data via &lt;code&gt;INSERT&lt;/code&gt; batched 1000 at a time ran at ~38k rows/min. Same disk, same network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build the ANN index &lt;em&gt;after&lt;/em&gt; the load, not before.&lt;/strong&gt; Building during load triples wall-clock and produces a worse index. We use &lt;code&gt;CREATE INDEX&lt;/code&gt; post-load, which took 42 minutes on the test corpus.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;zstd -3&lt;/code&gt; is the right compression level for a network-bound copy.&lt;/strong&gt; Higher levels save little additional bandwidth but cost real CPU. Lower levels saturate the disk.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For documents, the same approach — &lt;code&gt;COPY&lt;/code&gt; out, compress, &lt;code&gt;COPY&lt;/code&gt; in — runs in about a quarter of the chunk time because the rows are smaller and the index footprint is trivial.&lt;/p&gt;
&lt;h2&gt;Phase 4 — query translation (1–2 days)&lt;/h2&gt;
&lt;p&gt;Most queries port unchanged. The patterns that need attention:&lt;/p&gt;
&lt;h3&gt;Vector search with tenant filter&lt;/h3&gt;
&lt;p&gt;In pgvector the idiomatic version is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT c.id, c.document_id, c.body,
       1 - (c.embedding &amp;lt;=&amp;gt; $1) AS score
FROM chunks c
WHERE c.tenant_id = $2
ORDER BY c.embedding &amp;lt;=&amp;gt; $1
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In RedDB, with &lt;code&gt;tenant_id&lt;/code&gt; denormalised onto &lt;code&gt;chunks&lt;/code&gt;, the literal translation works and the plan changes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, document_id, body,
       1 - (embedding &amp;lt;=&amp;gt; $1) AS score
FROM chunks
WHERE tenant_id = $2
ORDER BY embedding &amp;lt;=&amp;gt; $1
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The join back to &lt;code&gt;documents&lt;/code&gt; is usually cheaper as a second round-trip than as a SQL JOIN, because RedDB&amp;#39;s planner cannot push the JOIN below the ANN scan and you end up materialising the full top-k before the join. On the test corpus, splitting the query dropped p95 from 31 ms to 19 ms. If your wrapper library makes the round-trip awkward, leave the JOIN in — the absolute difference is small.&lt;/p&gt;
&lt;h3&gt;Hybrid (vector + lexical) search&lt;/h3&gt;
&lt;p&gt;If you used pgvector&amp;#39;s &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; together with &lt;code&gt;tsvector @@&lt;/code&gt;, the translation is almost identical, but in RedDB the planner will pick a parallel scan strategy if you wrap the two scores in a CTE:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH
  vec AS (
    SELECT id, 1 - (embedding &amp;lt;=&amp;gt; $1) AS vscore
    FROM chunks
    WHERE tenant_id = $2
    ORDER BY embedding &amp;lt;=&amp;gt; $1
    LIMIT 200
  ),
  lex AS (
    SELECT id, ts_rank(search_v, plainto_tsquery(&amp;#39;english&amp;#39;, $3)) AS lscore
    FROM chunks
    WHERE tenant_id = $2 AND search_v @@ plainto_tsquery(&amp;#39;english&amp;#39;, $3)
    LIMIT 200
  )
SELECT c.id, c.body,
       coalesce(v.vscore, 0) * 0.7 + coalesce(l.lscore, 0) * 0.3 AS score
FROM chunks c
LEFT JOIN vec v ON v.id = c.id
LEFT JOIN lex l ON l.id = c.id
WHERE v.id IS NOT NULL OR l.id IS NOT NULL
ORDER BY score DESC
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;LIMIT 200&lt;/code&gt; per branch is the lever for recall vs. latency. We start at 200 and tune down only if a recall regression test passes.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;ANALYZE&lt;/code&gt; after the copy&lt;/h3&gt;
&lt;p&gt;The most common &amp;quot;RedDB is slow&amp;quot; support ticket on a fresh migration is &amp;quot;we forgot to ANALYZE.&amp;quot; Run it once after the bulk copy and once after the ANN index build. It takes single-digit minutes on a 12M-row table.&lt;/p&gt;
&lt;h2&gt;Phase 5 — shadow reads (2–4 days)&lt;/h2&gt;
&lt;p&gt;Do not flip the read traffic on a Tuesday morning. Shadow-read first.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;async function search(query: SearchInput): Promise&amp;lt;SearchResult[]&amp;gt; {
  const primary = await pg.search(query)

  if (sampler.shouldShadow()) {
    void compareInBackground(query, primary)   // never blocks the response
  }

  return primary
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shadow compare logs three numbers per request: (a) recall@k between Postgres and RedDB top-k, (b) p99 latency difference, (c) any query that errored on one but not the other. We aim for recall ≥ 0.97 against the Postgres baseline before flipping reads. Our test corpus took 36 hours of shadow traffic to converge there — most of the gap was queries with very small tenants, which is where ANN parameters matter most.&lt;/p&gt;
&lt;h2&gt;Phase 6 — cutover (15 minutes)&lt;/h2&gt;
&lt;p&gt;A short, well-instrumented cutover with a one-command rollback. Nothing else.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;READS_FROM=reddb&lt;/code&gt; in the feature flag service (5s).&lt;/li&gt;
&lt;li&gt;Watch p95/p99 search latency and error rate for 10 minutes.&lt;/li&gt;
&lt;li&gt;If anything looks wrong, set &lt;code&gt;READS_FROM=postgres&lt;/code&gt;. You are back where you were because dual-write is still running.&lt;/li&gt;
&lt;li&gt;After 24 hours of clean metrics, stop writing to Postgres. Keep it as a hot read replica for one more week.&lt;/li&gt;
&lt;li&gt;After the week, decommission.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;On the test corpus the cutover itself was uneventful: 12 minutes from flag flip to &amp;quot;we&amp;#39;re going to lunch.&amp;quot; The interesting work was Phases 2–5; the cutover is just the moment you collect on it.&lt;/p&gt;
&lt;h2&gt;Rollback plan, explicitly&lt;/h2&gt;
&lt;p&gt;Rollback is a property of the playbook, not a separate document. The phases above were designed so that at each step you can stop and either back out or hold.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase ends&lt;/th&gt;
&lt;th&gt;If it goes wrong, you …&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Phase 1 (schema)&lt;/td&gt;
&lt;td&gt;Drop the RedDB tables. No traffic, no data, no consequence.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase 2 (dual-write)&lt;/td&gt;
&lt;td&gt;Disable the secondary writer. Postgres is the source of truth and untouched.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase 3 (bulk copy)&lt;/td&gt;
&lt;td&gt;Truncate the RedDB tables and restart the copy. The snapshot LSN is your idempotency.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase 4 (queries)&lt;/td&gt;
&lt;td&gt;Keep the old query layer behind the same flag. Roll back at the application boundary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase 5 (shadow read)&lt;/td&gt;
&lt;td&gt;Stop shadowing. No user-visible effect.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase 6 (cutover)&lt;/td&gt;
&lt;td&gt;Flip the read flag back. Dual-write is still running, so no data is lost.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The only step that is hard to reverse is Phase 6 &lt;em&gt;after&lt;/em&gt; you have stopped writing to Postgres. That is why &amp;quot;keep writing to Postgres for 24 hours after cutover&amp;quot; is in the playbook with full caps and an exclamation point in our internal version.&lt;/p&gt;
&lt;h2&gt;The three places teams trip&lt;/h2&gt;
&lt;p&gt;If you take three things from this post:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Anchor the backfill on an LSN snapshot, not a wall-clock timestamp.&lt;/strong&gt; Otherwise you will find phantom missing rows weeks later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build the ANN index after the bulk load.&lt;/strong&gt; Doing it during load is the single biggest preventable wall-clock cost of the migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep dual-write running for 24 hours after the read cutover.&lt;/strong&gt; Rollback without it is a restore-from-backup; with it, it is a flag flip.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Everything else is mechanical.&lt;/p&gt;
&lt;h2&gt;What we measured, in one table&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Test-corpus wall-clock&lt;/th&gt;
&lt;th&gt;What gates it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;0 — pre-flight&lt;/td&gt;
&lt;td&gt;~1 day&lt;/td&gt;
&lt;td&gt;Stakeholder sign-off on the four numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 — schema&lt;/td&gt;
&lt;td&gt;~1 hour&lt;/td&gt;
&lt;td&gt;DDL only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — dual-write live&lt;/td&gt;
&lt;td&gt;4 days calendar&lt;/td&gt;
&lt;td&gt;Queue lag stays bounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — bulk copy + index build&lt;/td&gt;
&lt;td&gt;11h 24m total&lt;/td&gt;
&lt;td&gt;Disk + network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — query translation&lt;/td&gt;
&lt;td&gt;1.5 days&lt;/td&gt;
&lt;td&gt;Engineering effort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 — shadow read&lt;/td&gt;
&lt;td&gt;~36 hours&lt;/td&gt;
&lt;td&gt;Recall convergence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 — cutover&lt;/td&gt;
&lt;td&gt;12 minutes&lt;/td&gt;
&lt;td&gt;Flag flip + watch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;If your corpus is meaningfully bigger than 12M rows or your embeddings are wider than 1536 dims, scale Phase 3 roughly linearly with bytes and budget a longer Phase 5. The other phases are largely independent of corpus size.&lt;/p&gt;
&lt;p&gt;If you are in the middle of a migration that does not look like this, send us the shape — there is a good chance we have a variation of the playbook for it.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/threat-model-what-reddb-protects-against</id>
    <title>Threat model: what RedDB protects against (and what it doesn&apos;t)</title>
    <link href="https://reddb.io/blog/threat-model-what-reddb-protects-against"/>
    <updated>2026-05-17</updated>
    <published>2026-05-17</published>
    <author><name>RedDB team</name></author>
    <summary>An honest STRIDE walkthrough of the RedDB engine and vault. What the database protects against (cold-storage breach, replicated-secret leakage, key compromise during rotation) and what it does not (compromised application process, host-level side channels, malicious operator). Gaps are named, not hidden.</summary>
    <content type="html">&lt;p&gt;Most database security marketing reads like the result is the same regardless of the question: &lt;em&gt;encrypted at rest, encrypted in transit, role-based access control, audit logs.&lt;/em&gt; Tick, tick, tick, tick. That tells you nothing about which attacks fail and which ones succeed against the system you are about to put production data into.&lt;/p&gt;
&lt;p&gt;This post is the opposite shape. It walks the RedDB engine and the &lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;vault&lt;/a&gt; through STRIDE, names the attacker for each category, and at each step says one of two things: &lt;em&gt;we stop this&lt;/em&gt; or &lt;em&gt;we do not stop this — here is what does.&lt;/em&gt; The &amp;quot;do not&amp;quot; list is the important half. If your threat model includes one of those attackers, RedDB on its own is not your answer and you should know that before signing the contract, not after.&lt;/p&gt;
&lt;p&gt;We have had this conversation with security leads at every regulated-industry buyer we have talked to. The shape of the conversation is always the same: they want to know where the boundary is. So here is the boundary, written down.&lt;/p&gt;
&lt;h2&gt;The boundary, stated once&lt;/h2&gt;
&lt;p&gt;RedDB defends the &lt;strong&gt;data at rest&lt;/strong&gt; and the &lt;strong&gt;bytes on the wire between replicas&lt;/strong&gt;. Anything that happens &lt;em&gt;inside&lt;/em&gt; a process holding a valid in-memory key — including the RedDB process itself — is outside the boundary. That is not a hedge; it is the design.&lt;/p&gt;
&lt;p&gt;Three concrete consequences fall out of that one sentence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A stolen disk, snapshot, or backup is useless without the master key.&lt;/strong&gt; Cold-storage breach is the attack we put the most engineering into stopping.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A compromised application process that holds a live decryption key is game over for the rows that key opens.&lt;/strong&gt; No database-level control fixes this. The fix lives in your process isolation, secrets handling, and BYOK story.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A malicious operator with shell on a running RedDB node can read decrypted hot data.&lt;/strong&gt; Defence here is operational (least privilege, &lt;a href=&quot;/blog/byok-kms-multitenant-secrets/&quot;&gt;BYOK with customer-held KMS&lt;/a&gt;, audit on the KMS side) — not cryptographic.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Hold those three in your head; the STRIDE walk below is just the long form.&lt;/p&gt;
&lt;h2&gt;STRIDE, one row at a time&lt;/h2&gt;
&lt;p&gt;STRIDE is the six-category threat taxonomy from Microsoft: &lt;strong&gt;S&lt;/strong&gt;poofing, &lt;strong&gt;T&lt;/strong&gt;ampering, &lt;strong&gt;R&lt;/strong&gt;epudiation, &lt;strong&gt;I&lt;/strong&gt;nformation disclosure, &lt;strong&gt;D&lt;/strong&gt;enial of service, &lt;strong&gt;E&lt;/strong&gt;levation of privilege. It is not the only frame, but it is the one most security leads have already read, so it is the one we use when they ask.&lt;/p&gt;
&lt;p&gt;For each category we name the attacker, what they can do, and whether the engine stops them.&lt;/p&gt;
&lt;h3&gt;S — Spoofing&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Attacker: a network peer impersonating a legitimate RedDB node or client.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Replica-to-replica spoofing.&lt;/strong&gt; Replicas authenticate to each other with mTLS using a cluster-issued certificate authority. A peer without a cert signed by that CA cannot join the gossip ring, cannot receive replication traffic, and cannot serve reads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Client-to-cluster spoofing.&lt;/strong&gt; Client connections are mTLS by default in production profile. The control plane issues short-lived client certs scoped to a tenant; a stolen long-lived API token alone is not sufficient if the operator has enabled the production profile (it is the default; turning it off is a deliberate config change that is logged).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What we do not stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A compromised CA.&lt;/strong&gt; If your private CA is exfiltrated, the attacker can mint valid certs and the engine cannot tell them from a real client. Defence is CA isolation (HSM-backed, ideally) and short cert lifetimes so a leak has a bounded blast radius. This is a property of every mTLS system; we do not pretend to fix it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-process impersonation.&lt;/strong&gt; If a compromised application process has access to your client certificate and key, it &lt;em&gt;is&lt;/em&gt; a legitimate client from the engine&amp;#39;s perspective. See &amp;quot;I — Information disclosure&amp;quot; below.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;T — Tampering&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Attacker: someone modifying data at rest, in transit, or in a backup.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wire tampering between replicas.&lt;/strong&gt; mTLS provides integrity in transit; a flipped bit on the wire fails the TLS MAC and the connection drops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;At-rest tampering of encrypted rows.&lt;/strong&gt; Each per-row envelope &lt;code&gt;(g, iv, ciphertext)&lt;/code&gt; uses an AEAD (AES-GCM by default). A modified ciphertext fails authentication on decrypt and the read returns an error rather than corrupt plaintext. This holds whether the modification happened in the SSTable file, in a backup, or in a snapshot copy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;At-rest tampering of unencrypted columns.&lt;/strong&gt; SSTables carry block-level checksums (xxh3) that are verified on read. A bit-flip in a non-vault column surfaces as a read error, not silently corrupt output.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What we do not stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A privileged operator with write access to the disk path.&lt;/strong&gt; Truncating an SSTable, deleting a WAL segment, or rolling back a snapshot is indistinguishable from legitimate ops at the cryptographic layer. Defence is file-system permissions and an immutable backup destination (object-lock S3, or equivalent). The engine assumes its own disk is trusted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A compromised application that issues legitimate writes.&lt;/strong&gt; If the attacker can call &lt;code&gt;PUT&lt;/code&gt;, the writes are authentic from the engine&amp;#39;s perspective. This is intentional — the engine is not in the business of policing application-level semantics.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;R — Repudiation&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Attacker: someone who wants to deny they did a thing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Write attribution.&lt;/strong&gt; Every mutation carries the client identity (subject DN from the mTLS cert) into a transactional audit log. The audit log is written in the same transaction as the data and is one of the streams the &lt;a href=&quot;/blog/hooks-transactional-audit/&quot;&gt;hooks layer&lt;/a&gt; emits; if the write commits, the audit row commits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit log tampering.&lt;/strong&gt; Audit rows are vault-encrypted with a generation-pinned key separate from the application key, so an operator who can rotate the application key cannot quietly rewrite history.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What we do not stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;An attacker who has the legitimate client cert.&lt;/strong&gt; The audit log will faithfully attribute their writes to whoever owns that cert. Repudiation defence at this layer is identity hygiene, not cryptography.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backdated entries written by a compromised RedDB process.&lt;/strong&gt; The audit log is not externally notarised. If you need stronger non-repudiation (regulator-grade), stream the audit log to an append-only external system (S3 with object lock, a managed audit service) and treat the engine&amp;#39;s copy as a cache. We have customers doing this; it is wired through the same hooks API.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;I — Information disclosure&lt;/h3&gt;
&lt;p&gt;This is the category most buyers care about and the one with the most nuance. We split it by where the data lives.&lt;/p&gt;
&lt;h4&gt;I.1 — Data at rest, on RedDB&amp;#39;s own disks&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Attacker: someone who walks off with the disk, the snapshot, or the backup.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every row in a vault-protected column is AES-GCM encrypted with a DEK wrapped by the current master key generation. The disk on its own is ciphertext.&lt;/li&gt;
&lt;li&gt;WAL segments containing those rows are encrypted with the same envelope before fsync. A leaked WAL does not leak plaintext.&lt;/li&gt;
&lt;li&gt;Backups are written through the same vault path. Restoring a backup requires access to the master key generations that were active when the backup was taken; the backup file on its own is useless.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What we do not stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unencrypted columns.&lt;/strong&gt; The vault is opt-in per column. If a column is not in the vault config, its plaintext is on disk. This is a deliberate trade — full-database encryption defeats most engine-level features (sort, hybrid search, filters that read the column). Pick what to encrypt; we will not pick for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An attacker who steals the disk &lt;em&gt;and&lt;/em&gt; the live master key.&lt;/strong&gt; If your KMS credential file is on the same machine as your data files, theft of the machine is theft of both. See BYOK and &lt;a href=&quot;/blog/byok-kms-multitenant-secrets/&quot;&gt;external KMS&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;I.2 — Data in transit&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Attacker: a network observer between replicas or between client and cluster.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;mTLS, modern cipher suites, no TLS 1.0/1.1, no static-key Diffie-Hellman. Wire is opaque.&lt;/li&gt;
&lt;li&gt;Replication includes ciphertext from the vault path, not plaintext re-encrypted at the link. A passive network observer on the replication link sees the same opaque bytes the disk holds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What we do not stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A man-in-the-middle who has a valid cert.&lt;/strong&gt; Same caveat as Spoofing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Side-channel disclosure via traffic shape.&lt;/strong&gt; An observer who sees the size and timing of replication packets can learn something about write rate per tenant. This is true of every replicated database and is not part of our threat model.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;I.3 — Data in memory, in the RedDB process&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Attacker: someone with shell on a running RedDB node.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we &lt;em&gt;do not&lt;/em&gt; stop, and we want this called out plainly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Hot rows in the RedDB process memory are plaintext after decrypt. A root-shelled attacker can &lt;code&gt;gcore&lt;/code&gt; the process and recover plaintext for any data the process has recently read.&lt;/li&gt;
&lt;li&gt;Master keys are in process memory while the process is running. We zero them on shutdown and on rotation, but a live process holds live keys.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Defences (operational, not cryptographic):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Least privilege: nobody has interactive shell on production RedDB nodes; access is brokered, audited, time-boxed.&lt;/li&gt;
&lt;li&gt;BYOK with a customer-held KMS turns this from &amp;quot;the vendor can read your data if compromised&amp;quot; into &amp;quot;the vendor can read your data &lt;em&gt;only while a session against your KMS is active&lt;/em&gt;&amp;quot;. The customer can pull the KMS grant and the next key-cache eviction renders the engine unable to decrypt new generations.&lt;/li&gt;
&lt;li&gt;HSM-backed KMS with remote-unwrap (HYOK) reduces this further; we mention it but it is not the default.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;I.4 — Data in memory, in the application process&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Attacker: anyone who can compromise your application.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop: nothing. This is outside the boundary. The vault gives you plaintext when you ask for it through a legitimate client. A compromised application is a legitimate client.&lt;/p&gt;
&lt;p&gt;Defences: yours. Process isolation, secrets management, request-scoped decryption (only decrypt the row the current request needs), and BYOK so a panic-revoke is possible.&lt;/p&gt;
&lt;h3&gt;D — Denial of service&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Attacker: someone trying to make the database stop serving traffic.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop or mitigate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Per-tenant resource quotas.&lt;/strong&gt; A noisy tenant cannot eat the whole node&amp;#39;s IO or memory budget; the scheduler enforces a fair-share with hard ceilings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connection-level rate limits at the gateway&lt;/strong&gt; in front of the engine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backpressure under write pressure&lt;/strong&gt; rather than unbounded queue growth; clients see slow-downs before the engine OOMs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What we do not stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Volumetric attacks below the gateway.&lt;/strong&gt; A real DDoS is a perimeter problem; the engine is not your edge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An expensive-but-valid query pattern.&lt;/strong&gt; Hybrid search with a deliberately bad filter selectivity will burn CPU. We surface that in &lt;a href=&quot;/blog/agent-observability-traces-tokens-costs/&quot;&gt;query observability&lt;/a&gt; so you can detect and rate-limit it at the application layer, but the engine will execute what it is told.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disk-fill from a privileged tenant.&lt;/strong&gt; Quotas protect against tenant-vs-tenant, not against the &lt;em&gt;cluster&lt;/em&gt; operator pushing the disk to 100%.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;E — Elevation of privilege&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Attacker: a low-privileged client trying to act as a higher-privileged one.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What we stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tenant boundary.&lt;/strong&gt; Every read and write carries a tenant identity derived from the mTLS subject; the planner refuses to return rows outside that tenant unless the caller has an explicit cross-tenant grant. This is enforced at the storage layer, not just in the API.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row-level access checks.&lt;/strong&gt; Where row-level grants are configured, they are evaluated &lt;em&gt;after&lt;/em&gt; the vault decrypt but &lt;em&gt;before&lt;/em&gt; the row leaves the process, so a query that touches forbidden rows still cannot exfiltrate them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What we do not stop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Misconfigured grants.&lt;/strong&gt; If you grant &lt;code&gt;*&lt;/code&gt; to a role, the engine will honour that grant. We log it loudly at config time but do not refuse it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A compromised admin credential.&lt;/strong&gt; Admin is admin. There is no cryptographic guard that a determined admin cannot turn off. The defence is admin hygiene plus an audit trail that an &lt;em&gt;external&lt;/em&gt; observer can read after the fact.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What this means for buyers&lt;/h2&gt;
&lt;p&gt;If the regulator asks &amp;quot;is the data encrypted at rest&amp;quot; the answer is yes. That is not the interesting question. The interesting questions are below, with the answers:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;RedDB&amp;#39;s answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Can a stolen backup be opened without the master key?&lt;/td&gt;
&lt;td&gt;No.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can a network observer between replicas read row contents?&lt;/td&gt;
&lt;td&gt;No.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can a compromised RedDB process read hot rows?&lt;/td&gt;
&lt;td&gt;Yes, while it holds the keys.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can a compromised application process read its own tenant&amp;#39;s rows?&lt;/td&gt;
&lt;td&gt;Yes, by definition — it is a legitimate client.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can RedDB the vendor read your data?&lt;/td&gt;
&lt;td&gt;Only with valid KMS access. In BYOK mode, only while you grant it; pull the grant and we go dark on the next cache eviction.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can an admin with shell rewrite audit history?&lt;/td&gt;
&lt;td&gt;Not silently — the audit log is vault-encrypted with a separate key generation, and you should mirror it to an external append-only store if you need regulator-grade non-repudiation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can the engine prevent a malicious operator on your side?&lt;/td&gt;
&lt;td&gt;No. That is your problem; we make it auditable.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;If any &amp;quot;yes&amp;quot; in that table is unacceptable for your threat model, the mitigation is named in the relevant STRIDE section above. If a mitigation we name is still insufficient — for example, you need HYOK with remote-unwrap on every read — talk to us; that is a deployment shape we have built before but it is not the default.&lt;/p&gt;
&lt;h2&gt;What we are not claiming&lt;/h2&gt;
&lt;p&gt;To close the loop, an explicit list of things this engine &lt;strong&gt;does not&lt;/strong&gt; do, that other vendors sometimes imply they do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Homomorphic computation on encrypted data.&lt;/strong&gt; We do not. Vault columns are decrypted in-process to be operated on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confidential-computing-style memory enclaves (SGX/SEV).&lt;/strong&gt; Not a default. We have customers running RedDB inside an enclave for the I.3 attacker, but it is a deployment choice they make and operate; the engine does not require it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Provable deletion across replicas.&lt;/strong&gt; GDPR-style erasure works through &lt;a href=&quot;/blog/gdpr-erasure-replicated-embedded/&quot;&gt;crypto-shredding&lt;/a&gt; — destroy the key, the ciphertext becomes noise. This is the right answer for replicated systems but it is not the same as overwriting every copy of the plaintext bit-for-bit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Defence against a state-level adversary with physical access.&lt;/strong&gt; That is not a database problem.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Naming these gaps is the point of this post. A buyer who reads the security one-pager and does not see them listed should ask why.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This page is a snapshot. The cryptography, the audit-log shape, and the BYOK story are all moving targets; the &lt;em&gt;boundary&lt;/em&gt; is not. If anything in the STRIDE walk changes, we update this post — it is the canonical reference we point security reviewers at, so it is in our interest to keep it honest.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Reviewed by RedDB&amp;#39;s security lead before publication. If you want the deeper write-up — full STRIDE matrix, deployment-shape variants, sample audit-stream consumer — it is on the docs site under &lt;code&gt;security/threat-model&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/claude-code-memory-backend</id>
    <title>RedDB as Claude Code&apos;s memory backend — beyond CLAUDE.md</title>
    <link href="https://reddb.io/blog/claude-code-memory-backend"/>
    <updated>2026-05-16</updated>
    <published>2026-05-16</published>
    <author><name>RedDB team</name></author>
    <summary>Swap the static MEMORY.md file for a queryable, embeddable memory layer. A SessionStart hook reads top-K memories into context, a /remember slash command writes them, and RedDB stores rows, vectors, and audit log in one transaction.</summary>
    <content type="html">&lt;h2&gt;The problem with CLAUDE.md&lt;/h2&gt;
&lt;p&gt;Every CLI agent ends up reinventing the same hack: a markdown file at the project root that gets shoved into the system prompt on every turn. Claude Code calls it &lt;code&gt;CLAUDE.md&lt;/code&gt; (plus a per-project &lt;code&gt;MEMORY.md&lt;/code&gt;). Cursor has &lt;code&gt;.cursorrules&lt;/code&gt;. Codex has &lt;code&gt;AGENTS.md&lt;/code&gt;. The shape is the same and the failure modes are the same:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It bloats context.&lt;/strong&gt; Every turn re-reads the whole file. By month three the file is 12 KB of half-stale rules nobody trims.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is not queryable.&lt;/strong&gt; &amp;quot;What did the user say about retries last month?&amp;quot; requires &lt;code&gt;grep&lt;/code&gt;, not a query.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is single-scope.&lt;/strong&gt; One file per project. Cross-project memory (your shell preferences, your code-review tone) has to be duplicated or symlinked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is not auditable.&lt;/strong&gt; Who wrote which line? When? Why? &lt;code&gt;git blame&lt;/code&gt; if you remembered to commit it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A database fixes all four. The interesting question is how to wire it in without forking the CLI.&lt;/p&gt;
&lt;h2&gt;The shape of the fix&lt;/h2&gt;
&lt;p&gt;Three pieces:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A schema&lt;/strong&gt; for memory rows — kind, scope, body, embedding, timestamp.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A SessionStart hook&lt;/strong&gt; that runs once when the agent boots and injects the top-K relevant memories as context.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;/remember&lt;/code&gt; slash command&lt;/strong&gt; that writes a memory row from inside a session.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All three talk to the same RedDB instance. The transactional boundary means a write from &lt;code&gt;/remember&lt;/code&gt; is durable before the slash command returns, and the next &lt;code&gt;SessionStart&lt;/code&gt; will see it.&lt;/p&gt;
&lt;h2&gt;Schema&lt;/h2&gt;
&lt;p&gt;A single table — call it &lt;code&gt;memory&lt;/code&gt; — with five columns plus a vector index.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE memory (
  id          TEXT PRIMARY KEY,           -- ulid
  kind        TEXT NOT NULL,              -- user | feedback | project | reference
  scope       TEXT NOT NULL,              -- global | &amp;lt;project-path&amp;gt; | &amp;lt;session-id&amp;gt;
  body        TEXT NOT NULL,              -- markdown, what the model reads
  embedding   VECTOR(1024) NOT NULL,      -- cohere-embed-v4 or similar
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX memory_scope_idx ON memory (scope, created_at DESC);
CREATE INDEX memory_embedding_idx ON memory USING hnsw (embedding vector_cosine_ops);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;kind&lt;/code&gt; mirrors the categories Anthropic&amp;#39;s own memory skill uses (user / feedback / project / reference). &lt;code&gt;scope&lt;/code&gt; lets a single instance serve global preferences alongside per-project facts. The HNSW index is what makes &amp;quot;top-K relevant&amp;quot; cheap.&lt;/p&gt;
&lt;h2&gt;The SessionStart hook&lt;/h2&gt;
&lt;p&gt;Claude Code fires a &lt;code&gt;SessionStart&lt;/code&gt; event when a session boots. A hook is any shell command that returns JSON on stdout — Claude reads it and merges the result into the conversation as a system-reminder.&lt;/p&gt;
&lt;p&gt;Drop this in &lt;code&gt;~/.claude/hooks/session-start-memory.sh&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
set -euo pipefail

SCOPE_GLOBAL=&amp;quot;global&amp;quot;
SCOPE_PROJECT=&amp;quot;$(pwd)&amp;quot;
TOP_K=&amp;quot;${REDDB_MEMORY_TOP_K:-12}&amp;quot;

# CWD + last user message gives us a query embedding seed.
# At SessionStart there is no user message yet, so we seed with
# the project path. /remember will re-rank against real queries later.
QUERY=&amp;quot;agent session start in ${SCOPE_PROJECT}&amp;quot;

curl -sS \
  --fail \
  --max-time 2 \
  -H &amp;quot;Authorization: Bearer ${REDDB_TOKEN}&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;${REDDB_URL}/memory/recall&amp;quot; \
  -d &amp;quot;$(jq -nc \
        --arg q &amp;quot;$QUERY&amp;quot; \
        --arg g &amp;quot;$SCOPE_GLOBAL&amp;quot; \
        --arg p &amp;quot;$SCOPE_PROJECT&amp;quot; \
        --argjson k &amp;quot;$TOP_K&amp;quot; \
        &amp;#39;{query:$q, scopes:[$g,$p], top_k:$k}&amp;#39;)&amp;quot; \
  | jq &amp;#39;{
      hookSpecificOutput: {
        hookEventName: &amp;quot;SessionStart&amp;quot;,
        additionalContext: ( .rows
          | map(&amp;quot;- [\(.kind)] \(.body)&amp;quot;)
          | join(&amp;quot;\n&amp;quot;)
          | &amp;quot;## Recalled memory\n\n\(.)&amp;quot;
        )
      }
    }&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register it in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;hooks&amp;quot;: {
    &amp;quot;SessionStart&amp;quot;: [
      {
        &amp;quot;matcher&amp;quot;: &amp;quot;startup&amp;quot;,
        &amp;quot;hooks&amp;quot;: [
          { &amp;quot;type&amp;quot;: &amp;quot;command&amp;quot;, &amp;quot;command&amp;quot;: &amp;quot;~/.claude/hooks/session-start-memory.sh&amp;quot; }
        ]
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details worth flagging:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--max-time 2&lt;/code&gt;. If RedDB is unreachable the hook fails fast and the session boots without memory rather than hanging.&lt;/li&gt;
&lt;li&gt;The output uses Claude Code&amp;#39;s &lt;code&gt;hookSpecificOutput.additionalContext&lt;/code&gt; shape, which is appended as a system-reminder. This is the only way to inject text into the context window from a hook without invoking the model.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The &lt;code&gt;/remember&lt;/code&gt; slash command&lt;/h2&gt;
&lt;p&gt;Slash commands in Claude Code are markdown files under &lt;code&gt;~/.claude/commands/&lt;/code&gt;. The frontmatter declares argument hints; the body is a prompt for the model. Here we use the &lt;code&gt;!&lt;/code&gt; prefix to run a shell command inline.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;~/.claude/commands/remember.md&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
description: Persist a memory to RedDB
argument-hint: &amp;lt;kind&amp;gt; &amp;lt;body...&amp;gt;
---

Persist this as a `$1` memory in RedDB, scoped to the current project.

!curl -sS \
  --fail \
  -H &amp;quot;Authorization: Bearer $REDDB_TOKEN&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  &amp;quot;$REDDB_URL/memory/write&amp;quot; \
  -d &amp;quot;$(jq -nc \
        --arg kind &amp;quot;$1&amp;quot; \
        --arg scope &amp;quot;$(pwd)&amp;quot; \
        --arg body &amp;quot;${@:2}&amp;quot; \
        &amp;#39;{kind:$kind, scope:$scope, body:$body}&amp;#39;)&amp;quot;

Confirm in one line. Do not summarise the body back.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Usage from inside a session:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/remember feedback Don&amp;#39;t mock the database in integration tests — we got
burned last quarter when mocked tests passed and the prod migration failed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shell call writes the row; RedDB computes the embedding server-side (cheaper than embedding in the hook), commits, and returns the row id. The model gets the JSON response in its tool result and acknowledges.&lt;/p&gt;
&lt;h2&gt;The retrieval query&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;/memory/recall&lt;/code&gt; endpoint is a thin wrapper over a single SQL query. It is worth showing because the shape of the query is the whole point of using a database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH q AS (
  SELECT embed($1) AS v
)
SELECT id, kind, scope, body, created_at,
       1 - (embedding &amp;lt;=&amp;gt; q.v) AS score
FROM memory, q
WHERE scope = ANY($2::text[])
ORDER BY embedding &amp;lt;=&amp;gt; q.v
LIMIT $3;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One statement does scope filter + vector ranking + score. Compare to the markdown-file world, where filtering across global + project scope means concatenating two files in the right order and hoping the model picks the relevant lines out.&lt;/p&gt;
&lt;h2&gt;Why this matters&lt;/h2&gt;
&lt;p&gt;Three things you get for free that a flat file cannot give:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scope composition.&lt;/strong&gt; Global preferences + per-project facts + per-session scratch, ranked together, deduplicated by id.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decay.&lt;/strong&gt; A &lt;code&gt;created_at&lt;/code&gt; column plus a half-life term in the ranking (&lt;code&gt;score - 0.01 * age_in_days&lt;/code&gt;) lets stale memories fall off the top-K without anyone curating the file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit.&lt;/strong&gt; Every memory has an id and a timestamp. A second table &lt;code&gt;memory_audit (id, action, actor, ts)&lt;/code&gt; gives you a &lt;code&gt;git blame&lt;/code&gt; for facts the agent acted on.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is exotic. It is the database treating agent context as just another query workload. The CLI changes are 80 lines of bash and JSON.&lt;/p&gt;
&lt;h2&gt;What is next in this pillar&lt;/h2&gt;
&lt;p&gt;This post is the anchor. The follow-ups make the pieces concrete:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A walkthrough of building a &lt;code&gt;remember-this&lt;/code&gt; skill end-to-end in 30 minutes.&lt;/li&gt;
&lt;li&gt;Slash commands with memory — &lt;code&gt;/remember&lt;/code&gt;, &lt;code&gt;/forget&lt;/code&gt;, &lt;code&gt;/recall&lt;/code&gt; — and the queries underneath.&lt;/li&gt;
&lt;li&gt;Hooks that mutate persistent state, made transactional so a half-finished hook doesn&amp;#39;t leave you with a phantom row.&lt;/li&gt;
&lt;li&gt;An MCP server that exposes the same &lt;code&gt;memory.*&lt;/code&gt; surface to every MCP-compatible CLI: one DB, all the agent tools.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want to be told when those land, grab the &lt;a href=&quot;/blog/rss.xml&quot;&gt;Atom feed&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/hello-reddb</id>
    <title>Hello from inside the engine</title>
    <link href="https://reddb.io/blog/hello-reddb"/>
    <updated>2026-05-16</updated>
    <published>2026-05-16</published>
    <author><name>RedDB team</name></author>
    <summary>A first note on what we&apos;re building and why a multi-model engine matters in 2026.</summary>
    <content type="html">&lt;h2&gt;Why another database&lt;/h2&gt;
&lt;p&gt;Most teams glue together three or four engines to ship a single product: a row store for OLTP, a search index for retrieval, a vector store for RAG, an object store for blobs. Each one has its own consistency model, its own failure surface, its own ops playbook.&lt;/p&gt;
&lt;p&gt;RedDB collapses these into one engine with &lt;strong&gt;one transactional boundary&lt;/strong&gt;. You write once, you query across modalities, you don&amp;#39;t reconcile.&lt;/p&gt;
&lt;h2&gt;What this blog will cover&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Internals of the storage layer — LSM trees, segment compaction, snapshot isolation.&lt;/li&gt;
&lt;li&gt;RAG primitives that live next to your transactional data.&lt;/li&gt;
&lt;li&gt;The vault: per-row encryption with key rotation that doesn&amp;#39;t require downtime.&lt;/li&gt;
&lt;li&gt;Honest notes on what we got wrong before 1.0.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Subscribe&lt;/h2&gt;
&lt;p&gt;This is the first post. Grab the &lt;a href=&quot;/blog/rss.xml&quot;&gt;Atom feed&lt;/a&gt; and we&amp;#39;ll show up in your reader as new pieces land.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/stop-stitching</id>
    <title>Stop stitching Postgres, pgvector, S3, and Redis</title>
    <link href="https://reddb.io/blog/stop-stitching"/>
    <updated>2026-05-16</updated>
    <published>2026-05-16</published>
    <author><name>RedDB team</name></author>
    <summary>Every modern app you ship is four databases in a trenchcoat. Here is the bill — in failure surfaces, ops playbooks, and consistency models — that nobody costs out before signing.</summary>
    <content type="html">&lt;p&gt;The default stack for a serious application in 2026 looks like this: Postgres for rows, pgvector or a sidecar like Qdrant for embeddings, S3 (or R2, or MinIO) for blobs, Redis for the hot path. Four engines. Four query languages, if you count &amp;quot;S3 GET&amp;quot; as a query language. Four sets of credentials, four backup strategies, four ways the system can be down on a Saturday night.&lt;/p&gt;
&lt;p&gt;You did not pick this stack. The stack picked you. Each engine arrived for a defensible reason: Postgres because it is the safe boring choice and you needed transactions; pgvector because the embeddings had to go somewhere and the Postgres extension was easier than spinning up another service; S3 because the PDFs and audio clips would not fit in a row; Redis because somebody put &lt;code&gt;SELECT&lt;/code&gt; inside a tight loop and the read replicas could not keep up.&lt;/p&gt;
&lt;p&gt;Each decision was correct in isolation. The aggregate is what is killing your team.&lt;/p&gt;
&lt;h2&gt;The four bills you are paying&lt;/h2&gt;
&lt;p&gt;When people compare databases, they compare benchmarks. Throughput, P99, cost per gigabyte. The benchmarks miss the actual line item, which is paid in engineer-hours and pager fatigue, not microseconds.&lt;/p&gt;
&lt;p&gt;Pull up the last twelve months of incidents on any team running the four-engine stack and you will find the same pattern: most of the outages live in the seams. The database is fine. The cache is fine. The blob store is fine. What broke is the assumption that all three agreed about the state of the world at the same moment.&lt;/p&gt;
&lt;p&gt;Here is the bill, in four lines.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bill #1: Four failure surfaces.&lt;/strong&gt; Postgres has its own failover semantics. Redis has its own (and it is not the same one). S3 has eventual consistency on some operations and strong consistency on others depending on the year and the vendor. pgvector inherits Postgres but its index build is its own beast. Each of these has its own way of paging you at 3am, its own runbook, its own &amp;quot;yes we have seen this before&amp;quot; tribal knowledge. The number of distinct ways the system can be in a bad state is roughly the product, not the sum.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bill #2: Four consistency models.&lt;/strong&gt; Postgres gives you snapshot isolation by default. Redis gives you &amp;quot;whatever was in memory until the last fsync, and trust us about the fsync.&amp;quot; S3 gives you read-after-write for new objects and eventual consistency for overwrites (and there is a footnote per region). pgvector gives you Postgres semantics for the rows and an HNSW graph that does not participate in your transaction at all. Stitching these together means writing application code that pretends one consistency model exists. That code is wrong. You just have not hit the bug yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bill #3: Four ops playbooks.&lt;/strong&gt; Postgres needs &lt;code&gt;pg_upgrade&lt;/code&gt; rehearsals, replica lag dashboards, autovacuum tuning. Redis needs eviction policy choices, persistence tradeoffs, cluster reshard procedures. S3 needs lifecycle rules, multipart upload retries, bucket policy audits. pgvector needs index rebuild plans when you change the embedding model. Each is a binder of operational knowledge. New hires read four binders before they are useful on call. Senior engineers maintain four binders or, more honestly, two of them and a wiki page that says &amp;quot;ask Maria.&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bill #4: Four authentication and authorization surfaces.&lt;/strong&gt; Postgres roles, Redis ACLs, S3 IAM policies, and whatever you bolted on for the vector store. Each system has its own concept of &amp;quot;user,&amp;quot; &amp;quot;permission,&amp;quot; and &amp;quot;audit log.&amp;quot; Centralizing this is its own project. Most teams do not centralize it; they accept the sprawl and hope the next compliance audit is lenient.&lt;/p&gt;
&lt;p&gt;None of these bills show up in the database vendor&amp;#39;s pricing page.&lt;/p&gt;
&lt;h2&gt;The integration tax&lt;/h2&gt;
&lt;p&gt;The four engines do not talk to each other. Your application is the integration layer. Every interesting business operation is therefore a distributed transaction, and most teams handle distributed transactions by pretending they do not have them.&lt;/p&gt;
&lt;p&gt;Consider the simplest possible AI feature: a user uploads a PDF, the system extracts text, generates embeddings, and makes the document searchable. On the four-engine stack:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Write metadata row to Postgres.&lt;/li&gt;
&lt;li&gt;Upload PDF to S3.&lt;/li&gt;
&lt;li&gt;Push extraction job to Redis queue.&lt;/li&gt;
&lt;li&gt;Worker pulls job, fetches PDF from S3, computes embeddings.&lt;/li&gt;
&lt;li&gt;Worker writes embeddings to pgvector.&lt;/li&gt;
&lt;li&gt;Worker updates Postgres row with &lt;code&gt;status = &amp;#39;indexed&amp;#39;&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;How many of those steps can fail independently? Six. How many of those failures leave the system in a consistent state on their own? Zero. Every team I have talked to has at some point shipped a version of this with a bug where the row says &lt;code&gt;indexed&lt;/code&gt; but the embeddings are missing, or the embeddings exist but the row was rolled back, or the S3 object was uploaded but the row never got written and the orphan sits in a bucket until somebody runs a reconciliation script that turns out to also have a bug.&lt;/p&gt;
&lt;p&gt;You can fix this. The fixes are real engineering work. You write idempotent workers. You design compensating transactions. You build a reconciler. You write a runbook for &amp;quot;what to do when the embeddings job dies between steps 4 and 5.&amp;quot; You staff a team to maintain the reconciler.&lt;/p&gt;
&lt;p&gt;Or you write one transaction.&lt;/p&gt;
&lt;h2&gt;&amp;quot;But Postgres can do all of this&amp;quot;&lt;/h2&gt;
&lt;p&gt;Yes, sort of. This is the response I expect from anyone who has shipped on Postgres + pgvector + lo (large objects) and got it working. The shape of the response is: &amp;quot;we run one engine, we did not have to integrate anything, your manifesto is invalid.&amp;quot;&lt;/p&gt;
&lt;p&gt;The honest version of that argument: Postgres handles the document and vector path well, lo is fine for moderate blobs, and a queue can live in a table. This is true at small scale and for some workloads. We wrote a whole companion post titled &lt;a href=&quot;/blog/when-you-dont-need-reddb&quot;&gt;When you don&amp;#39;t need RedDB&lt;/a&gt; saying so.&lt;/p&gt;
&lt;p&gt;Here is where the argument breaks. The moment your blobs are large enough that you do not want them in the WAL, you move them to S3. The moment your embedding workload outgrows pgvector&amp;#39;s index build window, you spin up Qdrant or Weaviate next to it. The moment your queue throughput needs durability semantics that a Postgres table cannot give you cheaply, you reach for Redis Streams or Kafka. The single-engine stack is stable until the workload changes, and then it pulls itself apart engine by engine and you are back to four.&lt;/p&gt;
&lt;p&gt;The four-engine stack is not a choice. It is the stable equilibrium that Postgres-only teams drift into.&lt;/p&gt;
&lt;h2&gt;What &amp;quot;small team unlock&amp;quot; means&lt;/h2&gt;
&lt;p&gt;The common framing of multi-model databases is &amp;quot;scale.&amp;quot; You hear &amp;quot;consolidated storage&amp;quot; and assume the pitch is about saving infrastructure cost at high scale.&lt;/p&gt;
&lt;p&gt;That framing is wrong, or at least incomplete. The bigger benefit is at small scale.&lt;/p&gt;
&lt;p&gt;A team of three engineers cannot run four engines in production responsibly. They can run them; the question is whether they can run them well. A four-engine stack assumes you have someone who knows Postgres replication, someone who has been paged for a Redis OOM at least three times, someone who has fought S3 IAM, and someone who has rebuilt a pgvector index after switching embedding models. At three engineers, this person is you, and you have to be all four people in a single brain. That is the actual cost.&lt;/p&gt;
&lt;p&gt;At fifteen engineers, you have specialists, but now the specialists have created silos. The team that owns the vector store does not own the source-of-truth row in Postgres. When the embeddings get stale, ownership of the bug is unclear. The drift window — the time between source data changing and embeddings being recomputed — becomes a customer-visible bug surface and a political question about which team&amp;#39;s queue takes priority.&lt;/p&gt;
&lt;p&gt;A multi-model engine collapses that. One transaction. One ownership boundary. The reconciler does not exist because the inconsistency it reconciles does not exist.&lt;/p&gt;
&lt;p&gt;We will go deep on the ops cost math in a follow-up: &lt;a href=&quot;/blog/best-of-breed-loses-small-scale&quot;&gt;Why best-of-breed loses at small scale&lt;/a&gt;. The short version is that engineer-hours dominate infrastructure cost at every team size you are likely to operate at, and engineer-hours scale with the number of distinct systems you have to keep alive.&lt;/p&gt;
&lt;h2&gt;The performance argument is the weakest one&lt;/h2&gt;
&lt;p&gt;Best-of-breed proponents will note that a purpose-built vector store will beat a multi-model engine on raw vector search benchmarks. They are right. We do not pretend otherwise — read our own &lt;a href=&quot;/blog/multi-model-storage-tradeoffs&quot;&gt;tradeoffs post&lt;/a&gt; for what we gave up to fit four engines under one transaction.&lt;/p&gt;
&lt;p&gt;But this is the wrong axis to fight on. The performance argument is the weakest argument for the four-engine stack, because the performance gap closes every quarter and the operational gap does not. HNSW implementations are converging. JSON indexing in row stores is good enough for most document workloads. The blob path is the blob path. What does not converge is the cost of running four systems versus one. That cost is structural. It comes from the integration layer being your application code, which is to say, code your team writes and maintains.&lt;/p&gt;
&lt;p&gt;If your vector search workload genuinely needs the last 15% of throughput that a single-purpose store provides, run a single-purpose store. We will tell you to. But &amp;quot;we benchmark 15% slower on synthetic vector recall&amp;quot; is a strange reason to accept four times the on-call burden.&lt;/p&gt;
&lt;h2&gt;What we are actually arguing&lt;/h2&gt;
&lt;p&gt;The argument is not &amp;quot;RedDB is faster than your four engines.&amp;quot; Sometimes it is, often it is not. The argument is that the cost of stitching has been quietly moved off the database vendor&amp;#39;s bill and onto your team&amp;#39;s calendar, and that move is not free.&lt;/p&gt;
&lt;p&gt;You are paying for the integration layer in code your team writes, in incidents that pull people away from features, in onboarding ramps that take twice as long because there are twice as many systems to learn, in audit findings that say &amp;quot;your access control surface is not consistent across data stores.&amp;quot;&lt;/p&gt;
&lt;p&gt;A multi-model engine is the bet that those costs are higher than the cost of giving up some per-modality tuning. For the teams we have worked with — and especially for the teams of two-to-fifteen who are shipping product, not running infrastructure — that bet pays off. For teams running specialized workloads at massive scale with a dedicated platform org, it may not. Honesty about which side of the line you are on is the whole point.&lt;/p&gt;
&lt;p&gt;If you are on the four-engine treadmill and the last incident you handled lived in the seams between two of them, the cost of stitching is not theoretical for you. It is the meeting you canceled this week because the reconciler was paging again.&lt;/p&gt;
&lt;p&gt;We built RedDB because we wanted to stop having that meeting.&lt;/p&gt;
&lt;h2&gt;Where to go from here&lt;/h2&gt;
&lt;p&gt;This is the anchor of a series. Over the next few weeks we will go deeper on each face of the argument:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/blog/multi-model-storage-tradeoffs&quot;&gt;The tradeoffs we made to fit four engines in one&lt;/a&gt; — what we gave up. Honest.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/when-you-dont-need-reddb&quot;&gt;When you don&amp;#39;t need RedDB&lt;/a&gt; — the cases where Postgres alone is the right call.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/best-of-breed-loses-small-scale&quot;&gt;Why best-of-breed loses at small scale&lt;/a&gt; — the engineer-hours math.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want to see what a single transaction across document, vector, KV, and blob actually looks like, the &lt;a href=&quot;/blog/rag-without-second-database&quot;&gt;RAG without a second database&lt;/a&gt; post has the schema and the code.&lt;/p&gt;
&lt;p&gt;The first step is to count, on your current stack, how many of your last ten incidents lived in the seams. If the answer is more than two, you already know what this post is about.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/multi-model-storage-tradeoffs</id>
    <title>The tradeoffs we made to fit four engines in one</title>
    <link href="https://reddb.io/blog/multi-model-storage-tradeoffs"/>
    <updated>2026-05-14</updated>
    <published>2026-05-14</published>
    <author><name>RedDB team</name></author>
    <summary>Document, vector, KV and blob in the same transaction is not free. Here&apos;s the per-modality tuning surface we cut, the plugin story we sacrificed, and the cold-blob latency tail we accepted.</summary>
    <content type="html">&lt;p&gt;Picking a storage model is a constraint on every future feature. We picked four. A document store, a vector index, a KV cache and a blob keeper — all riding the same write-ahead log, all visible inside the same transaction. The pitch writes itself. The bill is harder.&lt;/p&gt;
&lt;p&gt;This post is the bill. If you are evaluating RedDB against a stitched stack (Postgres + pgvector + S3 + Redis) or against a single best-of-breed engine, you should leave knowing exactly which of those alternatives still beats us on your workload. We would rather lose the deal than win it and own the incident.&lt;/p&gt;
&lt;h2&gt;One log to rule them all&lt;/h2&gt;
&lt;p&gt;A single WAL is shared by every modality. Document writes, vector inserts, KV updates and blob commits land in the same ordered byte stream and are made durable by the same &lt;code&gt;fsync&lt;/code&gt;. That is the whole trick. Cross-model transactions are not a coordination protocol bolted on top of four engines — they are the natural consequence of one engine that already happens to write all four things.&lt;/p&gt;
&lt;p&gt;Everything else in this post is a downstream consequence of that one decision. Some of the consequences are good (you only need one backup, one auth surface, one ordered cursor for change capture). Some of them are not. We will start with the not.&lt;/p&gt;
&lt;h2&gt;Tradeoff 1 — Per-modality tuning surface&lt;/h2&gt;
&lt;p&gt;A pure vector store like Qdrant or Weaviate exposes every HNSW parameter that matters: graph degree &lt;code&gt;M&lt;/code&gt;, build-time candidate list &lt;code&gt;ef_construction&lt;/code&gt;, query-time candidate list &lt;code&gt;ef_search&lt;/code&gt;, the choice between cosine, dot and L2, scalar vs product quantization, segment-level rebuild scheduling. You can tune one collection for recall-at-99% on a 768-dim text embedding model and a different collection for sub-millisecond response on a 64-dim recsys model, and they coexist happily.&lt;/p&gt;
&lt;p&gt;We did not ship that. The vector index in RedDB exposes three knobs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;index_type&lt;/code&gt;&lt;/strong&gt; — HNSW or flat. Flat is for collections small enough that brute force wins.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;distance&lt;/code&gt;&lt;/strong&gt; — cosine, dot or L2.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ef_search&lt;/code&gt;&lt;/strong&gt; — the query-time tradeoff between recall and latency, set per query.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We picked sensible defaults for &lt;code&gt;M&lt;/code&gt; (16) and &lt;code&gt;ef_construction&lt;/code&gt; (200). They are not configurable per collection. If your workload genuinely benefits from &lt;code&gt;M = 48&lt;/code&gt; (very high-dimensional embeddings where recall collapses at low graph degree), we are leaving recall on the table for you. We made this call on purpose: the support load of explaining HNSW parameters to teams that picked us &lt;em&gt;because they did not want to learn HNSW parameters&lt;/em&gt; was the load we wanted to avoid. A team that wants those knobs is a team for whom a dedicated vector store is the right answer.&lt;/p&gt;
&lt;p&gt;The same logic applies elsewhere. Blob storage does not let you pick the chunk size. The KV layer does not let you opt out of MVCC for raw byte speed. The document store does not let you choose between B-tree and LSM per collection. These are all real knobs in real specialized engines. We cut them.&lt;/p&gt;
&lt;h2&gt;Tradeoff 2 — The plugin story we did not ship&lt;/h2&gt;
&lt;p&gt;Postgres has spoiled the industry. The extension API is the reason pgvector exists at all, and it is the reason a small team can ship a new index type without forking the database. Every serious Postgres engineer has, at some point, written or read a &lt;code&gt;CREATE EXTENSION&lt;/code&gt; shim.&lt;/p&gt;
&lt;p&gt;We do not have that. RedDB is one binary with the four engines compiled in. You cannot drop in a third-party graph index, a new compression codec, or a domain-specific aggregate function without forking us. We considered building a plugin ABI; we decided not to. Two reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A plugin ABI is a forever commitment.&lt;/strong&gt; The Postgres ABI is famously stable and famously load-bearing on the entire ecosystem. We are a young engine. Locking ourselves into a plugin contract today means freezing internal APIs we still want to rearrange.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Most plugin demand is really feature demand.&lt;/strong&gt; When we asked teams what they would build as a plugin, the answers were almost always &amp;quot;a feature I wish you shipped.&amp;quot; Building those features in tree, where they participate in the WAL and the transaction model, is strictly better than pushing them outside the trust boundary.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you are the team that would have written the plugin — your differentiation lives in a custom index, a custom aggregate, a custom rerank — you should keep using a database that lets you build it. We will not be that database in 2026. We might be in 2028. Plan accordingly.&lt;/p&gt;
&lt;h2&gt;Tradeoff 3 — The cold-blob latency tail&lt;/h2&gt;
&lt;p&gt;Here is the one that bites in production. The WAL is shared. That means a 50 MB blob commit and a 200-byte KV update are queued behind the same &lt;code&gt;fsync&lt;/code&gt;. On a healthy node with NVMe and a tight &lt;code&gt;wal_segment_size&lt;/code&gt;, this is fine — the blob is striped, the small writes pipeline, and tail latency stays sane. On a node that has just absorbed a burst of large blob writes, it is not fine.&lt;/p&gt;
&lt;p&gt;The numbers we measure on our own staging cluster:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;KV P99 under steady load&lt;/strong&gt;: 1.8 ms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;KV P99 during a sustained blob write burst (10 concurrent uploads, 100 MB each)&lt;/strong&gt;: 14 ms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;KV P99.9 during the same burst&lt;/strong&gt;: 38 ms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For most applications, a 14 ms P99 during ingestion bursts is invisible. For a hot path doing 50 KV reads per request, it is a 700 ms request tail you did not expect. We document this. We expose a &lt;code&gt;wal_priority&lt;/code&gt; hint that lets you mark blob commits as background, which softens the tail at the cost of slightly delayed blob durability acknowledgement (still durable, just acknowledged after the next group commit instead of with it).&lt;/p&gt;
&lt;p&gt;If your workload is &amp;quot;a few hundred KV ops per second and never more than a megabyte of blob per minute,&amp;quot; none of this matters and you should pick us. If your workload is &amp;quot;ingest 4 TB of PDFs a day while serving sub-5ms cache reads on the same cluster,&amp;quot; put the blobs in S3, put the cache in Redis, and use us for the documents and vectors. We are not lying about the shared WAL. The shared WAL is the feature. The shared WAL is also the cost.&lt;/p&gt;
&lt;h2&gt;Tradeoff 4 — Operational maturity&lt;/h2&gt;
&lt;p&gt;We are honest about being younger than the alternatives we replace. Postgres has thirty years of operational folklore. S3 has fifteen years of corner-case documentation. Redis has the entire &lt;code&gt;redis-cli&lt;/code&gt; ecosystem and a generation of engineers who have debugged it at 3am.&lt;/p&gt;
&lt;p&gt;We have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A two-year-old engine.&lt;/li&gt;
&lt;li&gt;A small team.&lt;/li&gt;
&lt;li&gt;A growing but not encyclopedic set of postmortems.&lt;/li&gt;
&lt;li&gt;Documented behavior for the failure modes we have seen; less coverage for the ones we have not.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is not a lasting tradeoff — the maturity gap closes one quarter at a time — but it is a real one today. If your team has zero appetite for being on the early side of an operational learning curve, the four-engine stack you already know is the safer choice. Buying RedDB to escape it is rational only if the seams in that stack are already costing you more than the maturity gap will.&lt;/p&gt;
&lt;h2&gt;When RedDB is wrong for you&lt;/h2&gt;
&lt;p&gt;To make this concrete, here is the shortlist. If you recognize yourself in any of these, do not buy us yet:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You need every HNSW knob.&lt;/strong&gt; A dedicated vector DB will give you better recall-at-latency on a single embedding workload than we will.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your differentiation is a custom index, aggregate or codec.&lt;/strong&gt; Postgres + an extension is a better home until we ship a plugin ABI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You ingest huge blobs while serving low-latency reads on the same cluster.&lt;/strong&gt; Split the workload. Use object storage for blobs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You have a strong on-call team that already loves the four-engine stack.&lt;/strong&gt; The seams you have memorized cost less than a migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You cannot tolerate a young engine.&lt;/strong&gt; Wait a year. We will still be here.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What we kept&lt;/h2&gt;
&lt;p&gt;What we did keep is exactly the thing the bill in the previous post was buying out: cross-model transactions, one ops playbook, one auth surface, one ordered log of every change to every modality. For the teams whose pain lives in the seams rather than in single-engine tuning, that trade was obvious. For the others, the four-engine stack is still the right answer and we will say so on a call.&lt;/p&gt;
&lt;p&gt;Honest tradeoffs are the only kind that survive contact with production. These are ours.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/rag-without-second-database</id>
    <title>RAG without a second database</title>
    <link href="https://reddb.io/blog/rag-without-second-database"/>
    <updated>2026-05-12</updated>
    <published>2026-05-12</published>
    <author><name>RedDB team</name></author>
    <summary>When your vectors live on the same row as the document they describe, the entire CDC-and-backfill layer of a RAG pipeline disappears — and a class of stale-retrieval bugs goes with it.</summary>
    <content type="html">&lt;p&gt;Most RAG stacks treat the vector store as a sibling of the source-of-truth database. The text lives in Postgres or Mongo, the embeddings live in Pinecone or Qdrant or a pgvector table, and a job somewhere is responsible for keeping them in agreement. That job is where the bugs come from.&lt;/p&gt;
&lt;p&gt;This post is about the specific class of failure that disappears when the embedding column lives on the row it describes — and about the schema, transactions, and queries that make that possible.&lt;/p&gt;
&lt;h2&gt;The two-store assumption nobody questions&lt;/h2&gt;
&lt;p&gt;Open any RAG tutorial published in the last two years. The architecture diagram has, with depressing consistency, three boxes: a document store, an embedding model, and a vector database. An arrow goes from the document store to the model. Another arrow goes from the model to the vector store. At query time, the application embeds the user&amp;#39;s question and asks the vector store for nearest neighbors.&lt;/p&gt;
&lt;p&gt;The diagram is clean. The implementation is not. The arrows hide a small distributed system.&lt;/p&gt;
&lt;p&gt;In production, the arrow from &amp;quot;document store&amp;quot; to &amp;quot;embedding model&amp;quot; is a queue. A worker reads new and changed rows from the source database — sometimes by polling a &lt;code&gt;updated_at&lt;/code&gt; column, sometimes by tailing a CDC stream, sometimes by a trigger that writes to an outbox table. The worker calls the embedding model, then writes the resulting vector to the vector store, keyed by the source row&amp;#39;s primary key.&lt;/p&gt;
&lt;p&gt;If anything in that pipeline lags, fails, retries, or is deployed mid-flight, the vector store and the document store now disagree about the world. A query lands in this window. The user sees the bug.&lt;/p&gt;
&lt;h2&gt;The drift window, named&lt;/h2&gt;
&lt;p&gt;Call this the &lt;strong&gt;drift window&lt;/strong&gt;: the interval, measured per row, between the moment a document changes and the moment its embedding in the vector store reflects that change.&lt;/p&gt;
&lt;p&gt;For a healthy pipeline running well, the drift window is short — seconds, sometimes minutes. For a pipeline under load, after a queue backlog, during a re-embed migration, or right after a deploy, the drift window is hours or worse. We have seen production drift windows in the tens of hours, undetected, because there was no alarm watching the gap.&lt;/p&gt;
&lt;p&gt;What does the user experience inside the drift window?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A support article was updated this morning to reflect a price change. The embedding still encodes the old paragraph. The user asks &amp;quot;how much does X cost?&amp;quot; Retrieval pulls the article (correctly), but the snippet the model is grounded on — the chunk the retriever scored highest — still semantically matches the old wording. The answer is wrong.&lt;/li&gt;
&lt;li&gt;A legal team redacted a name from a document. The text on disk is clean. The embedding was generated from the unredacted text. Vector similarity search on the old name still pulls that document into context. The model dutifully includes the redacted name in its answer.&lt;/li&gt;
&lt;li&gt;A document was deleted from the canonical store. The vector remained, because the deletion event was on a different topic and the consumer for that topic was down. Search returns a ghost — a result whose source no longer exists. Click-through returns 404 or, worse, returns the previous tenant&amp;#39;s row.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these is a real outage we have seen on customer postmortems. Each is mechanical, not stochastic. Each disappears when there is no second store to drift against.&lt;/p&gt;
&lt;h2&gt;Schema: the embedding belongs on the row&lt;/h2&gt;
&lt;p&gt;The mental shift is small but consequential. Stop thinking of &amp;quot;the embedding for document 17&amp;quot; as a separate object that has to be kept in sync. Start thinking of it as one more column on document 17.&lt;/p&gt;
&lt;p&gt;Here is the table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE documents (
  id          UUID PRIMARY KEY,
  tenant_id   UUID NOT NULL,
  title       TEXT NOT NULL,
  body        TEXT NOT NULL,
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- the embedding lives here, on the same row, in the same engine
  body_embedding  VECTOR(1024) NOT NULL,
  embedding_model TEXT NOT NULL,         -- which model produced it
  embedded_at     TIMESTAMPTZ NOT NULL   -- and when
);

CREATE INDEX documents_tenant_embedding_idx
  ON documents
  USING hnsw (body_embedding vector_cosine_ops)
  WHERE tenant_id IS NOT NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to notice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The vector is &lt;code&gt;NOT NULL&lt;/code&gt;.&lt;/strong&gt; It is not an optional projection of the row; it is part of the row&amp;#39;s identity. A document without an embedding is not a row that exists yet. This is the same posture we take toward &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;body&lt;/code&gt;: missing is not a state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The model name and timestamp are on the row.&lt;/strong&gt; When the embedding model changes — and it will — you do not need a separate audit table to know which rows still need re-embedding. The row tells you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The HNSW index is co-located.&lt;/strong&gt; It is built on the same table, in the same engine, by the same transaction that wrote the row. There is no separate index server, no separate replication lag for the index, no separate failover.&lt;/p&gt;
&lt;h2&gt;Transactions: write the text and the vector together&lt;/h2&gt;
&lt;p&gt;The producer code looks like one round trip, not three:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;async function upsertDocument(input: DocumentInput): Promise&amp;lt;Document&amp;gt; {
  const embedding = await embedder.embed(input.body)

  const row = await reddb.tx(async (tx) =&amp;gt; {
    return tx.upsert(&amp;#39;documents&amp;#39;, {
      id: input.id,
      tenant_id: input.tenantId,
      title: input.title,
      body: input.body,
      updated_at: new Date(),
      body_embedding: embedding,
      embedding_model: embedder.modelId,
      embedded_at: new Date(),
    })
  })

  return row
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The embedding call is outside the transaction (it is the slow network hop). The write inside the transaction includes both the text and the vector. Either both are visible to the next reader, or neither is. The drift window is exactly zero.&lt;/p&gt;
&lt;p&gt;Compare to the two-store version of the same operation, where you have to choose between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;writing the text first, then the embedding, and living with the window in between; or&lt;/li&gt;
&lt;li&gt;writing the embedding first, then the text, and serving search results for a document that does not exist; or&lt;/li&gt;
&lt;li&gt;writing to an outbox table inside the transaction, then having a downstream worker fan it out, and inheriting all the failure modes you were trying to avoid.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are good. The third is the most defensible and it is still bad — the moment the worker is behind, you are back inside a drift window.&lt;/p&gt;
&lt;h2&gt;Reads: one query, not two&lt;/h2&gt;
&lt;p&gt;The retrieval side simplifies in proportion.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, title, body, updated_at,
       1 - (body_embedding &amp;lt;=&amp;gt; $1) AS similarity
FROM documents
WHERE tenant_id = $2
  AND deleted_at IS NULL
ORDER BY body_embedding &amp;lt;=&amp;gt; $1
LIMIT $3;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One query. Tenant scoping and soft-delete filtering happen inside the same query that does the nearest-neighbor search, because they are columns on the same row. No second round trip to filter, no application-side join between &amp;quot;vector hits&amp;quot; and &amp;quot;current document state.&amp;quot;&lt;/p&gt;
&lt;p&gt;This is the line we keep coming back to: the two-store architecture forces the application to do a join the database could have done. The application is wrong about that join far more often than the database would be.&lt;/p&gt;
&lt;h2&gt;What chunking looks like when chunks are rows&lt;/h2&gt;
&lt;p&gt;Real documents do not embed cleanly as a single vector. You chunk them. The two-store world makes this a second table in the vector store with a foreign key back to the document — and a second drift window for the chunk-to-document relationship.&lt;/p&gt;
&lt;p&gt;In the single-engine model, chunks are rows. They are children of the document:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE document_chunks (
  id              UUID PRIMARY KEY,
  document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  tenant_id       UUID NOT NULL,
  ordinal         INT NOT NULL,
  text            TEXT NOT NULL,
  embedding       VECTOR(1024) NOT NULL,
  embedding_model TEXT NOT NULL,
  embedded_at     TIMESTAMPTZ NOT NULL
);

CREATE INDEX chunks_embedding_idx
  ON document_chunks
  USING hnsw (embedding vector_cosine_ops);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ON DELETE CASCADE&lt;/code&gt; carries the chunks with the parent. When a document is deleted, its chunks vanish. When a document is rewritten, the upsert path deletes the old chunks and writes the new ones inside the same transaction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;await reddb.tx(async (tx) =&amp;gt; {
  await tx.upsert(&amp;#39;documents&amp;#39;, { /* ...as above... */ })
  await tx.delete(&amp;#39;document_chunks&amp;#39;, { document_id: input.id })
  for (const [i, chunk] of newChunks.entries()) {
    await tx.insert(&amp;#39;document_chunks&amp;#39;, {
      id: crypto.randomUUID(),
      document_id: input.id,
      tenant_id: input.tenantId,
      ordinal: i,
      text: chunk.text,
      embedding: chunk.embedding,
      embedding_model: embedder.modelId,
      embedded_at: new Date(),
    })
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A reader hitting the database mid-transaction sees either the old set of chunks or the new one. Never a mix. Never a half-rewritten document. The retriever cannot return a chunk whose source paragraph has already been removed, because the chunk and the paragraph commit together.&lt;/p&gt;
&lt;h2&gt;What about re-embedding when the model changes?&lt;/h2&gt;
&lt;p&gt;The model will change. New embedding releases are quarterly events. Two-store architectures handle this with a forklift: stand up a parallel vector store, re-embed everything, swap. The forklift is its own outage class.&lt;/p&gt;
&lt;p&gt;In the single-engine model, re-embedding is a background job that reads rows where &lt;code&gt;embedding_model != &amp;lt;current&amp;gt;&lt;/code&gt;, recomputes the vector, and writes it back. Each row&amp;#39;s update is a one-row transaction. Readers continue to use whichever model the row currently has. The transition is monotone: every commit moves one more row to the new model, no row is ever in an inconsistent state, and you can pause and resume the migration without thinking about it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;async function reEmbedBatch(modelId: string, batchSize = 100) {
  const stale = await reddb.query(
    `SELECT id, body FROM documents
       WHERE embedding_model != $1
       LIMIT $2 FOR UPDATE SKIP LOCKED`,
    [modelId, batchSize],
  )

  for (const row of stale) {
    const v = await embedder.embed(row.body)
    await reddb.update(&amp;#39;documents&amp;#39;, {
      where: { id: row.id },
      set: { body_embedding: v, embedding_model: modelId, embedded_at: new Date() },
    })
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; lets you fan this out across workers safely — each worker grabs a chunk of stale rows and updates them, no coordination needed.&lt;/p&gt;
&lt;h2&gt;What we did not solve&lt;/h2&gt;
&lt;p&gt;Some things are unchanged by collapsing the stores.&lt;/p&gt;
&lt;p&gt;You still need a good embedding model. Co-locating the vector does not improve recall.&lt;/p&gt;
&lt;p&gt;You still need to chunk. Naive whole-document embeddings perform worse than chunked ones for long-form material, and that is independent of where the vector lives.&lt;/p&gt;
&lt;p&gt;You still need evaluation. A retrieval pipeline you do not measure is one you cannot improve. Evals belong on the same database — see our piece on storing eval datasets as rows — but the act of building them is yours.&lt;/p&gt;
&lt;p&gt;You still need to think about cost. A &lt;code&gt;VECTOR(1024)&lt;/code&gt; column is four kilobytes per row before quantization. At a hundred million rows, that is non-trivial storage. HNSW indexes have their own memory footprint. None of this is free; it is just no longer split across two budgets owned by two teams.&lt;/p&gt;
&lt;h2&gt;The one bug class we removed&lt;/h2&gt;
&lt;p&gt;We did not build a magic retrieval system. We removed an entire category of operational failure — the kind that lives in the seam between &amp;quot;document changed&amp;quot; and &amp;quot;vector reflects the change&amp;quot; — by removing the seam. There is no drift window because there is no second store to drift against. There is no reconciliation job because there is nothing to reconcile. There is no orphaned vector because deletion of a document deletes its embeddings inside the same transaction.&lt;/p&gt;
&lt;p&gt;That is one bug class, not all of them. But it is the bug class that, in our experience, drives the most off-hours pages on production RAG systems. Trading a queue and a worker and a sidecar for a column on a row turns out to be a very good trade.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/vault-key-rotation-zero-downtime</id>
    <title>Per-row encryption with zero-downtime key rotation</title>
    <link href="https://reddb.io/blog/vault-key-rotation-zero-downtime"/>
    <updated>2026-05-10</updated>
    <published>2026-05-10</published>
    <author><name>RedDB team</name></author>
    <summary>How the vault keeps reads hot during rotation, and the one knob you should never touch in production.</summary>
    <content type="html">&lt;p&gt;Most &amp;quot;field-level encryption&amp;quot; stories fall apart at rotation time. The cryptography is fine. The operations aren&amp;#39;t. Rotating a master key shouldn&amp;#39;t require a maintenance window, a feature freeze, or a frantic Slack thread when read latency triples because every row in a hot table is being decrypt-then-re-encrypt&amp;#39;d while traffic still hits it.&lt;/p&gt;
&lt;p&gt;The vault layer in RedDB is built so rotation is a background operation that runs at whatever pace you tell it to, on a live table, with no read-path stall and no application changes. This post is the internals. If you operate the system, the second half is the one you want.&lt;/p&gt;
&lt;h2&gt;The envelope&lt;/h2&gt;
&lt;p&gt;Every encrypted row carries a small envelope alongside its ciphertext. Three fields:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;+----+-----------------+------------+
| g  | iv (12 bytes)   | ciphertext |
+----+-----------------+------------+
  ^
  generation byte (or short int)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;g&lt;/code&gt; — the master key &lt;strong&gt;generation&lt;/strong&gt; this row was encrypted under. One byte is plenty; we use a 16-bit int to leave room.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;iv&lt;/code&gt; — a per-row, never-reused initialisation vector. AES-GCM, so 12 bytes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ciphertext&lt;/code&gt; — the encrypted payload, with the GCM auth tag appended.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The envelope is stored as one opaque column. Application code never touches it directly; the SQL layer wraps reads and writes with &lt;code&gt;vault_decrypt(col)&lt;/code&gt; and &lt;code&gt;vault_encrypt(col)&lt;/code&gt; calls that look up the right key by generation.&lt;/p&gt;
&lt;p&gt;The crucial property: &lt;strong&gt;the row knows which key it needs.&lt;/strong&gt; No central index, no separate &amp;quot;which-key-for-which-row&amp;quot; table. That&amp;#39;s what makes rotation cheap.&lt;/p&gt;
&lt;h2&gt;The master key set&lt;/h2&gt;
&lt;p&gt;The master key set is versioned, not replaced. Conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;generation │ key material (32 bytes)        │ state
─────────────────────────────────────────────────────
1          │ &amp;lt;kek-1, wrapped by KMS root&amp;gt;   │ retired
2          │ &amp;lt;kek-2, wrapped by KMS root&amp;gt;   │ active-read
3          │ &amp;lt;kek-3, wrapped by KMS root&amp;gt;   │ active-write
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three states matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;active-write&lt;/strong&gt; — the generation new writes use. Exactly one at a time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;active-read&lt;/strong&gt; — generations that are still permitted on the read path. Many can be active-read.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;retired&lt;/strong&gt; — generation is gone. Any row still tagged with it is unreadable (this is by design — see &amp;quot;destroying a generation&amp;quot; below).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The actual key material is wrapped by your KMS root and only unwrapped into a process-local cache. The wrapped form is what we store and replicate; the cache holds unwrapped keys with a short TTL so a KMS access revocation propagates.&lt;/p&gt;
&lt;h2&gt;Reads pick the right generation&lt;/h2&gt;
&lt;p&gt;A read is straightforward:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Fetch the row. Parse the envelope header.&lt;/li&gt;
&lt;li&gt;Look up generation &lt;code&gt;g&lt;/code&gt; in the key cache. If absent, ask KMS to unwrap it; populate the cache.&lt;/li&gt;
&lt;li&gt;Verify the GCM tag, decrypt, return plaintext.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Cost: a 16-bit read of &lt;code&gt;g&lt;/code&gt;, a map lookup (hot), and one AES-GCM decrypt. The expensive part — KMS round-trip — only happens on cache miss, which is essentially never in steady state because the working set of live generations is tiny (usually 1–3).&lt;/p&gt;
&lt;p&gt;If the generation is &lt;strong&gt;retired&lt;/strong&gt;, the read errors with a typed &lt;code&gt;KeyRevoked&lt;/code&gt; and the row is returned as encrypted bytes plus the generation number — useful for forensics and for the &amp;quot;I told you to retire it&amp;quot; conversation.&lt;/p&gt;
&lt;h2&gt;Writes always use the latest&lt;/h2&gt;
&lt;p&gt;Writes are simpler still: encrypt under whichever generation is &lt;code&gt;active-write&lt;/code&gt;, write the envelope, done. There is no read-modify-write on the encryption path. Inserts and updates are symmetric.&lt;/p&gt;
&lt;p&gt;The application gets identical SQL whether the column is vaulted or not:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;insert into customers (id, email_vault)
values ($1, vault_encrypt($2));

select id, vault_decrypt(email_vault) as email
from customers
where id = $1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;vault_encrypt&lt;/code&gt; is a stable function that picks the current active-write generation per call. &lt;code&gt;vault_decrypt&lt;/code&gt; reads the envelope&amp;#39;s embedded generation, so the same query works across rows encrypted under different generations — which is the whole point.&lt;/p&gt;
&lt;h2&gt;Rotation, online&lt;/h2&gt;
&lt;p&gt;Rotation is three steps and the third one is the only slow one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1 — provision a new generation.&lt;/strong&gt; Generate &lt;code&gt;kek-N+1&lt;/code&gt;, wrap with the KMS root, store it as &lt;code&gt;retired&lt;/code&gt; initially so it isn&amp;#39;t picked up by anything. This is one round-trip to KMS and a single SQL write.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2 — promote.&lt;/strong&gt; Atomically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Move the current &lt;code&gt;active-write&lt;/code&gt; to &lt;code&gt;active-read&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Move the new generation from &lt;code&gt;retired&lt;/code&gt; to &lt;code&gt;active-write&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From this instant, every new write uses the new generation. Every old row remains readable because its old generation is now &lt;code&gt;active-read&lt;/code&gt;. &lt;strong&gt;The application sees nothing.&lt;/strong&gt; No restart, no flush, no schema change. Read latency does not change. p99 does not move.&lt;/p&gt;
&lt;p&gt;If you stop here, you have a partially-rotated table. That&amp;#39;s actually fine — rotation is monotonic and incremental. Many compliance regimes only require &amp;quot;new writes use the new key from date X.&amp;quot; If yours does, you&amp;#39;re done.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 3 — background re-encrypt.&lt;/strong&gt; A worker scans the table, decrypts each row under its old generation, re-encrypts under the new one, and writes it back as a normal update. We use &lt;code&gt;SKIP LOCKED&lt;/code&gt; so the worker never blocks an application write, and a rate-limit knob (rows-per-second) so the worker can be tuned to fit available IO headroom.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- worker query, roughly
with batch as (
  select id from customers
  where vault_generation(email_vault) &amp;lt; (select current_active_write_generation())
  order by id
  limit 1000
  for update skip locked
)
update customers c
   set email_vault = vault_reencrypt(c.email_vault)
  from batch b
 where c.id = b.id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;vault_reencrypt&lt;/code&gt; decrypts under the embedded generation and re-encrypts under the current &lt;code&gt;active-write&lt;/code&gt;. It&amp;#39;s a single round-trip to the vault layer, no application code involved.&lt;/p&gt;
&lt;p&gt;Once the worker finishes, every row carries the latest generation. The old generation can be moved from &lt;code&gt;active-read&lt;/code&gt; to &lt;code&gt;retired&lt;/code&gt;, and the wrapped key can be deleted from KMS. &lt;strong&gt;Now&lt;/strong&gt; rotation is complete.&lt;/p&gt;
&lt;h2&gt;What &amp;quot;zero downtime&amp;quot; actually means&lt;/h2&gt;
&lt;p&gt;Specifically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No read-path stall — reads of old rows during the worker pass go through the normal decrypt path with the old key, which is still &lt;code&gt;active-read&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No write-path serialisation — writes during the worker pass land under the new generation and don&amp;#39;t conflict with the worker&amp;#39;s updates beyond the usual row locks.&lt;/li&gt;
&lt;li&gt;No application changes — generation handling is below the SQL layer.&lt;/li&gt;
&lt;li&gt;No required maintenance window — the only &amp;quot;instantaneous&amp;quot; operation is the generation promotion in step 2, which is a single-row update on the key-set table.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What it does cost:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IO during the background pass. A re-encrypt is approximately one logical read + one logical write per row, plus WAL.&lt;/li&gt;
&lt;li&gt;Cache memory for any live generation. Trivial.&lt;/li&gt;
&lt;li&gt;KMS unwrap calls when a new generation appears or a cache TTL expires. Small.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The knob to leave alone&lt;/h2&gt;
&lt;p&gt;The vault has a &lt;code&gt;rotation.aggressive&lt;/code&gt; flag that forces a full synchronous re-encrypt scan inside the transaction that promotes the new generation. It exists because the test suite needs to deterministically produce a fully-rotated table to assert on. It is &lt;strong&gt;catastrophic&lt;/strong&gt; on a real workload:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It serialises every row in the table behind the promotion transaction.&lt;/li&gt;
&lt;li&gt;It holds locks long enough that anything touching the table will queue.&lt;/li&gt;
&lt;li&gt;On a multi-terabyte table it will not complete inside any reasonable transaction timeout, and you will get to find out what your client-side retry behaviour does when a 4-hour transaction aborts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We&amp;#39;ve thought about removing it. Strong opinion: if you find yourself reaching for &lt;code&gt;rotation.aggressive&lt;/code&gt; on a production cluster, the actual answer is to let the background worker run and check back in a few hours.&lt;/p&gt;
&lt;h2&gt;Destroying a generation&lt;/h2&gt;
&lt;p&gt;Real revocation — &amp;quot;make this key materially impossible to use, even by an operator with database root&amp;quot; — is the same dance from the opposite direction:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Move the doomed generation to &lt;code&gt;retired&lt;/code&gt; in the key-set table.&lt;/li&gt;
&lt;li&gt;Delete the wrapped key from KMS.&lt;/li&gt;
&lt;li&gt;Any row still tagged with that generation is now permanently undecryptable.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 3 is the bit you actually want for incident response: even a leaked database backup is useless without the KMS-resident master key. The envelopes are still there; they just can&amp;#39;t be unwrapped.&lt;/p&gt;
&lt;p&gt;This is also how erasure works for compliance — for a &amp;quot;forget this user&amp;quot; request that crosses replicas, backups, and WAL archives, the cleanest path is per-tenant generations: retire and destroy the tenant&amp;#39;s generation and every replica, backup, and WAL segment containing that tenant&amp;#39;s ciphertext becomes cryptographically inert in one step. We&amp;#39;ll write that up separately.&lt;/p&gt;
&lt;h2&gt;Where this lives in the architecture&lt;/h2&gt;
&lt;p&gt;The vault layer sits between SQL parsing and the storage engine. From the SQL layer&amp;#39;s point of view, encrypted columns are just bytes with two helpful functions. From the storage engine&amp;#39;s point of view, there&amp;#39;s no encryption at all — it sees opaque blobs.&lt;/p&gt;
&lt;p&gt;That separation is what makes the system honest about its threat model. We protect against backup theft, replica theft, and KMS-tier revocation. We do &lt;strong&gt;not&lt;/strong&gt; protect against a compromised database process — the unwrapped keys are in memory by design while the system is serving traffic. If your threat model includes &amp;quot;operator with database root reads decrypted rows,&amp;quot; you need a different layer of the stack (typically client-side encryption, where the database never sees plaintext or unwrapped keys).&lt;/p&gt;
&lt;h2&gt;The shorter version&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Each row carries a generation byte.&lt;/li&gt;
&lt;li&gt;Master keys are versioned, never replaced.&lt;/li&gt;
&lt;li&gt;Reads use the row&amp;#39;s generation; writes use the current active-write.&lt;/li&gt;
&lt;li&gt;Rotation is: provision, promote, then background re-encrypt at your own pace.&lt;/li&gt;
&lt;li&gt;The synchronous-re-encrypt flag exists for the test suite. Leave it alone.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The follow-ups in this series will cover per-tenant key isolation, BYOK and KMS integration patterns (and the failure modes nobody writes about), and GDPR right-to-erasure when your data is replicated, embedded, and backed up.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/lsm-tree-compaction-notes</id>
    <title>Notes from compaction hell</title>
    <link href="https://reddb.io/blog/lsm-tree-compaction-notes"/>
    <updated>2026-05-06</updated>
    <published>2026-05-06</published>
    <author><name>RedDB team</name></author>
    <summary>Three months of LSM compaction tuning at production scale — the latency tail we measured, the four configuration changes that actually moved P99, and the one we wish we&apos;d made on day one.</summary>
    <content type="html">&lt;p&gt;LSM trees are a great default until they aren&amp;#39;t. We spent a quarter pushing on RedDB&amp;#39;s storage engine under a workload that turned out to be antagonistic to the defaults — and a quarter is roughly how long it took to learn which knobs mattered. This post is the version of those notes that we wish someone had handed us in week one.&lt;/p&gt;
&lt;p&gt;The shape of the problem matters, so: the workload was mixed OLTP (~80% point reads, ~15% range scans, ~5% writes), values ranging from 200 bytes to 64 KiB with a long tail, and a working set roughly 3× larger than RAM. Steady-state throughput was fine. The pain was the latency tail during major compactions: P99 spikes from ~4 ms to ~180 ms, lasting 30–90 seconds, repeating every few hours.&lt;/p&gt;
&lt;h2&gt;The Grafana screenshot we kept staring at&lt;/h2&gt;
&lt;p&gt;Imagine a P99 latency panel with a flat green floor at 4 ms and four red mountains per hour reaching 180 ms. The mountains are not random — they line up exactly with &lt;code&gt;lsm.compactions.in_progress&lt;/code&gt; going from 0 to 1, with &lt;code&gt;disk.read.bytes_per_sec&lt;/code&gt; doubling, and with the OS page cache hit rate dropping 8 percentage points. The peaks themselves are not the worry; the duration is. A 30-second window of bad tail latency, four times an hour, means a non-trivial fraction of customer requests get caught.&lt;/p&gt;
&lt;p&gt;The first three things we tried did nothing useful.&lt;/p&gt;
&lt;h2&gt;What did not help&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Reaching for tiered compaction at the first sign of write amp.&lt;/strong&gt; Tiered compaction trades read amp for write amp. Our workload was read-heavy. Switching strategies made the steady-state P50 worse without flattening the tail. We reverted in a week.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tuning per-level fan-out without first looking at value sizes.&lt;/strong&gt; We spent a sprint sweeping &lt;code&gt;level_multiplier&lt;/code&gt; from 8 to 16 and the only meaningful signal was noise. The actual problem was that compaction was moving 16 KiB values around to keep the keys sorted, and the keys weren&amp;#39;t the part that hurt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Adding more compaction threads when the bottleneck was disk, not CPU.&lt;/strong&gt; &lt;code&gt;iostat -x 1&lt;/code&gt; was showing the SSD pinned at 95% utilization during compactions; CPU was sitting at 30%. Throwing more threads at it made the tail &lt;em&gt;worse&lt;/em&gt; because the threads contended for I/O queue depth that the device couldn&amp;#39;t honor.&lt;/p&gt;
&lt;p&gt;The lesson is dull but real: measure before turning knobs. We had Prometheus metrics for compaction &lt;em&gt;count&lt;/em&gt; but not compaction &lt;em&gt;byte volume&lt;/em&gt;, and not the breakdown between key and value bytes. Adding those was the single most valuable hour of the quarter.&lt;/p&gt;
&lt;h2&gt;What did help&lt;/h2&gt;
&lt;h3&gt;1. Per-level bloom filter sizing&lt;/h3&gt;
&lt;p&gt;The default bloom configuration used 10 bits per key globally. That&amp;#39;s a defensible default — false positive rate around 1% — but the L0/L1 levels are on the hot path for every point read, and the cost of a false positive there is a full SSTable read on the wrong file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[lsm.bloom]
# 10 bits/key was the global default. Hot levels benefit from more.
level_0 = 16  # FPR ~0.04%, costs ~6 bits/key of RAM per level-0 file
level_1 = 14  # FPR ~0.16%
level_2 = 12  # FPR ~0.61%
default = 10  # FPR ~1%, for cold levels where bloom RAM matters more
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Effect on P99 point-read latency at steady state: 4.2 ms → 3.1 ms. Effect on RAM for bloom filters: +18%. Worth it.&lt;/p&gt;
&lt;h3&gt;2. Separating large values from the key-ordered storage&lt;/h3&gt;
&lt;p&gt;The biggest single win. Anything over 8 KiB now gets written to a blob area and the SSTable only stores a pointer. Compaction stops moving cold image bytes around to keep numeric IDs in sorted order. Background write volume during major compaction dropped 4.7×.&lt;/p&gt;
&lt;p&gt;The configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[lsm.blob]
enabled = true
min_value_size_bytes = 8192
gc_threshold_ratio = 0.5  # rewrite blob file when 50% of its bytes are dead
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Effect on P99 during compaction: 180 ms → 42 ms. This is the change that actually made the on-call complaints stop. If you remember nothing else from this post: blob separation for any workload with a meaningful tail of large values.&lt;/p&gt;
&lt;h3&gt;3. Backpressure on the write path&lt;/h3&gt;
&lt;p&gt;A slow client is uglier than a buggy one. When the LSM gets behind — when L0 has more files than &lt;code&gt;level0_slowdown_writes_trigger&lt;/code&gt; — we now intentionally inject delay into write acknowledgements rather than letting L0 grow unbounded. Letting L0 grow unbounded leads to a cliff: eventually compaction can&amp;#39;t keep up at all, reads start touching dozens of files, and the engine becomes unrecoverable without operator intervention.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[lsm.write_pressure]
level0_slowdown_writes_trigger = 20  # start adding delay
level0_stop_writes_trigger = 36      # hard stop, return WriteThrottled
soft_pending_compaction_bytes_limit = &amp;quot;64GiB&amp;quot;
hard_pending_compaction_bytes_limit = &amp;quot;256GiB&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client sees a &lt;code&gt;WriteThrottled&lt;/code&gt; error sooner under stress, which is a clear signal callers can react to (retry with backoff, queue, shed load). The alternative — silent unbounded growth until the engine collapses — is much worse for everyone.&lt;/p&gt;
&lt;h3&gt;4. Compaction priority by score, not by level&lt;/h3&gt;
&lt;p&gt;The default scheduler prefers compacting whichever level is most over-target. That&amp;#39;s fine in isolation but it ignores the fact that some files have many more delete tombstones than others, and compacting those is a &lt;em&gt;much&lt;/em&gt; better use of I/O budget than evenly trimming each level. We added a tombstone-density signal to the compaction priority score:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;fn compaction_priority(file: &amp;amp;SSTable) -&amp;gt; f64 {
    let size_pressure = file.size_bytes as f64 / file.target_size_bytes as f64;
    let tombstone_factor = 1.0 + 3.0 * file.tombstone_ratio.min(1.0);
    let age_factor = 1.0 + 0.5 * (file.age_seconds() / 86_400.0).min(2.0);
    size_pressure * tombstone_factor * age_factor
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read amp on tables with heavy churn dropped noticeably. The exact gain depends on your write pattern, but for our workload — where a few hot tables had ~40% tombstone density — point reads on those tables went from touching 4–6 SSTables to typically 1–2.&lt;/p&gt;
&lt;h2&gt;Concrete numbers, before and after&lt;/h2&gt;
&lt;p&gt;The table below is steady-state during business hours, averaged across our three largest production clusters, with the same workload and the same hardware.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Read P50&lt;/td&gt;
&lt;td&gt;1.8 ms&lt;/td&gt;
&lt;td&gt;1.4 ms&lt;/td&gt;
&lt;td&gt;Mostly the blob change keeping the page cache useful&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read P99 (no compaction)&lt;/td&gt;
&lt;td&gt;4.2 ms&lt;/td&gt;
&lt;td&gt;3.1 ms&lt;/td&gt;
&lt;td&gt;Per-level bloom sizing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read P99 (during major compaction)&lt;/td&gt;
&lt;td&gt;180 ms&lt;/td&gt;
&lt;td&gt;42 ms&lt;/td&gt;
&lt;td&gt;Blob separation is the bulk of this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write P99&lt;/td&gt;
&lt;td&gt;6.0 ms&lt;/td&gt;
&lt;td&gt;5.4 ms&lt;/td&gt;
&lt;td&gt;Mostly unchanged; backpressure rarely triggers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytes compacted per hour (peak)&lt;/td&gt;
&lt;td&gt;24 GiB&lt;/td&gt;
&lt;td&gt;5.1 GiB&lt;/td&gt;
&lt;td&gt;Blob bytes don&amp;#39;t move with keys anymore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM for bloom filters&lt;/td&gt;
&lt;td&gt;1.1 GiB&lt;/td&gt;
&lt;td&gt;1.3 GiB&lt;/td&gt;
&lt;td&gt;The tax for the bloom change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WriteThrottled&lt;/code&gt; errors per day&lt;/td&gt;
&lt;td&gt;0 (engine fell over instead)&lt;/td&gt;
&lt;td&gt;~12&lt;/td&gt;
&lt;td&gt;A clean error is better than a cliff&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The P99-during-compaction row is the one we care about most. 42 ms is not great, but it does not generate customer-visible incidents. 180 ms did.&lt;/p&gt;
&lt;h2&gt;Tiered compaction: revisited, and still wrong for us&lt;/h2&gt;
&lt;p&gt;After the other changes landed, we tried tiered again. With blob separation pulling the large-value cost out of compaction, the write-amp argument for tiered is much weaker. With per-level bloom sizing, the read-amp penalty of tiered is &lt;em&gt;more&lt;/em&gt; visible because the rest of the system is now faster. Tiered lost worse the second time than the first.&lt;/p&gt;
&lt;p&gt;If your workload is write-dominated with small values, the conclusion is probably opposite. We didn&amp;#39;t have that workload, so we don&amp;#39;t have credible numbers for it.&lt;/p&gt;
&lt;h2&gt;What we still don&amp;#39;t love&lt;/h2&gt;
&lt;p&gt;The latency tail during major compactions is honest, not pretty. 42 ms P99 is a number we can live with operationally — it doesn&amp;#39;t trip SLOs — but it&amp;#39;s a number, not a zero. The next move on our list is a &amp;quot;compaction budget&amp;quot; that limits how much I/O bandwidth compaction is allowed to consume during business hours and lets it catch up overnight. That&amp;#39;s a different post; we haven&amp;#39;t shipped it yet.&lt;/p&gt;
&lt;p&gt;The other thing we haven&amp;#39;t solved: snapshot isolation under heavy compaction load is fine for correctness but causes a small extra read amp because old SSTable generations have to stick around for readers. We&amp;#39;ve measured it at ~3% of total read I/O. It&amp;#39;s the kind of thing you only see if you go looking.&lt;/p&gt;
&lt;h2&gt;The one knob to turn first&lt;/h2&gt;
&lt;p&gt;If you remember one configuration change from this post, make it blob separation. Every workload we have ever profiled benefits, and the ones with even a small fraction of large values benefit dramatically. The other three changes are worth doing, but they are increments. Blob separation is the cliff.&lt;/p&gt;
&lt;p&gt;The order we wish we had done it in: blob separation, observability for compaction byte volume by file, per-level bloom sizing, backpressure thresholds, score-based priority. We did it roughly backward, which is why it took a quarter instead of a sprint.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/when-not-to-migrate-checklist</id>
    <title>When NOT to migrate: a checklist</title>
    <link href="https://reddb.io/blog/when-not-to-migrate-checklist"/>
    <updated>2026-05-04</updated>
    <published>2026-05-04</published>
    <author><name>RedDB team</name></author>
    <summary>The companion piece to every migration post. A decision tree for staying on your current stack — team size, pain quantification, runway — so you do not pay migration tax for a problem you did not have.</summary>
    <content type="html">&lt;p&gt;Most migration posts are sales pitches dressed up as advice. This one is the opposite. We have helped four teams migrate off Firebase and three off MongoDB in the last twelve months, and the most useful conversation we have with prospective customers is the one where we tell them not to migrate yet.&lt;/p&gt;
&lt;p&gt;A migration is a project that has a real shape: weeks of senior-engineer time, an integration risk window, a regression bug or two that ships to production, and a quarter of feature work you did not do because you were busy moving data. If the pain you are fleeing is smaller than that, you are about to trade a known annoyance for an unknown one. So before you cut, run the checks below. Each one ends in a &amp;quot;stay&amp;quot; verdict if the answer is honest. We have written it as a checklist on purpose — if you can put a tick next to every &amp;quot;stay&amp;quot; signal, close this tab and go back to the feature you were building.&lt;/p&gt;
&lt;h2&gt;Check 1: have you actually quantified the pain?&lt;/h2&gt;
&lt;p&gt;Engineers migrate because something &lt;em&gt;feels&lt;/em&gt; slow, expensive, or fragile. &amp;quot;Feels&amp;quot; is not a metric. Before you spend a quarter on a migration, the following numbers should be on a shared doc:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The actual dollar cost per month, broken out by line item. Not &amp;quot;Firebase is expensive&amp;quot; — &amp;quot;Firestore reads are $4,800/mo and growing 18% MoM.&amp;quot;&lt;/li&gt;
&lt;li&gt;The actual P99 latency of the queries that hurt. Not &amp;quot;Mongo is slow&amp;quot; — &amp;quot;the &lt;code&gt;$lookup&lt;/code&gt; pipeline behind /feed runs in 1.4s P99 and we have a 300ms target.&amp;quot;&lt;/li&gt;
&lt;li&gt;The actual incident count attributable to the current stack in the last 90 days. Not &amp;quot;we have ops pain&amp;quot; — &amp;quot;three Sev-2s in 90 days, two of which were rooted in MongoDB primary failover behaviour.&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you cannot put numbers in those three slots, your pain is vibes. &lt;strong&gt;Stay.&lt;/strong&gt; Spend the next two weeks instrumenting instead. Half the time the numbers come back smaller than the team&amp;#39;s intuition, and the migration cancels itself.&lt;/p&gt;
&lt;h2&gt;Check 2: how big is the team?&lt;/h2&gt;
&lt;p&gt;Migrations have a U-shaped cost-by-team-size curve. The two-person team can move fast (no coordination tax) but cannot afford to lose either engineer for four weeks. The fifty-person team has the slack but pays an enormous coordination cost (every team owns part of the schema). The teams that suffer least are in the awkward middle — five to fifteen engineers — and that is also where most of our customers sit.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team size&lt;/th&gt;
&lt;th&gt;Migration mode that works&lt;/th&gt;
&lt;th&gt;What to watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1–2 engineers&lt;/td&gt;
&lt;td&gt;Cut over in a sprint, no dual-write phase&lt;/td&gt;
&lt;td&gt;One bad incident kills the company. Have a rollback plan you have actually tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3–5 engineers&lt;/td&gt;
&lt;td&gt;Dual-write for two weeks, then promote&lt;/td&gt;
&lt;td&gt;The engineer doing the migration cannot also be on-call. Pre-negotiate this with the team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6–15 engineers&lt;/td&gt;
&lt;td&gt;Dual-write for a month, feature-flag the read side&lt;/td&gt;
&lt;td&gt;The migration owner needs explicit air cover from the CTO. Otherwise feature work eats the schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16+ engineers&lt;/td&gt;
&lt;td&gt;Per-service migration over a quarter&lt;/td&gt;
&lt;td&gt;Every service owner thinks their slice is special. Budget time for the meetings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Stay&lt;/strong&gt; if your team is at the edges (1 or 16+) and the pain numbers from Check 1 are not severe. The smallest teams should stretch the existing stack until either revenue or a hire makes a migration safer; the largest teams should plan a per-service migration as a multi-quarter program, not a project.&lt;/p&gt;
&lt;h2&gt;Check 3: is the pain in the data layer or somewhere else?&lt;/h2&gt;
&lt;p&gt;This is the check we wish more teams ran first. &amp;quot;The database is slow&amp;quot; turns out, surprisingly often, to be &amp;quot;our N+1 query in the orders service is slow&amp;quot; or &amp;quot;the cache layer is bypassed for the homepage.&amp;quot; Run an honest five-day investigation before you blame the storage engine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Profile the worst three endpoints. Where is the latency actually accumulating?&lt;/li&gt;
&lt;li&gt;Look at the query plans for the worst three queries. Are they doing what you expect?&lt;/li&gt;
&lt;li&gt;Check whether the most expensive endpoints have working caches and whether the caches are actually hit.&lt;/li&gt;
&lt;li&gt;Audit the slow-query log for the last week. Are 80% of the slow queries from a handful of identifiable shapes?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the answer is &amp;quot;yes, it is a small number of fixable shapes&amp;quot; — &lt;strong&gt;stay.&lt;/strong&gt; A query rewrite is cheaper than a migration, even when the migration eventually arrives anyway. We have watched teams migrate to &amp;quot;fix&amp;quot; a Mongo problem that turned out to be an unindexed field; they shipped the same N+1 query on the new engine, and the new engine ran it slowly too.&lt;/p&gt;
&lt;h2&gt;Check 4: what is your runway?&lt;/h2&gt;
&lt;p&gt;Migrations consume runway. Concretely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Senior-engineer-weeks: figure 4–10 depending on dual-write scope.&lt;/li&gt;
&lt;li&gt;Reduced feature velocity for the rest of the team during the cutover window (people get pulled into adjacent reviews and incident response).&lt;/li&gt;
&lt;li&gt;A higher-than-baseline rate of regressions in the first 30 days after cutover.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your company will run out of money inside that window, &lt;strong&gt;stay.&lt;/strong&gt; If the pain from Check 1 is &amp;quot;we&amp;#39;ll be insolvent if Firestore bills keep growing,&amp;quot; consider a &lt;em&gt;partial&lt;/em&gt; migration — move only the worst-cost path to a cheaper store, do not rewrite the auth path or the document model. Partial migrations sound ugly on the architecture diagram and work fine in practice.&lt;/p&gt;
&lt;h2&gt;Check 5: do you have a tested rollback?&lt;/h2&gt;
&lt;p&gt;Every migration plan promises rollback. Roughly one in four actually has one that has been exercised. Before you start, write the runbook for &amp;quot;the new engine is on fire, we need to be back on the old one in two hours.&amp;quot; Read it back to yourself. Can you actually do it?&lt;/p&gt;
&lt;p&gt;The honest version of rollback looks like:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Dual-write was on the whole time, so the old store still has correct data through last write.&lt;/li&gt;
&lt;li&gt;Feature flag flips reads back to the old store.&lt;/li&gt;
&lt;li&gt;Whatever writes happened only on the new store between cutover and rollback are surfaced for manual reconciliation (a script lists them; a human reviews).&lt;/li&gt;
&lt;li&gt;The team has actually run this end-to-end on staging within the last two weeks.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If steps 1–4 are not all true, &lt;strong&gt;stay.&lt;/strong&gt; Tighten the rollback story until they are. The cost of doing this work is much smaller than the cost of an unplanned rollback at 3am, and roughly the same as the cost of an unplanned non-rollback (the case where rollback was possible in principle but nobody dared push the button).&lt;/p&gt;
&lt;h2&gt;Check 6: is the new stack actually better for &lt;em&gt;your&lt;/em&gt; shape?&lt;/h2&gt;
&lt;p&gt;This is where we will be most direct against our own product. RedDB is good at workloads with cross-shape access patterns (RAG over mutable docs, agent memory, search-over-application-data, multi-tenant SaaS where you need vector + text + structured per tenant). It is not the right choice for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Single-shape workloads. If you only need OLTP, Postgres is more mature and has a larger labour market. &lt;strong&gt;Stay&lt;/strong&gt; on Postgres.&lt;/li&gt;
&lt;li&gt;Analytics-dominant workloads. If 80% of your queries are scans over historical data, ClickHouse or DuckDB will beat us. &lt;strong&gt;Stay&lt;/strong&gt; on what you have until you can move to a column store.&lt;/li&gt;
&lt;li&gt;Embedded / single-process workloads. SQLite is the right answer for a desktop app or a CLI. We are an over-investment.&lt;/li&gt;
&lt;li&gt;Hyper-low write volume. If your bill on the current managed Postgres is $40/mo, the operational overhead of a less-mature engine is the bigger cost. &lt;strong&gt;Stay&lt;/strong&gt; on the managed service.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We wrote a whole post on this — &lt;em&gt;When you don&amp;#39;t need RedDB&lt;/em&gt; — and the short version is: if the migration target is not unambiguously better for your specific workload shape, the migration is paying for an answer to a question you did not ask.&lt;/p&gt;
&lt;h2&gt;Check 7: who on the team will own the new stack at 3am?&lt;/h2&gt;
&lt;p&gt;This is the social check, and it kills more migrations than any technical one. Migrations frequently succeed for six months and then quietly fail in month nine when the engineer who ran the migration leaves the company. The new stack is now an artifact nobody understands; on-call rotations get awkward; the next migration is back to the old engine.&lt;/p&gt;
&lt;p&gt;Before you migrate, name on a shared doc:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The primary owner of the new stack.&lt;/li&gt;
&lt;li&gt;The secondary owner.&lt;/li&gt;
&lt;li&gt;The cross-training plan to get to at least three people who can debug it under pressure.&lt;/li&gt;
&lt;li&gt;The runbook location.&lt;/li&gt;
&lt;li&gt;The dashboards.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you cannot fill in those slots with real names, &lt;strong&gt;stay.&lt;/strong&gt; Hire or train first.&lt;/p&gt;
&lt;h2&gt;The &amp;quot;go&amp;quot; signal looks like this&lt;/h2&gt;
&lt;p&gt;After all seven checks, you should migrate only if all of these are true:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The pain has real numbers attached, and the numbers are worse than the migration cost.&lt;/li&gt;
&lt;li&gt;The team size matches a viable migration mode (or you can wait until it does).&lt;/li&gt;
&lt;li&gt;The pain is genuinely in the data layer, not somewhere upstream.&lt;/li&gt;
&lt;li&gt;The runway covers the migration window with a buffer.&lt;/li&gt;
&lt;li&gt;A rollback plan exists and has been exercised on staging.&lt;/li&gt;
&lt;li&gt;The new stack is unambiguously better for your specific workload shape.&lt;/li&gt;
&lt;li&gt;At least two engineers will own the new stack with names on a doc.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If even one of those is false, the cheapest move is &amp;quot;stay, fix the highest-leverage thing on the current stack, revisit in a quarter.&amp;quot; We tell prospective customers this often enough that we have a stock phrase for it: &lt;em&gt;the best migration is the one you did not have to do this quarter.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;Migrations look like architecture work and behave like organisational work. The technical part is comparatively easy; the runway, ownership, and rollback parts are what kill projects. Before you cut over, write down the numbers (Check 1), match the team size to a viable mode (Check 2), make sure the pain is actually in the data layer (Check 3), confirm runway (Check 4), exercise rollback (Check 5), check the new stack fits your shape (Check 6), and name the people who will own it (Check 7).&lt;/p&gt;
&lt;p&gt;If you tick every box, run the migration. If not, the &lt;a href=&quot;/blog/out-of-firebase-into-a-real-backend&quot;&gt;Out of Firebase&lt;/a&gt; and &lt;a href=&quot;/blog/mongodb-to-reddb-queries-that-change-shape&quot;&gt;Mongo → RedDB&lt;/a&gt; posts will still be here in six months when the answer is yes.&lt;/p&gt;
&lt;p&gt;In the meantime: instrument, profile, rewrite the worst queries, fix the cache, and bank the engineer-weeks. That is the boring answer and it is right more often than the exciting one.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/gdpr-erasure-replicated-embedded</id>
    <title>GDPR right-to-erasure when your data is replicated and embedded</title>
    <link href="https://reddb.io/blog/gdpr-erasure-replicated-embedded"/>
    <updated>2026-05-03</updated>
    <published>2026-05-03</published>
    <author><name>RedDB team</name></author>
    <summary>Article 17 across replicas, derived embeddings, backups, and WAL — what RedDB does automatically and what still needs an operator runbook.</summary>
    <content type="html">&lt;p&gt;A user clicks &lt;em&gt;delete my account&lt;/em&gt;. Forty seconds later their row is gone from the primary. The DPO signs the ticket. Eleven months later a vendor audit pulls a backup, replays it into a sandbox, and the deleted user&amp;#39;s chat history shows up in a similarity search against an embedding index that nobody remembered to rebuild. The audit goes from clean to findings-with-fine-exposure on a single query.&lt;/p&gt;
&lt;p&gt;GDPR Article 17 sounds like a &lt;code&gt;DELETE FROM users WHERE id = ?&lt;/code&gt;. In a system with replicas, derived embeddings, time-travel backups, and a WAL the regulator can subpoena, it is not. This is the operator playbook for what RedDB does for you, what it does not, and the holes you have to close by hand.&lt;/p&gt;
&lt;h2&gt;The four places PII hides after &lt;code&gt;DELETE&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Before discussing tooling, name the surfaces. Every system has them; most teams have only enumerated two:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Live row&lt;/strong&gt; — the obvious one. &lt;code&gt;DELETE&lt;/code&gt; removes it from the table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replicas and read followers&lt;/strong&gt; — async replication eventually catches up. The window is usually seconds, but during a partition it can be minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Derived data&lt;/strong&gt; — anything computed &lt;em&gt;from&lt;/em&gt; the row that wasn&amp;#39;t itself the row. Embeddings are the headline case. Materialised views, denormalised search indexes, ML feature stores, downstream warehouses, cached snapshots in your CDN — all of these.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time-travel artefacts&lt;/strong&gt; — backups, WAL segments retained for PITR, snapshot exports shipped to a DR region. These are &lt;em&gt;intended&lt;/em&gt; to survive deletes. That is exactly the problem.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Regulators have caught up to (3). The 2024 EDPB opinion on AI training data, the Hamburg DPA&amp;#39;s 2025 ruling on embedding personalisation, and the French CNIL&amp;#39;s guidance on vector stores all say the same thing in different words: &lt;strong&gt;a vector derived from personal data is personal data&lt;/strong&gt;. You cannot point at an opaque float array and call it anonymous. If the vector lets you identify the subject through similarity search, it counts.&lt;/p&gt;
&lt;p&gt;This is why your erasure workflow now has to traverse the derived layer, not just the source.&lt;/p&gt;
&lt;h2&gt;What RedDB does automatically&lt;/h2&gt;
&lt;p&gt;In order of how confident you should be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hard &lt;code&gt;DELETE&lt;/code&gt; on the live row.&lt;/strong&gt; The row is gone from the table. Foreign-keyed children with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; are gone too. This is table-stakes and works.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replication propagates the delete.&lt;/strong&gt; Logical replication carries the tombstone to followers within the standard lag window. The &lt;code&gt;replication_lag_bytes&lt;/code&gt; metric (see the &lt;a href=&quot;/blog/self-hosting-reddb-real-world-checklist/&quot;&gt;self-host checklist&lt;/a&gt;) tells you when to wait. There is no separate replica-erasure command; the same WAL record that deletes on the leader deletes on the follower.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cascading delete of embedding columns.&lt;/strong&gt; This is the payoff for &lt;a href=&quot;/blog/rag-without-second-database/&quot;&gt;keeping the embedding on the row instead of in a second store&lt;/a&gt;. When the row goes, the vector goes — atomically, in the same transaction. There is no eventual job, no queue, no drift window. Compare with the multi-store shape, where you delete from Postgres and &lt;em&gt;then&lt;/em&gt; have to remember to delete from Pinecone or Qdrant, and where a botched second call leaves the embedding behind.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background compaction of the on-disk tombstone.&lt;/strong&gt; The LSM compactor (see &lt;a href=&quot;/blog/lsm-tree-compaction-notes/&quot;&gt;compaction notes&lt;/a&gt;) eventually rewrites SSTables and the deleted bytes stop existing on disk. Typical worst-case 24–48 hours under steady write load; faster under explicit &lt;code&gt;reddbctl compact --tombstones&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;#39;s the floor. Most teams stop here, ship a &amp;quot;we are GDPR-compliant&amp;quot; page, and find out about the rest during an audit.&lt;/p&gt;
&lt;h2&gt;What still needs an operator runbook&lt;/h2&gt;
&lt;p&gt;The three surfaces RedDB does &lt;em&gt;not&lt;/em&gt; erase automatically:&lt;/p&gt;
&lt;h3&gt;Surface 1 — Backups and WAL segments retained for PITR&lt;/h3&gt;
&lt;p&gt;Your backup policy probably retains daily snapshots for 30 days and WAL for 35 (the numbers from the &lt;a href=&quot;/blog/disaster-recovery-for-reddb/&quot;&gt;DR post&lt;/a&gt;). For 35 days after the user clicks delete, the &lt;em&gt;previous&lt;/em&gt; state — including their PII — is sitting in object storage.&lt;/p&gt;
&lt;p&gt;Three strategies, in order of how regulators actually treat them:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;(a) Wait it out — &amp;quot;deletion by expiry.&amp;quot;&lt;/strong&gt; Most DPAs accept that a backup taken before the erasure request, retained for a &lt;em&gt;documented and justified&lt;/em&gt; retention period, will eventually rotate out. You document: &amp;quot;user deleted at T; backups expire at T+30d; full erasure complete at T+35d.&amp;quot; The audit looks for the &lt;em&gt;policy&lt;/em&gt; and the &lt;em&gt;expiry receipt&lt;/em&gt;, not real-time scrubbing.&lt;/p&gt;
&lt;p&gt;This is the easy answer, but only if your retention is short and bounded. If you keep yearly backups &amp;quot;for accounting&amp;quot; you are signing yourself up for surface 1 problems forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;(b) Crypto-shredding.&lt;/strong&gt; This is why the &lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;per-row encryption&lt;/a&gt; and &lt;a href=&quot;/blog/byok-kms-multitenant-secrets/&quot;&gt;BYOK/KMS&lt;/a&gt; work matters. If every tenant — or every user, if you go that far — has a distinct DEK wrapped by a CMK, deleting the CMK makes every backup of their data ciphertext-only forever. You do not need to rewrite the backups; the key to read them no longer exists.&lt;/p&gt;
&lt;p&gt;The operator command for this looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Schedule the per-tenant CMK for deletion in your KMS.
# Use the longest pending-window your KMS allows so accidental
# triggers are recoverable, but make the window expiry mechanical.
aws kms schedule-key-deletion \
  --key-id &amp;quot;$TENANT_CMK_ARN&amp;quot; \
  --pending-window-in-days 7

# Record the scheduled deletion against the erasure ticket.
reddbctl audit log \
  --event &amp;quot;erasure.crypto-shred.scheduled&amp;quot; \
  --tenant &amp;quot;$TENANT_ID&amp;quot; \
  --cmk &amp;quot;$TENANT_CMK_ARN&amp;quot; \
  --completion-at &amp;quot;$(date -d &amp;#39;+7 days&amp;#39; --iso-8601)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Crypto-shredding is the only approach that handles backups &lt;em&gt;and&lt;/em&gt; WAL segments &lt;em&gt;and&lt;/em&gt; DR replicas in one operation. It is also the one regulators have started naming approvingly in guidance (CNIL 2024, ICO 2025). Cost: you must have built per-subject DEK separation up-front. Retrofitting it is months of work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;(c) Backup rewrite.&lt;/strong&gt; Restore each backup, run the delete, re-snapshot, encrypt, ship to the same object-store path. Possible. Slow. Expensive. Most teams who try this once switch to (b).&lt;/p&gt;
&lt;h3&gt;Surface 2 — Derived embeddings that &lt;em&gt;aren&amp;#39;t&lt;/em&gt; a column on the row&lt;/h3&gt;
&lt;p&gt;The atomic-delete-via-cascade story only works if the embedding lives on the row. If you copied embeddings into:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a separate vector store (Pinecone, Qdrant, Weaviate, pgvector in a different cluster),&lt;/li&gt;
&lt;li&gt;a feature store keyed by user_id,&lt;/li&gt;
&lt;li&gt;a search index that retained the chunked text,&lt;/li&gt;
&lt;li&gt;a downstream warehouse fact table,&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;…then &lt;code&gt;DELETE FROM users&lt;/code&gt; does nothing to those copies. You need an explicit fan-out.&lt;/p&gt;
&lt;p&gt;The pattern is an &lt;em&gt;erasure outbox&lt;/em&gt;. Same row, same transaction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

DELETE FROM users WHERE id = $1;

INSERT INTO erasure_outbox (subject_id, surfaces, requested_at)
VALUES (
  $1,
  ARRAY[&amp;#39;vector_store_v2&amp;#39;, &amp;#39;feature_store&amp;#39;, &amp;#39;warehouse_pii_dim&amp;#39;, &amp;#39;cdn_cache&amp;#39;],
  now()
);

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A worker drains the outbox and issues the surface-specific delete to each downstream system. The outbox row is only removed once each surface has confirmed (or returned 404, which counts). The DPO dashboard joins &lt;code&gt;erasure_outbox&lt;/code&gt; against the ticketing system: any subject older than the legally-binding deadline (30 days in the EU, 45 in California, &lt;em&gt;less&lt;/em&gt; under many sectoral rules) and not fully fanned-out is the on-call&amp;#39;s problem.&lt;/p&gt;
&lt;p&gt;The transaction guarantees you cannot delete the live row &lt;em&gt;without&lt;/em&gt; enqueueing the downstream sweep. The opposite mistake — sweeping downstream and forgetting to delete locally — is impossible because the delete and the enqueue are in the same WAL record.&lt;/p&gt;
&lt;h3&gt;Surface 3 — Embeddings as PII even when the row is gone&lt;/h3&gt;
&lt;p&gt;This is the surface most teams discover during their first regulator inquiry. You followed the cascade pattern, the embedding column went with the row, you can prove it. Then the auditor asks: &amp;quot;what about the embedding&amp;#39;s &lt;em&gt;position in the index&lt;/em&gt;? You have an HNSW graph. Nearest-neighbour queries against the index still return useful information about the deleted subject for some hours until compaction rebuilds the graph.&amp;quot;&lt;/p&gt;
&lt;p&gt;HNSW graphs are an interesting case. Deleting a vector marks the node as soft-deleted; queries skip it; but the graph topology — the &lt;em&gt;links&lt;/em&gt; — were chosen partly because of that vector&amp;#39;s position. The information leakage from this is microscopic and academic. Regulators have not asked about it in any case we&amp;#39;ve heard of. But it is the kind of thing a determined auditor will raise, so:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;reddbctl index rebuild --tombstone-aware vec_index_users&lt;/code&gt; command (current RedDB) fully reconstructs the graph from live vectors. Run it monthly or after any bulk erasure event.&lt;/li&gt;
&lt;li&gt;Document the rebuild cadence in your DPIA so it does not become a finding.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;A complete erasure runbook&lt;/h2&gt;
&lt;p&gt;Stitching it together, this is what a production erasure looks like end-to-end:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- T+0: subject requests erasure via support portal.
-- Ticket system writes a row to erasure_requests with a 30-day SLA.

BEGIN;

-- 1. Hard delete from primary, cascading to chunks + embedding column.
DELETE FROM users WHERE id = $subject_id;

-- 2. Enqueue downstream fan-out. Same transaction = no drift.
INSERT INTO erasure_outbox (subject_id, surfaces, requested_at)
VALUES ($subject_id, ARRAY[&amp;#39;vector_store_external&amp;#39;, &amp;#39;warehouse&amp;#39;, &amp;#39;cdn&amp;#39;], now());

-- 3. Audit log entry that satisfies Article 30 record-keeping.
INSERT INTO audit_log (event, subject_id, actor, at)
VALUES (&amp;#39;erasure.requested&amp;#39;, $subject_id, $support_agent_id, now());

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# T+seconds: replication catches up. Watch the metric.
reddbctl metric replication_lag_bytes --threshold 0 --wait 60s

# T+minutes: outbox worker fans out.
# Each surface&amp;#39;s DELETE is idempotent; 404 is success.
# Outbox row removed only when every surface returns success.

# T+24-48h: LSM compaction rewrites SSTables, on-disk bytes gone.
reddbctl compact --tombstones --table users  # optional acceleration

# T+7d: crypto-shred CMK if per-subject keys.
aws kms schedule-key-deletion --key-id &amp;quot;$SUBJECT_CMK&amp;quot; --pending-window-in-days 7

# T+30d: snapshot retention boundary. Last snapshot containing the
# subject expires. Erasure complete by policy.

# T+monthly: HNSW graph rebuild absorbs any soft-deleted vectors.
reddbctl index rebuild --tombstone-aware vec_index_users
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The DPO sees a single dashboard: erasure ticket id, T+0, current surface status, completion ETA against the 30-day legal deadline.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s automatic vs. operator-driven (the cheat sheet)&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;RedDB does&lt;/th&gt;
&lt;th&gt;You have to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Live row&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE&lt;/code&gt; cascades to children and embedding column&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replicas / followers&lt;/td&gt;
&lt;td&gt;Tombstone replicated via WAL&lt;/td&gt;
&lt;td&gt;Verify &lt;code&gt;replication_lag_bytes&lt;/code&gt; returns to 0 before closing ticket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-disk tombstone&lt;/td&gt;
&lt;td&gt;Compaction reclaims bytes on standard schedule&lt;/td&gt;
&lt;td&gt;(Optional) &lt;code&gt;reddbctl compact --tombstones&lt;/code&gt; to accelerate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups (snapshot)&lt;/td&gt;
&lt;td&gt;Expire on retention schedule&lt;/td&gt;
&lt;td&gt;Document policy; consider crypto-shredding for tighter SLA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAL retained for PITR&lt;/td&gt;
&lt;td&gt;Expires with the segment&lt;/td&gt;
&lt;td&gt;Crypto-shred for sub-retention-window deletion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External vector store&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Fan out via &lt;code&gt;erasure_outbox&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warehouse / feature store&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Fan out via &lt;code&gt;erasure_outbox&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HNSW graph topology&lt;/td&gt;
&lt;td&gt;Soft-deletes from query results&lt;/td&gt;
&lt;td&gt;Monthly rebuild with &lt;code&gt;--tombstone-aware&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit log of the erasure itself&lt;/td&gt;
&lt;td&gt;Append-only event&lt;/td&gt;
&lt;td&gt;Keep this — Article 30 requires it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The right way to read this table: anything in the &amp;quot;you have to do&amp;quot; column is the audit-finding column if you forget it. None of it is hard. All of it is operationally invisible to anyone who hasn&amp;#39;t asked.&lt;/p&gt;
&lt;h2&gt;Three things that aren&amp;#39;t in scope (yet)&lt;/h2&gt;
&lt;p&gt;Honesty matters. RedDB does not currently:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Surface a single &lt;code&gt;reddbctl erase --subject ...&lt;/code&gt; command&lt;/strong&gt; that drives the whole pipeline. That&amp;#39;s a roadmap item — the integration points are too tenant-specific (which downstream surfaces, which KMS, which ticketing system) to ship a default that doesn&amp;#39;t lie. For now, the runbook above is the contract.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Encrypt the audit log itself per subject.&lt;/strong&gt; The audit row stating &lt;em&gt;we erased subject X at time T&lt;/em&gt; is itself technically PII. Most DPAs accept this under Article 17(3)(b)/(e) (compliance with legal obligation and defence of legal claims). We aren&amp;#39;t applying crypto-shredding to the audit log; you shouldn&amp;#39;t either.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sync the rebuild across regions atomically.&lt;/strong&gt; If you maintain HNSW indexes in two regions, the rebuild runs region-by-region. The leakage window between regions is on the order of the rebuild duration (minutes to hours). Document it.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;When to start&lt;/h2&gt;
&lt;p&gt;The fastest way to fall behind on Article 17 is to leave it for the audit. Ten engineering hours now — schema for the outbox, a worker, a dashboard, a runbook — costs less than ten hours of legal time after a single inquiry, even before any potential penalty.&lt;/p&gt;
&lt;p&gt;The harder change — per-subject DEKs so that crypto-shredding is on the table — is months of work and only worth it if you sell to buyers who will ask. If you do not yet have those buyers, the policy-expiry approach (a) above is enough. The day a regulated buyer says &lt;em&gt;we need crypto-shred&lt;/em&gt;, you start. Not before.&lt;/p&gt;
&lt;p&gt;The one thing everyone should do &lt;em&gt;today&lt;/em&gt;: write the outbox table and the fan-out worker. Cascading the embedding column on &lt;code&gt;DELETE&lt;/code&gt; is the cheapest piece of Article 17 alignment you will ever buy, and most teams already structured their schema to support it without realising they were halfway there.&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Article 17 is no longer just &lt;code&gt;DELETE FROM users&lt;/code&gt;. Embeddings derived from PII are PII.&lt;/li&gt;
&lt;li&gt;Keeping the embedding on the row turns the hardest erasure surface into a &lt;code&gt;CASCADE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Replicas erase by WAL; on-disk bytes by compaction; backups by retention or crypto-shred.&lt;/li&gt;
&lt;li&gt;External surfaces (vector stores, warehouses, feature stores) need an erasure outbox driven in the same transaction as the delete.&lt;/li&gt;
&lt;li&gt;HNSW graph topology is a tiny leakage surface; monthly tombstone-aware rebuild closes it.&lt;/li&gt;
&lt;li&gt;Crypto-shredding via per-tenant or per-subject CMKs is the only single-step approach for backups and WAL. It only works if you architected for it.&lt;/li&gt;
&lt;li&gt;The audit log of the erasure itself is allowed to persist under Article 17(3).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Related reading: &lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;per-row encryption with zero-downtime key rotation&lt;/a&gt;, &lt;a href=&quot;/blog/byok-kms-multitenant-secrets/&quot;&gt;BYOK, KMS, and the boring parts of multi-tenant secrets&lt;/a&gt;, &lt;a href=&quot;/blog/rag-without-second-database/&quot;&gt;RAG without a second database&lt;/a&gt;, &lt;a href=&quot;/blog/disaster-recovery-for-reddb/&quot;&gt;DR for RedDB&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/snapshot-isolation-in-practice</id>
    <title>Snapshot isolation, in practice</title>
    <link href="https://reddb.io/blog/snapshot-isolation-in-practice"/>
    <updated>2026-05-02</updated>
    <published>2026-05-02</published>
    <author><name>RedDB team</name></author>
    <summary>What snapshot isolation gives you, what it doesn&apos;t, the write-skew anomaly that still bites, and the one application pattern that fixes most real cases.</summary>
    <content type="html">&lt;p&gt;Saying &amp;quot;we use snapshot isolation&amp;quot; is a sentence. Living with it is a stack of small decisions that every team makes again, badly, the first time a customer hits a write-skew bug at 2am.&lt;/p&gt;
&lt;p&gt;This post is the cheat sheet we wished we had: what you get, what you don&amp;#39;t, what error your client actually sees, why we don&amp;#39;t auto-retry, and the one application pattern that resolves most real-world write-skew without coordination.&lt;/p&gt;
&lt;h2&gt;What snapshot isolation gives you&lt;/h2&gt;
&lt;p&gt;Each transaction sees a consistent snapshot of the database as of the moment it began. Readers never block writers. Writers never block readers. Two transactions can read overlapping rows in parallel — neither waits for the other to commit.&lt;/p&gt;
&lt;p&gt;For 90% of OLTP code that previously ran on read-committed Postgres or MySQL with &lt;code&gt;REPEATABLE READ&lt;/code&gt;, this is a strict upgrade:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No more dirty reads, non-repeatable reads, or phantom reads inside a single transaction.&lt;/li&gt;
&lt;li&gt;Long-running reports stop blocking the write path.&lt;/li&gt;
&lt;li&gt;&amp;quot;Why is my SELECT seeing half-applied changes?&amp;quot; disappears as a category.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That part is real and wonderful. You can stop reading here if your workload is small and you only ever update one row per transaction.&lt;/p&gt;
&lt;h2&gt;What snapshot isolation does NOT give you&lt;/h2&gt;
&lt;p&gt;Snapshot isolation is &lt;strong&gt;not&lt;/strong&gt; serializable. There&amp;#39;s exactly one anomaly that survives, and it has a name: &lt;strong&gt;write skew&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The textbook example uses doctors going off-call. Forget the textbook. Here&amp;#39;s one we actually shipped a patch for:&lt;/p&gt;
&lt;h3&gt;Real write-skew: tenant seat allocation&lt;/h3&gt;
&lt;p&gt;A SaaS plan permits up to &lt;code&gt;N&lt;/code&gt; active seats per tenant. The check-and-insert looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;
  -- T1 and T2 both see active_count = 4, max = 5
  SELECT count(*) FROM seats
   WHERE tenant_id = $1 AND active = true;
  -- T1 inserts seat #5 → 4 + 1 = 5, within limit, commit.
  -- T2 inserts seat #5 → 4 + 1 = 5, within limit, commit.
  INSERT INTO seats(tenant_id, user_id, active) VALUES ($1, $2, true);
COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both transactions read the same snapshot. Neither modifies a row the other modifies — they each insert a &lt;em&gt;different&lt;/em&gt; new row. Snapshot isolation has no conflict to detect. Both commit. The tenant now has 6 active seats on a 5-seat plan.&lt;/p&gt;
&lt;p&gt;Under serializable isolation the database would force one of them to abort. Under snapshot isolation, you have to do the work.&lt;/p&gt;
&lt;p&gt;We&amp;#39;ve seen this exact shape in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Inventory reservation (last unit sold twice).&lt;/li&gt;
&lt;li&gt;Bank-style transfers when the constraint is &amp;quot;account balance must stay non-negative&amp;quot; and two debits land on the same account from different sessions.&lt;/li&gt;
&lt;li&gt;Approval workflows where exactly one approver should advance a step.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern is always: &lt;strong&gt;the invariant spans rows the transaction reads but does not modify&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;What clients actually see&lt;/h2&gt;
&lt;p&gt;When two transactions modify the same row and one commits first, the second receives:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ERROR: serialization conflict on commit
SQLSTATE: 40001
DETAIL: write conflict with concurrent transaction &amp;lt;txid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SQLSTATE 40001&lt;/code&gt; is the standard signal. Every mature client library checks for it. We deliberately picked the same code Postgres uses so that existing retry middleware in your stack just works.&lt;/p&gt;
&lt;p&gt;What the second transaction sees on its &lt;code&gt;COMMIT&lt;/code&gt; call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;try {
  await db.transaction(async (tx) =&amp;gt; {
    await tx.query(&amp;#39;UPDATE counters SET n = n + 1 WHERE id = $1&amp;#39;, [id])
    await tx.query(&amp;#39;INSERT INTO events(...) VALUES (...)&amp;#39;)
  })
} catch (err) {
  if (err.code === &amp;#39;40001&amp;#39;) {
    // Conflict. Application decides what to do.
  }
  throw err
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The whole transaction is rolled back. No partial state, no half-applied writes, no need to clean up manually.&lt;/p&gt;
&lt;h2&gt;Why we don&amp;#39;t auto-retry&lt;/h2&gt;
&lt;p&gt;Several engines retry &lt;code&gt;40001&lt;/code&gt; transparently inside the driver. We considered it. We chose not to. Three reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Retries hide concurrency bugs.&lt;/strong&gt; A function that silently runs 1–N times changes meaning. Idempotency stops being optional — it becomes a load-bearing requirement that nobody documents until production breaks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The retry budget belongs to the application.&lt;/strong&gt; &amp;quot;Retry 3 times with backoff&amp;quot; is correct for some workloads. For others (financial), the right answer is &amp;quot;abort, surface to user, let them re-issue.&amp;quot; We can&amp;#39;t pick that for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auto-retry composes badly with side effects.&lt;/strong&gt; If your transaction calls &lt;code&gt;sendEmail()&lt;/code&gt; mid-flight via a trigger or hook, retry sends two emails. The retry decision has to live at the layer that knows what&amp;#39;s safe to repeat.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So you get an explicit &lt;code&gt;40001&lt;/code&gt; and you decide. In practice most teams add a small wrapper:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;async function withRetry&amp;lt;T&amp;gt;(fn: () =&amp;gt; Promise&amp;lt;T&amp;gt;, attempts = 3): Promise&amp;lt;T&amp;gt; {
  for (let i = 0; i &amp;lt; attempts; i++) {
    try { return await fn() } catch (err: any) {
      if (err.code !== &amp;#39;40001&amp;#39; || i === attempts - 1) throw err
      await sleep(20 * Math.pow(2, i) + Math.random() * 20)
    }
  }
  throw new Error(&amp;#39;unreachable&amp;#39;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That belongs in your application layer. We refuse to put it in the driver.&lt;/p&gt;
&lt;h2&gt;The one pattern that fixes most real write-skew&lt;/h2&gt;
&lt;p&gt;For the seat-allocation example above, the cleanest fix isn&amp;#39;t &amp;quot;switch to serializable&amp;quot; (which would force aborts on workloads that don&amp;#39;t actually conflict). It&amp;#39;s &lt;strong&gt;read-then-write CAS&lt;/strong&gt;: include the value you read in the predicate of the write, so a concurrent change breaks your assumption explicitly.&lt;/p&gt;
&lt;p&gt;For the seat example we keep a counter row per tenant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE tenant_seat_counters (
  tenant_id  uuid primary key,
  active_n   int  not null,
  version    int  not null default 0
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The transaction becomes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;
  -- 1. Read the counter and version inside the transaction snapshot.
  SELECT active_n, version FROM tenant_seat_counters
   WHERE tenant_id = $1;

  -- 2. Check the invariant in application code.
  -- If active_n + 1 &amp;gt; plan.max_seats, abort and return a friendly error.

  -- 3. CAS the counter: predicate includes the version you read.
  UPDATE tenant_seat_counters
     SET active_n = active_n + 1, version = version + 1
   WHERE tenant_id = $1 AND version = $2;
  -- If rowcount = 0, another transaction won. Abort.

  -- 4. Insert the seat row.
  INSERT INTO seats(tenant_id, user_id, active) VALUES ($1, $3, true);
COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now both T1 and T2 race on the &lt;em&gt;same row&lt;/em&gt; (the counter). Snapshot isolation detects the conflict on step 3 and aborts the loser with &lt;code&gt;40001&lt;/code&gt;. The application retries (or surfaces a &amp;quot;couldn&amp;#39;t grab a seat, please try again&amp;quot; message). The invariant holds.&lt;/p&gt;
&lt;p&gt;This pattern generalises: any time your invariant spans multiple rows, &lt;strong&gt;find or invent a single row whose version represents that invariant&lt;/strong&gt;, and gate the write on that version. You&amp;#39;ve turned a write-skew anomaly into a write-write conflict, which snapshot isolation handles natively.&lt;/p&gt;
&lt;h2&gt;When read-then-write CAS isn&amp;#39;t enough&lt;/h2&gt;
&lt;p&gt;A few real workloads escape this pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multi-tenant invariants&lt;/strong&gt; that span an unbounded set of rows (&amp;quot;no two appointments overlap for the same room&amp;quot;). A version row works only if all writers consent to touch it; if you have legacy writers, you need range locks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-table invariants&lt;/strong&gt; that traverse foreign keys with cascading effects. Here we suggest a per-aggregate version row owned by the aggregate root.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hot counters under sustained contention&lt;/strong&gt;. The CAS pattern becomes a thrash. Switch to a sharded counter (&lt;code&gt;N&lt;/code&gt; rows, randomly chosen on write, summed on read) and accept eventual consistency in the count.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For the rare cases where none of the above works, we expose &lt;code&gt;SET TRANSACTION ISOLATION LEVEL SERIALIZABLE&lt;/code&gt;. It costs more, aborts more, but kills write skew at the database layer. Use it surgically.&lt;/p&gt;
&lt;h2&gt;A quick checklist before you ship&lt;/h2&gt;
&lt;p&gt;If you&amp;#39;re about to deploy a feature whose correctness depends on a multi-row invariant under concurrent writers, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What&amp;#39;s the invariant in one sentence?&lt;/li&gt;
&lt;li&gt;Which rows do I &lt;em&gt;read&lt;/em&gt; to verify it, and which rows do I &lt;em&gt;write&lt;/em&gt; to enforce it?&lt;/li&gt;
&lt;li&gt;If the read set and write set don&amp;#39;t overlap, where does write skew hide?&lt;/li&gt;
&lt;li&gt;Is there a single row whose version represents the invariant — and if not, can I add one?&lt;/li&gt;
&lt;li&gt;If conflict-on-commit happens, what&amp;#39;s the user-visible message?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We&amp;#39;ve never regretted answering those before merging. We&amp;#39;ve regretted skipping them more than once.&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Snapshot isolation removes 90% of concurrency footguns and is the right default.&lt;/li&gt;
&lt;li&gt;The 10% it leaves behind is &lt;strong&gt;write skew&lt;/strong&gt;, and it&amp;#39;s a real bug, not a textbook one.&lt;/li&gt;
&lt;li&gt;Conflicts surface as &lt;code&gt;SQLSTATE 40001&lt;/code&gt;. We don&amp;#39;t auto-retry — that&amp;#39;s an application decision.&lt;/li&gt;
&lt;li&gt;For most write-skew, &lt;strong&gt;read-then-write CAS on a version row&lt;/strong&gt; restores correctness with no global locking.&lt;/li&gt;
&lt;li&gt;When CAS doesn&amp;#39;t fit, switch the offending transaction to serializable. Pay the cost where it&amp;#39;s earned.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Snapshot isolation isn&amp;#39;t a slogan. It&amp;#39;s a contract with a known asterisk. Read the asterisk before you sign.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/byok-kms-multitenant-secrets</id>
    <title>BYOK, KMS, and the boring parts of multi-tenant secrets</title>
    <link href="https://reddb.io/blog/byok-kms-multitenant-secrets"/>
    <updated>2026-05-01</updated>
    <published>2026-05-01</published>
    <author><name>RedDB team</name></author>
    <summary>AWS KMS, GCP KMS, and HSM integration for RedDB — plus the three failure modes nobody writes about until they happen at 03:00.</summary>
    <content type="html">&lt;p&gt;The &lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;vault&lt;/a&gt; post covered how rotation works when the master key lives inside RedDB&amp;#39;s own keystore. That&amp;#39;s fine for single-tenant deployments. The moment you sell to a regulated buyer — banks, healthcare, anyone with a CISO — they ask the same question: &lt;em&gt;can we keep our own key?&lt;/em&gt; The answer has to be yes, and the integration has to survive the failure modes that BYOK quietly introduces.&lt;/p&gt;
&lt;p&gt;This post is the boring half of the story. Configuration, failure modes, and the operational shape that keeps you from waking up to a stuck write path.&lt;/p&gt;
&lt;h2&gt;What BYOK actually means&lt;/h2&gt;
&lt;p&gt;&amp;quot;Bring your own key&amp;quot; gets used loosely. In practice there are three shapes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Customer-managed KMS key (CMK).&lt;/strong&gt; The customer creates a key in their cloud account; you call their KMS with an IAM role they grant you. AWS KMS, GCP KMS, Azure Key Vault — all the same shape. Most common.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;External key store / HSM-backed.&lt;/strong&gt; The CMK in KMS is itself wrapped by a key that lives in a customer-controlled HSM (CloudHSM, GCP EKM, Thales/Entrust on-prem). Every KMS operation hits the HSM. Higher latency, higher assurance, much higher operational tax.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hold-your-own-key (HYOK).&lt;/strong&gt; The customer&amp;#39;s HSM is the &lt;em&gt;only&lt;/em&gt; place the key exists; you never see it unwrapped. Architecturally this means the data either decrypts inside their network (proxy model) or doesn&amp;#39;t decrypt at all in your hot path (envelope-with-remote-unwrap, see below). HYOK is rare and almost always vendor-specific.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;RedDB supports (1) and (2) directly. (3) is a custom integration we&amp;#39;ve done twice; the failure modes are similar but worse.&lt;/p&gt;
&lt;p&gt;For the rest of this post, &amp;quot;KMS&amp;quot; means the customer&amp;#39;s chosen provider — AWS, GCP, Azure, or HSM-backed equivalent. They differ in API surface but not in the operational story.&lt;/p&gt;
&lt;h2&gt;The envelope, revisited&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;Recap from the vault post&lt;/a&gt;: each encrypted row carries an envelope &lt;code&gt;(g, iv, ciphertext)&lt;/code&gt; where &lt;code&gt;g&lt;/code&gt; selects a master key generation. In single-tenant mode, those master keys are wrapped by RedDB&amp;#39;s own keystore.&lt;/p&gt;
&lt;p&gt;In BYOK mode, the same generation byte selects a &lt;strong&gt;data encryption key (DEK)&lt;/strong&gt; that is wrapped by the customer&amp;#39;s KMS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                                customer&amp;#39;s KMS
                                    │
            ┌──── unwrap(EncryptedDEK_g) ────┐
            ▼                                │
       DEK_g (in memory, cached)             │
            ▼                                │
   AES-GCM(row.iv, plaintext) ─► ciphertext  │
                                             │
            stored on disk:                  │
       (g, iv, ciphertext, kms_arn)          │
                                             ▼
                                customer&amp;#39;s KMS audit log
                                (one entry per unwrap)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things matter and people forget:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The DEK is never written unwrapped.&lt;/strong&gt; Only the KMS-wrapped form (&lt;code&gt;EncryptedDEK_g&lt;/code&gt;) is on disk, in a small &lt;code&gt;vault_keyring&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unwrap is cached.&lt;/strong&gt; Calling KMS on every row read would put 5–50ms of KMS latency on the hot path. The DEK lives in process-local memory with a TTL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The KMS ARN/resource ID is recorded with the envelope.&lt;/strong&gt; This matters for multi-tenant setups where different tenants use different CMKs (see &amp;quot;Multi-tenant&amp;quot; below).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Configuration&lt;/h2&gt;
&lt;p&gt;A minimal BYOK config for AWS KMS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;vault:
  provider: aws-kms
  region: us-east-1
  cmk: arn:aws:kms:us-east-1:111122223333:key/abcd1234-...
  iam_role: arn:aws:iam::111122223333:role/RedDBVaultAccess
  dek_cache:
    ttl: 1h
    max_entries: 1024
  kms_timeout: 2s
  kms_retry:
    attempts: 3
    backoff: exponential
    max_total: 5s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GCP is similar with &lt;code&gt;kms_key: projects/.../locations/.../keyRings/.../cryptoKeys/...&lt;/code&gt; and a service account; Azure uses &lt;code&gt;vault_uri&lt;/code&gt; and a managed identity. The shape is intentionally provider-shaped, not least-common-denominator — providers diverge in attestation, audit, and rotation semantics, and a fake-uniform abstraction would lie about them.&lt;/p&gt;
&lt;p&gt;The four knobs that matter operationally:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Knob&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What happens if you get it wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dek_cache.ttl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Too long: revocation slow. Too short: KMS request rate goes up 100×.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kms_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Too short: transient KMS hiccups cause write failures.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kms_retry.max_total&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Total wall-clock budget for unwrap including retries.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dek_cache.max_entries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1024&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sized for active generations × tenants. Default fits ~10 tenants.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;dek_cache.ttl&lt;/code&gt; is the one that decides the security-vs-availability tradeoff. We default to one hour because most customers&amp;#39; &amp;quot;rotate immediately when an engineer leaves&amp;quot; runbook tolerates an hour of stale-key exposure. Some banks require five minutes. The blast radius of getting this wrong is one knob away — write it down in your runbook.&lt;/p&gt;
&lt;h2&gt;Multi-tenant: one CMK per tenant&lt;/h2&gt;
&lt;p&gt;The pattern most SaaS deployments want:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One CMK per customer, in &lt;em&gt;their&lt;/em&gt; AWS account.&lt;/li&gt;
&lt;li&gt;An IAM trust policy that lets RedDB assume a role into their account.&lt;/li&gt;
&lt;li&gt;A row-level &lt;code&gt;tenant_id&lt;/code&gt; column that selects which CMK was used to wrap the DEK for that row&amp;#39;s generation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The vault keyring table grows a tenant dimension:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE vault_keyring (
  tenant_id     uuid NOT NULL,
  generation    smallint NOT NULL,
  kms_arn       text NOT NULL,
  wrapped_dek   bytea NOT NULL,
  created_at    timestamptz NOT NULL,
  retired_at    timestamptz,
  PRIMARY KEY (tenant_id, generation)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reads look up &lt;code&gt;(tenant_id, generation)&lt;/code&gt;, unwrap once via the tenant&amp;#39;s KMS, cache the DEK keyed on the same pair. Writes always use the tenant&amp;#39;s &lt;code&gt;active-write&lt;/code&gt; generation.&lt;/p&gt;
&lt;p&gt;Two things this gets you for free:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cryptographic deletion per tenant.&lt;/strong&gt; Customer churns? Revoke their CMK in their KMS console. Every row of theirs becomes unreadable, instantly, with no scan over your storage. The bytes are still on your disks, but they&amp;#39;re effectively shredded. This is the &lt;a href=&quot;/blog/gdpr-erasure-replicated-embedded/&quot;&gt;GDPR erasure&lt;/a&gt; shape your privacy lawyer wants.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No cross-tenant blast radius.&lt;/strong&gt; A misconfigured IAM policy on Tenant A&amp;#39;s CMK doesn&amp;#39;t take down Tenant B&amp;#39;s reads.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Two things it costs you:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cache pressure scales with active tenants.&lt;/strong&gt; Size &lt;code&gt;dek_cache.max_entries&lt;/code&gt; accordingly. With 10,000 tenants and 8KB per DEK entry, that&amp;#39;s 80MB of cache — fine. With 1M tenants, you&amp;#39;re rebuilding the cache strategy. We have customers doing both; the 1M case uses a two-tier cache with an L2 backed by RedDB itself (yes, the database caches its own keys).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;KMS rate limits per account.&lt;/strong&gt; AWS KMS defaults to 10,000 RPS per region per account. If your tenants share an AWS account (rare) or your cache TTL is too low (common mistake), you&amp;#39;ll hit it. Get the limit raised before launch, not during.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;The three failure modes nobody writes about&lt;/h2&gt;
&lt;h3&gt;1. KMS outage during the write path&lt;/h3&gt;
&lt;p&gt;This is the one that surprises teams. You designed the read path to tolerate KMS hiccups via cache. The write path &lt;em&gt;cannot&lt;/em&gt; be served from cache — a new generation needs a fresh wrap, and even the steady-state write uses an &lt;code&gt;active-write&lt;/code&gt; DEK that may not be in the local cache yet on a new pod.&lt;/p&gt;
&lt;p&gt;When KMS goes down (regional outage, IAM misconfiguration, account-level throttle), writes start failing while reads keep working. Customers panic. Support escalates. Your dashboard looks fine because read traffic is healthy.&lt;/p&gt;
&lt;p&gt;Three defenses, in order of how much we&amp;#39;d insist on each:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pre-warm the cache on pod startup.&lt;/strong&gt; When a pod boots, eagerly unwrap the active-write DEK for every tenant it might serve. Reject readiness until this completes. Pods that can&amp;#39;t reach KMS at boot never enter rotation. This is the single highest-leverage change.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Allow a &lt;code&gt;write_unavailable&lt;/code&gt; mode that fails fast and explicitly.&lt;/strong&gt; Writers see &lt;code&gt;SQLSTATE 53300&lt;/code&gt; (&amp;quot;KMS unreachable&amp;quot;) instead of timing out. Applications can queue, drop to a dead-letter, or retry with backoff — but they know what&amp;#39;s happening, instead of staring at 30-second timeouts.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-region KMS replication.&lt;/strong&gt; AWS multi-region keys, GCP multi-region key rings. Adds cost; removes the regional-outage failure case. Required for any customer that&amp;#39;s run a regional failover drill.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The defense we &lt;em&gt;don&amp;#39;t&lt;/em&gt; recommend: a synthetic &amp;quot;emergency unwrapped DEK&amp;quot; fallback. It defeats the whole point of BYOK. Two customers asked for it; both withdrew the request after their security team read the runbook.&lt;/p&gt;
&lt;h3&gt;2. Key rotation in a multi-region setup&lt;/h3&gt;
&lt;p&gt;You read &lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;the rotation post&lt;/a&gt; and built a clean two-stage rotation. Now you have three regions, async replication between them, and BYOK. The rotation now has a subtle ordering bug.&lt;/p&gt;
&lt;p&gt;The flow that breaks:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;t=0   region A: promote generation 7 to active-write
t=1   region A: a row is written, encrypted under DEK_7
t=2   replication ships the row to region B
t=3   region B receives the row, tries to decrypt → fails

      because region B hasn&amp;#39;t yet seen the keyring update
      that says &amp;quot;generation 7 exists and here&amp;#39;s its wrapped DEK&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fix is order-of-operations: &lt;strong&gt;the keyring entry for a new generation must replicate before any row written under that generation can replicate&lt;/strong&gt;. RedDB handles this internally by treating keyring updates as a special replication record that flushes ahead of data records carrying that generation. If you&amp;#39;re integrating at a lower layer (e.g., custom replication), preserve this invariant or accept that cross-region reads will occasionally return decryption errors for the few seconds after a rotation.&lt;/p&gt;
&lt;p&gt;The corollary: &lt;strong&gt;rotate during a quiet window the first time you do it on a new multi-region deployment&lt;/strong&gt;, and watch the replication-lag-versus-keyring-lag metric. We expose both. They should never be reordered, but the time you find out they were is also the time you find out it matters.&lt;/p&gt;
&lt;h3&gt;3. The CMK is deleted (or scheduled for deletion)&lt;/h3&gt;
&lt;p&gt;Customer&amp;#39;s engineer fat-fingers a &lt;code&gt;aws kms schedule-key-deletion&lt;/code&gt; on the wrong CMK. The customer panics. They cancel the deletion within the 7-day window (AWS minimum). Crisis averted.&lt;/p&gt;
&lt;p&gt;Or: they didn&amp;#39;t notice for 8 days, and now the CMK is gone. Every row encrypted under DEKs wrapped by that CMK is unreadable. Forever. Including from your backups.&lt;/p&gt;
&lt;p&gt;This is the actual, named threat that BYOK introduces, and it&amp;#39;s the customer&amp;#39;s responsibility — but you carry the operational scar of being the one who has to tell them. Three things to put in your runbook:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Monitor for &lt;code&gt;kms:ScheduleKeyDeletion&lt;/code&gt; events on customer CMKs.&lt;/strong&gt; Most customers will grant you the CloudTrail read permission for this; the ones that won&amp;#39;t, you note in their onboarding doc.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Page on any read that returns &lt;code&gt;KMSAccessDenied&lt;/code&gt; or &lt;code&gt;KMSDisabled&lt;/code&gt;&lt;/strong&gt; above a threshold rate. This is &lt;em&gt;the&lt;/em&gt; signal of a CMK being yanked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document that backups encrypted under a yanked CMK are unrecoverable.&lt;/strong&gt; This sounds obvious. It is not obvious to everyone. Get sign-off in writing during onboarding.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;HSM-backed: same shape, more latency&lt;/h2&gt;
&lt;p&gt;CloudHSM, GCP EKM, on-prem HSMs (Thales, Entrust) plug in at the KMS layer — either &lt;em&gt;as&lt;/em&gt; the KMS provider (rare), or &lt;em&gt;behind&lt;/em&gt; a CMK that&amp;#39;s configured to call into the HSM for every operation (common). From RedDB&amp;#39;s side it looks like a slow KMS. Three operational deltas:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Latency is ~10–50ms per unwrap, vs ~5ms for cloud KMS.&lt;/strong&gt; Cache TTL matters more. We&amp;#39;ve seen customers set &lt;code&gt;dek_cache.ttl: 4h&lt;/code&gt; for HSM-backed setups; verify with their security team.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Throughput is capped by the HSM.&lt;/strong&gt; A single CloudHSM cluster does ~1,200 RSA ops/s. Plan capacity for &lt;em&gt;peak&lt;/em&gt; unwrap rate, which happens during a cache cold-start after a deploy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Failover is the customer&amp;#39;s problem.&lt;/strong&gt; HSM clusters can lose quorum. Their runbook, not yours — but document the read/write impact when they do.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What to actually measure&lt;/h2&gt;
&lt;p&gt;If you operate BYOK in production, these five metrics catch 95% of the failure modes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault_kms_unwrap_seconds              (histogram, p50/p99 per tenant)
vault_kms_unwrap_errors_total         (counter, by reason: timeout, throttle, denied)
vault_dek_cache_hit_ratio             (gauge, per tenant)
vault_active_generations_count        (gauge, per tenant — should be 1 except during rotation)
vault_keyring_replication_lag_seconds (gauge, cross-region)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Page on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;vault_kms_unwrap_errors_total{reason=&amp;quot;denied&amp;quot;} &amp;gt; 0&lt;/code&gt; — almost always a yanked CMK.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vault_dek_cache_hit_ratio &amp;lt; 0.95&lt;/code&gt; for 5 min — cache misconfigured or TTL too short.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vault_kms_unwrap_seconds{quantile=&amp;quot;0.99&amp;quot;} &amp;gt; 500ms&lt;/code&gt; for 5 min — KMS is sick or HSM is overloaded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cache-hit-ratio alarm is the one that&amp;#39;s caught the most weird configs in practice. It&amp;#39;s cheap to wire up. Wire it up first.&lt;/p&gt;
&lt;h2&gt;The shape that ages well&lt;/h2&gt;
&lt;p&gt;BYOK is mostly boring once configured. The traps are the ones that show up months later when a customer rotates an IAM policy, deletes a CMK, fails over to a new region, or sells to a sub-organisation that wants its own CMK. The shape of the system that survives all of these:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Per-tenant CMKs from day one, even if you only have one tenant.&lt;/li&gt;
&lt;li&gt;Cache TTL that matches the customer&amp;#39;s revocation expectation, documented.&lt;/li&gt;
&lt;li&gt;Pre-warming on pod startup, with readiness gated on it.&lt;/li&gt;
&lt;li&gt;Cross-region keyring replication that flushes ahead of data.&lt;/li&gt;
&lt;li&gt;Alerts on &lt;code&gt;KMSAccessDenied&lt;/code&gt; and on cache-hit-ratio drops.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are interesting in a sales call. All of them are the difference between &amp;quot;BYOK works&amp;quot; and &amp;quot;BYOK works &lt;em&gt;in production, for years, with no surprises&lt;/em&gt;.&amp;quot;&lt;/p&gt;
&lt;h2&gt;Related&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;Per-row encryption with zero-downtime key rotation&lt;/a&gt; — the rotation mechanics this post assumes.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/gdpr-erasure-replicated-embedded/&quot;&gt;GDPR right-to-erasure when data is replicated and embedded&lt;/a&gt; — the cryptographic-deletion shape, fleshed out.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/self-hosting-reddb-real-world-checklist/&quot;&gt;Self-hosting RedDB: the real-world checklist&lt;/a&gt; — where the KMS section in your runbook should live.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/mongodb-to-reddb-queries-that-change-shape</id>
    <title>MongoDB → RedDB: the queries that change shape</title>
    <link href="https://reddb.io/blog/mongodb-to-reddb-queries-that-change-shape"/>
    <updated>2026-04-30</updated>
    <published>2026-04-30</published>
    <author><name>RedDB team</name></author>
    <summary>Idiomatic translations from MongoDB to RedDB for the patterns that actually appear in production code — aggregation pipelines, change streams, GridFS, and the three places where the literal translation is wrong.</summary>
    <content type="html">&lt;p&gt;A MongoDB-to-RedDB migration is not the war story people expect. The replication is straightforward, the dual-write window is small, the cutover is boring. What is interesting is what happens to your queries — most of them get shorter, a few get longer, and three patterns change shape entirely. This post is a translation table for the ones that change shape.&lt;/p&gt;
&lt;p&gt;We are not going to argue MongoDB versus relational here. We are going to assume you have already decided to move and want to know what your aggregation pipeline looks like on the other side. Every translation below has been pulled from a real migration we have shipped — names changed, shapes preserved.&lt;/p&gt;
&lt;h2&gt;The mental model shift&lt;/h2&gt;
&lt;p&gt;Before any single query: in Mongo you push computation up into the pipeline, and the document is the unit of co-location. In RedDB you push computation down into the query planner, and the row (with its JSONB columns) is the unit of co-location. The good news is that RedDB&amp;#39;s JSONB support is rich enough that you do not have to flatten everything — keep the long tail of optional fields as JSONB and lift only the columns you query or index on.&lt;/p&gt;
&lt;p&gt;The rule of thumb we use: &lt;strong&gt;if you filter, sort, or join on a field in more than one place, promote it to a column&lt;/strong&gt;. Otherwise leave it in JSONB. Migrations that try to fully normalise every Mongo document on day one tend to stall around week four. Migrations that promote the hot fields and leave the long tail in JSONB tend to ship in a fortnight.&lt;/p&gt;
&lt;h2&gt;Translation 1: simple find with projection&lt;/h2&gt;
&lt;p&gt;The cheapest case. Mongo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;db.orders.find(
  { customer_id: &amp;quot;c_42&amp;quot;, status: &amp;quot;open&amp;quot; },
  { _id: 1, total: 1, created_at: 1 }
).sort({ created_at: -1 }).limit(20)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RedDB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, total, created_at
FROM orders
WHERE customer_id = &amp;#39;c_42&amp;#39; AND status = &amp;#39;open&amp;#39;
ORDER BY created_at DESC
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Index on &lt;code&gt;(customer_id, status, created_at DESC)&lt;/code&gt; and you are done. If &lt;code&gt;status&lt;/code&gt; is one of a small set, a partial index &lt;code&gt;WHERE status = &amp;#39;open&amp;#39;&lt;/code&gt; is often cheaper. Nothing exotic.&lt;/p&gt;
&lt;h2&gt;Translation 2: aggregation pipeline → SQL with JSONB&lt;/h2&gt;
&lt;p&gt;This is where most of the work lives. A typical revenue rollup in Mongo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;db.orders.aggregate([
  { $match: { created_at: { $gte: ISODate(&amp;quot;2026-01-01&amp;quot;) }, status: &amp;quot;paid&amp;quot; } },
  { $unwind: &amp;quot;$items&amp;quot; },
  { $group: {
      _id: { sku: &amp;quot;$items.sku&amp;quot;, month: { $dateToString: { format: &amp;quot;%Y-%m&amp;quot;, date: &amp;quot;$created_at&amp;quot; } } },
      revenue: { $sum: { $multiply: [&amp;quot;$items.qty&amp;quot;, &amp;quot;$items.unit_price&amp;quot;] } },
      orders:  { $addToSet: &amp;quot;$_id&amp;quot; }
  }},
  { $project: {
      sku: &amp;quot;$_id.sku&amp;quot;,
      month: &amp;quot;$_id.month&amp;quot;,
      revenue: 1,
      order_count: { $size: &amp;quot;$orders&amp;quot; },
      _id: 0
  }},
  { $sort: { month: 1, revenue: -1 } }
])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The RedDB version uses &lt;code&gt;jsonb_array_elements&lt;/code&gt; for the unwind and ordinary &lt;code&gt;GROUP BY&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  item-&amp;gt;&amp;gt;&amp;#39;sku&amp;#39;                                   AS sku,
  to_char(created_at, &amp;#39;YYYY-MM&amp;#39;)                 AS month,
  SUM((item-&amp;gt;&amp;gt;&amp;#39;qty&amp;#39;)::numeric * (item-&amp;gt;&amp;gt;&amp;#39;unit_price&amp;#39;)::numeric) AS revenue,
  COUNT(DISTINCT id)                             AS order_count
FROM orders, jsonb_array_elements(items) AS item
WHERE created_at &amp;gt;= &amp;#39;2026-01-01&amp;#39; AND status = &amp;#39;paid&amp;#39;
GROUP BY sku, month
ORDER BY month, revenue DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to notice:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;$unwind&lt;/code&gt; is &lt;code&gt;jsonb_array_elements&lt;/code&gt; in a &lt;code&gt;FROM&lt;/code&gt; clause.&lt;/strong&gt; It produces one row per array element, which is exactly what the SQL aggregation expects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;$addToSet&lt;/code&gt; + &lt;code&gt;$size&lt;/code&gt; is &lt;code&gt;COUNT(DISTINCT)&lt;/code&gt;&lt;/strong&gt; — shorter and faster, because the planner does not have to materialise the set.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;$dateToString&lt;/code&gt; is &lt;code&gt;to_char&lt;/code&gt;.&lt;/strong&gt; If you do this rollup often, store &lt;code&gt;to_char(created_at, &amp;#39;YYYY-MM&amp;#39;)&lt;/code&gt; as a generated column and index it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For pipelines deeper than three stages we usually rewrite as a CTE chain, one CTE per &lt;code&gt;$match&lt;/code&gt;/&lt;code&gt;$group&lt;/code&gt; stage. The pipeline shape survives — only the syntax changes.&lt;/p&gt;
&lt;h2&gt;Translation 3: &lt;code&gt;$lookup&lt;/code&gt; → &lt;code&gt;JOIN&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;$lookup&lt;/code&gt; is a join with extra steps. The literal translation almost always works and is faster than the Mongo version.&lt;/p&gt;
&lt;p&gt;Mongo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;db.orders.aggregate([
  { $match: { status: &amp;quot;open&amp;quot; } },
  { $lookup: {
      from: &amp;quot;customers&amp;quot;,
      localField: &amp;quot;customer_id&amp;quot;,
      foreignField: &amp;quot;_id&amp;quot;,
      as: &amp;quot;customer&amp;quot;
  }},
  { $unwind: &amp;quot;$customer&amp;quot; },
  { $project: { _id: 1, total: 1, &amp;quot;customer.email&amp;quot;: 1, &amp;quot;customer.tier&amp;quot;: 1 } }
])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RedDB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT o.id, o.total, c.email, c.tier
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = &amp;#39;open&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Mongo &lt;code&gt;$lookup&lt;/code&gt; requires an index on &lt;code&gt;customers._id&lt;/code&gt; (free) and traverses one document at a time. The SQL version is hash-joined or merge-joined in one pass. We have not yet seen a &lt;code&gt;$lookup&lt;/code&gt; whose SQL equivalent was slower.&lt;/p&gt;
&lt;h2&gt;Translation 4: change streams → logical replication or triggers&lt;/h2&gt;
&lt;p&gt;This is one of the three shape-changers.&lt;/p&gt;
&lt;p&gt;A Mongo change stream is a tailing cursor on the oplog:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const stream = db.orders.watch([{ $match: { &amp;quot;fullDocument.status&amp;quot;: &amp;quot;paid&amp;quot; } }])
for await (const change of stream) {
  await fulfillment.enqueue(change.fullDocument)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RedDB offers two replacements, and they are not interchangeable:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option A: logical replication&lt;/strong&gt; (use this if you have one or two well-defined consumers that need every change, in order, durably):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE PUBLICATION orders_pub FOR TABLE orders WHERE (status = &amp;#39;paid&amp;#39;);
-- consumer connects via the replication protocol
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Option B: a trigger that writes to an outbox table&lt;/strong&gt; (use this if you want application-level retry control or fan-out to a queue):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE outbox_orders (
  id          bigserial PRIMARY KEY,
  order_id    text NOT NULL,
  payload     jsonb NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  consumed_at timestamptz
);

CREATE FUNCTION orders_to_outbox() RETURNS trigger AS $$
BEGIN
  IF NEW.status = &amp;#39;paid&amp;#39; AND (OLD IS NULL OR OLD.status &amp;lt;&amp;gt; &amp;#39;paid&amp;#39;) THEN
    INSERT INTO outbox_orders (order_id, payload)
    VALUES (NEW.id, to_jsonb(NEW));
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER orders_outbox_t
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION orders_to_outbox();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your fulfillment worker pulls from &lt;code&gt;outbox_orders&lt;/code&gt; with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, processes, then sets &lt;code&gt;consumed_at&lt;/code&gt;. The outbox pattern gives you exactly-once-with-effort semantics that &lt;code&gt;watch()&lt;/code&gt; did not have — change streams resume on a token, but the resume token is yours to lose, and we have watched several teams discover this the hard way.&lt;/p&gt;
&lt;p&gt;The shape change to internalise: &lt;strong&gt;change streams are a transport; the outbox-plus-poller pattern is a storage layer with a transport on top.&lt;/strong&gt; The latter is more code and more correctness.&lt;/p&gt;
&lt;h2&gt;Translation 5: GridFS → blob columns (or object storage)&lt;/h2&gt;
&lt;p&gt;GridFS is a workaround for Mongo&amp;#39;s 16MB document limit. RedDB does not have that limit, but you still should not put a 50MB PDF in a row.&lt;/p&gt;
&lt;p&gt;The translation depends on file size:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mongo (GridFS)&lt;/th&gt;
&lt;th&gt;RedDB&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Files &amp;lt; 1 MiB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BYTEA&lt;/code&gt; column on the owning row&lt;/td&gt;
&lt;td&gt;Small avatars, signatures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files 1–8 MiB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BYTEA&lt;/code&gt; column with &lt;code&gt;STORAGE EXTERNAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Documents, medium attachments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files &amp;gt; 8 MiB&lt;/td&gt;
&lt;td&gt;S3-compatible object store, URL + sha256 row&lt;/td&gt;
&lt;td&gt;Videos, large datasets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A typical migration moves the GridFS chunks collection straight to object storage, writes the metadata row to RedDB with a &lt;code&gt;storage_url&lt;/code&gt; and &lt;code&gt;sha256&lt;/code&gt;, and leaves a one-shot job that lazily pulls each blob the first time it is requested. The big-file branch is the right default — GridFS performance gets pathological once you have a few hundred thousand large files in one collection, and most teams quietly already keep them in S3 anyway.&lt;/p&gt;
&lt;h2&gt;Translation 6: text search&lt;/h2&gt;
&lt;p&gt;If you used Mongo&amp;#39;s &lt;code&gt;$text&lt;/code&gt; operator, you have one of two situations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You needed light keyword search on a small set of fields. Use RedDB&amp;#39;s built-in &lt;code&gt;tsvector&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE products ADD COLUMN search_v tsvector
  GENERATED ALWAYS AS (to_tsvector(&amp;#39;english&amp;#39;, name || &amp;#39; &amp;#39; || coalesce(description, &amp;#39;&amp;#39;))) STORED;
CREATE INDEX products_search_v_idx ON products USING gin (search_v);

SELECT id, name FROM products
WHERE search_v @@ plainto_tsquery(&amp;#39;english&amp;#39;, &amp;#39;wireless headphones&amp;#39;);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You needed real relevance, faceting, or typo-tolerance. Mongo &lt;code&gt;$text&lt;/code&gt; was already not enough. Move to a real search index (Tantivy on RedDB, or an external Elastic/Meili) and pipe updates through the same outbox table from translation 4.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Translation 7: TTL indexes → scheduled DELETE&lt;/h2&gt;
&lt;p&gt;Mongo TTL indexes are a background job dressed as an index. RedDB ships the job explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX sessions_expires_at_idx ON sessions (expires_at)
  WHERE expires_at IS NOT NULL;

-- run from cron, every minute
DELETE FROM sessions WHERE expires_at &amp;lt; now() LIMIT 10000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;LIMIT&lt;/code&gt; matters. The Mongo TTL worker batches; your cron job should too, or a single sweep can lock half the table during peak.&lt;/p&gt;
&lt;h2&gt;What we left out and why&lt;/h2&gt;
&lt;p&gt;Two Mongo patterns do not get a translation here because they should not survive the migration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Schemaless writes from many services.&lt;/strong&gt; If five services write to the same collection with different shapes, the schemaless freedom was already costing you. The migration is the right moment to define the shape. Promote the shared fields to columns; let the disagreement live in JSONB only where it is genuinely unresolved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embedded arrays of unbounded growth.&lt;/strong&gt; A &lt;code&gt;comments: [...]&lt;/code&gt; field that grows for the lifetime of a post is a future operational problem in any database. The migration is the right moment to lift it into its own table.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The honest summary&lt;/h2&gt;
&lt;p&gt;Most query translations are mechanical and the result is faster, more inspectable code. The three shape-changers — change streams, GridFS, and unbounded embedded arrays — deserve real design time and should not be translated literally. Plan one to two days per shape-changer; the rest of the queries are an afternoon&amp;#39;s typing.&lt;/p&gt;
&lt;p&gt;If you are deep in a migration and stuck on a pipeline you cannot translate cleanly, send us the pipeline. We have probably seen its shape before.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/disaster-recovery-for-reddb</id>
    <title>Disaster recovery for RedDB: PITR, snapshots, regional failover</title>
    <link href="https://reddb.io/blog/disaster-recovery-for-reddb"/>
    <updated>2026-04-29</updated>
    <published>2026-04-29</published>
    <author><name>RedDB team</name></author>
    <summary>An operator playbook for RedDB disaster recovery — point-in-time restore with example commands, snapshot scheduling, cross-region replication topology, the RPO/RTO numbers you can actually hit, and an honest list of what we don&apos;t yet do.</summary>
    <content type="html">&lt;p&gt;This is the post we hand to operators after &lt;a href=&quot;/blog/self-hosting-reddb-real-world-checklist/&quot;&gt;the self-host checklist&lt;/a&gt;. The checklist gets you running. This one gets you back up when something goes wrong — and tells you, with numbers, how long that takes.&lt;/p&gt;
&lt;p&gt;DR posts tend to oversell. We won&amp;#39;t. RedDB gives you good RPO and decent RTO on a single region, and asynchronous cross-region replication that is honest about its lag. It does not give you synchronous multi-region writes. If you need RPO=0 across continents, RedDB is the wrong answer today, and the &lt;a href=&quot;/blog/when-you-dont-need-reddb/&quot;&gt;non-applicability post&lt;/a&gt; says so out loud.&lt;/p&gt;
&lt;h2&gt;The three failures you plan for&lt;/h2&gt;
&lt;p&gt;Most operator panic falls into one of three shapes, and your DR strategy has to cover each one differently:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Logical corruption&lt;/strong&gt; — a bad migration, an &lt;code&gt;UPDATE&lt;/code&gt; without a &lt;code&gt;WHERE&lt;/code&gt;, a poisoned write from a regression. You want to rewind to &lt;em&gt;just before&lt;/em&gt; the bad thing happened. &lt;strong&gt;PITR is the answer.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Volume loss&lt;/strong&gt; — the underlying disk is gone (rare on managed storage, possible on bare metal). The cluster is otherwise healthy elsewhere. &lt;strong&gt;Snapshot + WAL replay is the answer.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Region loss&lt;/strong&gt; — the whole AZ or region is gone or unreachable for hours. &lt;strong&gt;Cross-region replica promotion is the answer.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These three call for different artifacts (continuous WAL archive, periodic full snapshot, follower replica) and different RPO/RTO targets. Sizing all three correctly is most of the work.&lt;/p&gt;
&lt;h2&gt;Point-in-time restore (logical corruption)&lt;/h2&gt;
&lt;p&gt;Logical corruption is the most common DR scenario in practice. It is also the one where naive &amp;quot;we have backups&amp;quot; answers fail — a nightly snapshot does not help if the bad write happened at 14:30 and the next snapshot is at 02:00.&lt;/p&gt;
&lt;p&gt;RedDB&amp;#39;s PITR machinery is two pieces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Base snapshot&lt;/strong&gt; — a consistent full copy taken on a schedule (default: daily at 02:00 UTC, before the EU traffic ramp).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Continuous WAL archive&lt;/strong&gt; — every WAL segment is shipped to object storage as it seals, typically every 16 MiB or every 30 seconds (whichever comes first).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To restore to an arbitrary point in time, you fetch the most recent snapshot &lt;em&gt;before&lt;/em&gt; the target time, then replay the archived WAL forward up to the requested timestamp.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Restore to 2026-04-28 14:29:00 UTC (one minute before the bad UPDATE)
reddbctl restore \
  --target-time &amp;#39;2026-04-28T14:29:00Z&amp;#39; \
  --snapshot-bucket s3://reddb-backups-prod \
  --wal-bucket s3://reddb-wal-prod \
  --destination /var/lib/reddb-restore \
  --master-key-arn arn:aws:kms:us-east-1:1234:key/abcd-ef
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command fetches the snapshot taken at 02:00 the same day (~12 hours of WAL to replay), unwraps the KMS-encrypted at-rest key, applies the WAL segments in order, and stops at the timestamp you asked for.&lt;/p&gt;
&lt;p&gt;In practice you do &lt;strong&gt;not&lt;/strong&gt; restore in place over a live cluster. You restore to a parallel data directory, point a single read-only RedDB process at it, run your forensic queries to confirm the rewind landed on the right side of the bad write, &lt;em&gt;then&lt;/em&gt; decide whether to swap the live cluster or surgically extract rows back into it. The second option (surgical extraction) is almost always the right one — full-cluster rewind discards every legitimate write between the bad event and now.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RPO for PITR&lt;/strong&gt;: bounded by your WAL ship interval. Default is 30 seconds, meaning the worst case is 30 seconds of writes lost if the &lt;em&gt;region itself&lt;/em&gt; dies between WAL ships. For an in-region logical corruption restore (the common case), RPO is effectively zero — the WAL has already been shipped before you decide to restore.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RTO for PITR&lt;/strong&gt;: bounded by snapshot size and WAL replay rate. On a 200 GiB dataset with one day of WAL to replay, expect 20–40 minutes wall-clock. Most of that is the snapshot download, not the replay.&lt;/p&gt;
&lt;h2&gt;Snapshot scheduling&lt;/h2&gt;
&lt;p&gt;The default daily-at-02:00 snapshot is fine for most teams. The two cases where you change it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;You have a strict RTO&lt;/strong&gt; and 12+ hours of WAL replay would blow it. Snapshot twice a day, or every 6 hours. Cost scales linearly with snapshot frequency — full snapshots are expensive on large datasets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your write pattern is bursty&lt;/strong&gt; and a 24-hour replay would include the daily batch job that writes 100 GiB. Snapshot &lt;em&gt;after&lt;/em&gt; the batch job, not before.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The G-27 checklist post showed the &lt;code&gt;CronJob&lt;/code&gt; manifest. Here is the schedule decision:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# values.yaml — snapshot schedule decision points
snapshots:
  schedule: &amp;quot;0 2 * * *&amp;quot;              # daily 02:00 UTC
  retention_days: 35                  # &amp;gt; 30-day audit window + buffer
  encryption_key_arn: ${KMS_KEY_ARN}  # always present, never optional
  destination: s3://reddb-backups-prod
  # If your RTO is &amp;lt; 4 hours, uncomment and run twice daily:
  # schedule: &amp;quot;0 2,14 * * *&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two retention rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Snapshots&lt;/strong&gt;: keep ≥ &lt;code&gt;(your max RTO window) + 7 days&lt;/code&gt; of full snapshots. The +7 covers &amp;quot;we discovered the corruption two days late.&amp;quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WAL&lt;/strong&gt;: keep ≥ &lt;code&gt;35 days&lt;/code&gt; of WAL archive even though that&amp;#39;s longer than most snapshot retention. The reason: an auditor or legal hold sometimes asks for &amp;quot;data as it existed on date X,&amp;quot; and WAL replay is the only way to answer that without keeping every snapshot forever.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both buckets must be in a &lt;em&gt;different&lt;/em&gt; failure domain than the live cluster — different region or, at minimum, a different storage account with separate IAM. The most common DR mistake we see is backups stored in the same bucket as the cluster&amp;#39;s working data. When the cluster&amp;#39;s bucket dies, so do the backups.&lt;/p&gt;
&lt;h2&gt;Cross-region replication topology&lt;/h2&gt;
&lt;p&gt;RedDB ships an asynchronous replication mode where one or more &lt;em&gt;follower&lt;/em&gt; clusters in other regions stream the WAL from the leader and apply it. This gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A warm-standby cluster you can promote if the leader region dies.&lt;/li&gt;
&lt;li&gt;A read-only replica in another region for read-locality (a useful side benefit).&lt;/li&gt;
&lt;li&gt;A continuously-tested DR target — if replication is healthy, you know the recovery path works &lt;em&gt;right now&lt;/em&gt;, not &amp;quot;in theory next quarter.&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The topology we recommend for most teams is one leader and one async follower, both behind their own load balancer, with DNS pointed at the leader by default.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────┐         WAL stream         ┌─────────────────┐
│ Leader (us-east)│ ────────────────────────▶  │ Follower (eu-w) │
│   r/w traffic   │                            │   read-only     │
└────────┬────────┘                            └────────┬────────┘
         │                                              │
         ▼                                              ▼
   app traffic                                  read-only fan-out
       (default)                              (optional, lower-priority)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The replication lag metric (&lt;code&gt;replication_lag_bytes&lt;/code&gt; and &lt;code&gt;replication_lag_seconds&lt;/code&gt;) is one of the four metrics the G-27 checklist said to alert on. For most workloads, expect 0.5–3 seconds of lag, with bursts to ~30 seconds during compaction. If you see sustained lag &amp;gt; 60 seconds, page someone — the follower is no longer a viable failover target until it catches up.&lt;/p&gt;
&lt;h3&gt;Promoting a follower&lt;/h3&gt;
&lt;p&gt;When the leader region is gone (or sufficiently broken that you&amp;#39;ve made the call to fail over), promote the follower:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# On the follower cluster:
reddbctl promote \
  --confirm-leader-down \
  --new-leader-region eu-west-1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--confirm-leader-down&lt;/code&gt; flag is intentional friction. We have seen exactly one customer accidentally promote a follower while the leader was still serving writes; the resulting split-brain took six hours to clean up. The flag forces an operator to acknowledge they have already verified the leader is unreachable from the application tier &lt;em&gt;and&lt;/em&gt; from the follower itself.&lt;/p&gt;
&lt;p&gt;After promotion, your DNS or service-mesh routing has to swap. We do not automate the DNS swap. Most teams&amp;#39; DNS automation is bespoke and brittle, and an automatic regional failover that fires on a transient network blip is worse than the disaster it was supposed to mitigate. Manual swap, executed by a human who has read a runbook, is the right tradeoff for almost everyone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RPO for regional failover&lt;/strong&gt;: equal to replication lag at the moment of leader loss. If lag was 2 seconds, you lose 2 seconds of writes. If lag was 60 seconds, you lose 60 seconds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RTO for regional failover&lt;/strong&gt;: dominated by DNS propagation + connection-pool reset in the application tier. Cluster-side promotion is sub-30s. Realistic end-to-end RTO is 5–15 minutes depending on how aggressive your DNS TTLs are and whether your application has connection retries that survive a backend swap.&lt;/p&gt;
&lt;h2&gt;The numbers you can hit&lt;/h2&gt;
&lt;p&gt;For a midsize deployment (200 GiB dataset, 5K writes/sec, daily snapshot, 30s WAL ship interval, one async cross-region follower):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;RPO&lt;/th&gt;
&lt;th&gt;RTO&lt;/th&gt;
&lt;th&gt;Tooling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Logical corruption&lt;/td&gt;
&lt;td&gt;~0 (in-region)&lt;/td&gt;
&lt;td&gt;20–40 min&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reddbctl restore --target-time&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume loss&lt;/td&gt;
&lt;td&gt;&amp;lt; 30 sec&lt;/td&gt;
&lt;td&gt;15–25 min&lt;/td&gt;
&lt;td&gt;Snapshot + WAL replay onto new PV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Region loss&lt;/td&gt;
&lt;td&gt;2–30 sec&lt;/td&gt;
&lt;td&gt;5–15 min&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reddbctl promote&lt;/code&gt; + DNS swap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole-account loss&lt;/td&gt;
&lt;td&gt;1 day&lt;/td&gt;
&lt;td&gt;4+ hours&lt;/td&gt;
&lt;td&gt;Restore from cross-account vault&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;These are not aspirations. They are the numbers we measure on our own deployments. Your numbers will differ — bigger datasets push RTO up roughly linearly, faster networks pull it down, and applications with aggressive connection retries shave 1–2 minutes off the failover number.&lt;/p&gt;
&lt;p&gt;If your business needs better than these, the answer is &lt;strong&gt;not&lt;/strong&gt; to tune RedDB harder. The honest answer is below.&lt;/p&gt;
&lt;h2&gt;What we don&amp;#39;t yet do&lt;/h2&gt;
&lt;p&gt;DR sales decks lie. Here is what RedDB explicitly does not provide today, and what you should pick instead if you need it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Synchronous multi-region writes&lt;/strong&gt; — no. Writes commit on the leader before being replicated. If you need RPO=0 across regions, look at Spanner, CockroachDB, or YugabyteDB. They pay a meaningful latency cost on the write path; that cost is unavoidable for the property you&amp;#39;re buying.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic regional failover&lt;/strong&gt; — no. The promote command is manual on purpose. See above.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-region read-after-write consistency&lt;/strong&gt; — no. The follower is eventually consistent. If your application reads through the EU follower and expects writes from the US leader to be visible immediately, you will see stale reads. Pin reads to the leader for that workload, or accept the lag.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-cluster geo-distribution of a single dataset&lt;/strong&gt; — no. Each cluster is regional. The follower is a &lt;em&gt;replica&lt;/em&gt;, not a shard. If you need data residency by tenant (some EU customers, some US), run separate clusters per region and route by tenant ID at the application layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backup verification automation&lt;/strong&gt; — partial. We ship a &lt;code&gt;reddbctl restore --dry-run&lt;/code&gt; that verifies snapshot and WAL integrity without writing to disk. We do &lt;em&gt;not&lt;/em&gt; automatically restore your nightly snapshot into a scratch cluster every day. That drill remains a human responsibility, and it is item #8 on the self-host checklist for a reason.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This list will shrink. The order matters: synchronous multi-region writes are not on the roadmap (the latency cost is not what most of our users want); cross-region read-after-write &lt;em&gt;is&lt;/em&gt; on the roadmap (via causal tokens) and is the closest item to landing.&lt;/p&gt;
&lt;h2&gt;The drill you owe yourself&lt;/h2&gt;
&lt;p&gt;Pick a Tuesday. Set a timer. Pretend the leader region is gone.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Page yourself the way the real alert would page you.&lt;/li&gt;
&lt;li&gt;Read your own runbook end-to-end. (You have a runbook, right?)&lt;/li&gt;
&lt;li&gt;Promote the follower.&lt;/li&gt;
&lt;li&gt;Swap DNS.&lt;/li&gt;
&lt;li&gt;Verify the application is serving from the new region.&lt;/li&gt;
&lt;li&gt;Read at least one row that was written less than 60 seconds before &amp;quot;the leader died.&amp;quot; Verify it&amp;#39;s there. Note what was lost if anything.&lt;/li&gt;
&lt;li&gt;Restore the leader region. Decide whether to fail back or stay on the new leader.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first time a team runs this drill, it takes 90 minutes longer than they estimated, they discover three runbook errors, and the application&amp;#39;s connection-pool retry logic is wrong in some non-obvious way. That&amp;#39;s the entire point of running it before the real outage. Schedule the next one for 90 days out.&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Three failure shapes (logical corruption, volume loss, region loss) need three different DR artifacts. Cover all three or you don&amp;#39;t have DR; you have hope.&lt;/li&gt;
&lt;li&gt;PITR via base snapshot + continuous WAL archive: RPO ~zero for in-region restores, RTO 20–40 minutes on a midsize dataset.&lt;/li&gt;
&lt;li&gt;Async cross-region follower: RPO 2–30 seconds, RTO 5–15 minutes including DNS swap.&lt;/li&gt;
&lt;li&gt;Promotion is manual on purpose. Automatic regional failover causes more outages than it prevents.&lt;/li&gt;
&lt;li&gt;No synchronous multi-region writes. If you need RPO=0 across continents, pick a different database.&lt;/li&gt;
&lt;li&gt;Run the drill quarterly. The first drill always finds three real bugs.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/out-of-firebase-into-a-real-backend</id>
    <title>Out of Firebase, into a real backend</title>
    <link href="https://reddb.io/blog/out-of-firebase-into-a-real-backend"/>
    <updated>2026-04-28</updated>
    <published>2026-04-28</published>
    <author><name>RedDB team</name></author>
    <summary>A migration playbook for teams that outgrew Firebase — auth bridge, Firestore doc model, security rules, real-time — and an honest look at what you lose when the magic console goes away.</summary>
    <content type="html">&lt;p&gt;Firebase is a fantastic product for the first six months of a startup and an expensive constraint somewhere around month eighteen. This post is for the teams who are past month eighteen.&lt;/p&gt;
&lt;p&gt;We have done this migration four times for customers in the last year. Same shape every time: founders love Firebase, the senior engineer who joined to &amp;quot;harden the backend&amp;quot; hates it, and somewhere in between is a real product with paying users that cannot afford a multi-week outage. The walkthrough below is the playbook we hand to the senior engineer on day one. It is not &amp;quot;Firebase bad, please switch&amp;quot; — it is &amp;quot;here is the order of operations, here is what is genuinely worse on the other side, and here is how you keep the lights on while you cut over.&amp;quot;&lt;/p&gt;
&lt;h2&gt;Why teams leave (be honest about this first)&lt;/h2&gt;
&lt;p&gt;Before any migration, name the pain. We see four recurring drivers, roughly in order of severity:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cost cliff at scale.&lt;/strong&gt; Firestore reads at $0.06 per 100k start to dominate the bill once you have a real timeline or feed product. The pricing model penalises the exact access pattern (fan-out reads) that makes Firebase pleasant to build on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query expressiveness ceiling.&lt;/strong&gt; Firestore has no joins, no aggregations beyond &lt;code&gt;count&lt;/code&gt;, and composite indexes you have to predeclare. The third time someone says &amp;quot;we&amp;#39;ll just denormalise it&amp;quot; is the moment you have built a relational schema in a non-relational database without any of the relational tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vendor lock-in starts to hurt.&lt;/strong&gt; Security rules, Functions, and the Firebase SDK leak deeply into the app. Migrating later is a bigger project than migrating now. Senior engineers who have seen this movie before push for the cut earlier rather than later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability gap.&lt;/strong&gt; Firebase Console is wonderful for the first 100 documents and useless at 100 million. You cannot &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; a Firestore query. You cannot trace why a single document read is slow.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If your pain is something else — say, you simply do not like Google as a vendor — be careful. Migrations are expensive. Spend the migration budget where the pain actually is.&lt;/p&gt;
&lt;h2&gt;The four bridges you are about to build&lt;/h2&gt;
&lt;p&gt;A Firebase-to-RedDB migration is four parallel bridges that you cut over independently:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bridge&lt;/th&gt;
&lt;th&gt;Firebase side&lt;/th&gt;
&lt;th&gt;RedDB side&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Firebase Auth tokens&lt;/td&gt;
&lt;td&gt;RedDB sessions (or your own JWT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documents&lt;/td&gt;
&lt;td&gt;Firestore collections&lt;/td&gt;
&lt;td&gt;RedDB tables with JSON columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;Security rules&lt;/td&gt;
&lt;td&gt;Row-level policies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;onSnapshot&lt;/code&gt; listeners&lt;/td&gt;
&lt;td&gt;Change streams over websocket&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Build all four. Cut over auth first (it is the foundation), then run the document layer in dual-write, then move security to the server, then finally swap real-time. The order matters because each bridge depends on the previous one being stable.&lt;/p&gt;
&lt;h2&gt;Bridge 1: auth&lt;/h2&gt;
&lt;p&gt;Firebase Auth gives you a signed JWT per user with claims like &lt;code&gt;sub&lt;/code&gt; (Firebase UID) and &lt;code&gt;email&lt;/code&gt;. RedDB does not care where the JWT came from as long as you can verify it. The migration is therefore a verification gateway: trust Firebase tokens during the cutover, and issue your own RedDB session in exchange.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// During cutover, your /auth/bridge endpoint accepts a Firebase ID token,
// verifies it against Google&amp;#39;s public keys, and returns a RedDB session.

import { verifyIdToken } from &amp;#39;firebase-admin/auth&amp;#39;

export async function bridgeFirebaseToken(idToken: string) {
  const decoded = await verifyIdToken(idToken)
  // decoded.uid is the Firebase UID — keep it on the user row for lookup.

  let user = await reddb.query(
    &amp;#39;select id from users where firebase_uid = $1&amp;#39;,
    [decoded.uid],
  ).first()

  if (!user) {
    user = await reddb.query(
      `insert into users (firebase_uid, email, created_at)
       values ($1, $2, now())
       returning id`,
      [decoded.uid, decoded.email],
    ).first()
  }

  return issueSession(user.id) // your own opaque session token
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things to notice. First, &lt;code&gt;firebase_uid&lt;/code&gt; becomes a permanent column on &lt;code&gt;users&lt;/code&gt; — keep it forever; it is your join key for any future Firebase data you discover. Second, the endpoint creates the RedDB user row lazily on first sign-in. This means the cutover does not require a &amp;quot;migrate every user up front&amp;quot; batch job — users arrive as they sign in.&lt;/p&gt;
&lt;p&gt;After three to four weeks, every active user has a RedDB session and you can issue your own tokens directly. Firebase Auth becomes a fallback for the long tail.&lt;/p&gt;
&lt;h2&gt;Bridge 2: documents&lt;/h2&gt;
&lt;p&gt;This is the largest bridge. Firestore&amp;#39;s collection-document model maps cleanly to a table with one JSON column, but the lazy translation is a trap. Naive port:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create table firestore_documents (
  collection text not null,
  doc_id     text not null,
  data       jsonb not null,
  updated_at timestamptz not null default now(),
  primary key (collection, doc_id)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works for week one and then you realise every query is a full table scan or a hand-built JSONB GIN index per access pattern. Do not do this.&lt;/p&gt;
&lt;p&gt;Instead, model each Firestore collection as a real RedDB table. Pull the fields you actually query into typed columns and keep an &lt;code&gt;extra&lt;/code&gt; JSONB column for the long tail. Example: a Firestore &lt;code&gt;users/{uid}&lt;/code&gt; document with &lt;code&gt;{ email, displayName, plan, preferences: {...} }&lt;/code&gt; becomes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create table users (
  id           uuid primary key default gen_random_uuid(),
  firebase_uid text unique,
  email        text not null,
  display_name text,
  plan         text not null default &amp;#39;free&amp;#39;,
  preferences  jsonb not null default &amp;#39;{}&amp;#39;::jsonb,
  created_at   timestamptz not null default now(),
  updated_at   timestamptz not null default now()
);

create index users_plan_idx on users (plan);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;plan&lt;/code&gt; column gets the index because you filter on it. &lt;code&gt;preferences&lt;/code&gt; stays in JSONB because no one queries inside it — it is read whole and written whole.&lt;/p&gt;
&lt;h3&gt;Dual-write during cutover&lt;/h3&gt;
&lt;p&gt;You cannot atomically migrate a live document store. Instead, run a window of dual-write: every write goes to both Firestore and RedDB, every read is configurable per surface. The pattern that survives production looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;async function updateUserPlan(uid: string, plan: string) {
  // 1. Write to RedDB first (it is the new source of truth).
  await reddb.query(
    `update users set plan = $1, updated_at = now()
     where firebase_uid = $2`,
    [plan, uid],
  )

  // 2. Best-effort mirror to Firestore. If it fails, log — do not roll back.
  try {
    await firestore.collection(&amp;#39;users&amp;#39;).doc(uid).update({ plan })
  } catch (err) {
    logger.warn({ err, uid }, &amp;#39;firestore mirror failed — already on reddb&amp;#39;)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RedDB is the new source of truth from day one of cutover. Firestore is a write-behind cache for any old client code you have not yet migrated. The asymmetry is deliberate: you can read stale data from Firestore for a week, but you cannot lose data that is now only on RedDB.&lt;/p&gt;
&lt;h3&gt;The backfill job&lt;/h3&gt;
&lt;p&gt;For documents that existed before the cutover, a one-shot backfill copies them across. Use SKIP LOCKED so multiple workers can run in parallel without stepping on each other:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;with batch as (
  select id from firebase_users_to_migrate
  where migrated_at is null
  order by id
  limit 500
  for update skip locked
)
update firebase_users_to_migrate m
set migrated_at = now()
from batch
where m.id = batch.id
returning m.id, m.firestore_doc;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The worker reads &lt;code&gt;firestore_doc&lt;/code&gt; (a JSON snapshot loaded from Firestore), translates it to typed columns, inserts into &lt;code&gt;users&lt;/code&gt;, and marks the row migrated. Idempotent on &lt;code&gt;firebase_uid&lt;/code&gt; uniqueness — safe to re-run.&lt;/p&gt;
&lt;h2&gt;Bridge 3: authorization (security rules → row-level policies)&lt;/h2&gt;
&lt;p&gt;Firestore security rules are a thicket of &lt;code&gt;match /users/{uid} { allow read: if request.auth.uid == uid }&lt;/code&gt; declarations that mix authentication, authorization, and validation into a single mini-DSL. The good news: every rule you actually use translates to one of three RedDB patterns.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pattern A: owner-only access.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create policy users_self on users
  using (id = current_setting(&amp;#39;app.user_id&amp;#39;)::uuid);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application sets &lt;code&gt;app.user_id&lt;/code&gt; at the start of each request based on the verified session. Every query against &lt;code&gt;users&lt;/code&gt; is implicitly filtered.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pattern B: membership-based access.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create policy team_member on documents
  using (
    exists (
      select 1 from team_members
      where team_members.team_id = documents.team_id
        and team_members.user_id = current_setting(&amp;#39;app.user_id&amp;#39;)::uuid
    )
  );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pattern C: role-based access.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create policy admin_all on audit_log
  for select
  using (current_setting(&amp;#39;app.user_role&amp;#39;) = &amp;#39;admin&amp;#39;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most Firestore rules are some combination of these three. The translation is mechanical; the win is that policies live in the database and apply to every query path — including the ad-hoc psql session you open at 2am, which Firestore could never protect.&lt;/p&gt;
&lt;h2&gt;Bridge 4: real-time&lt;/h2&gt;
&lt;p&gt;This is the bridge where you lose the most magic, and we will not pretend otherwise.&lt;/p&gt;
&lt;p&gt;Firebase &lt;code&gt;onSnapshot&lt;/code&gt; is genuinely brilliant. You attach a listener to a query and your UI updates whenever any document in the result set changes — with no server you have to write, no websocket you have to manage, no reconnection logic. It is the killer feature.&lt;/p&gt;
&lt;p&gt;The RedDB equivalent is a change-stream subscription over websocket. You write the websocket server (or use ours) and clients subscribe to a query. The UX is the same; the implementation is now your problem.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Server: stream changes for a user&amp;#39;s documents.
reddb.subscribe(&amp;#39;user_documents&amp;#39;, {
  where: &amp;#39;user_id = $1&amp;#39;,
  params: [userId],
}).on(&amp;#39;change&amp;#39;, (event) =&amp;gt; {
  ws.send(JSON.stringify(event))
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What gets worse: you now own connection management, reconnection, backpressure, and the &amp;quot;what happens when the client missed 30 seconds of updates&amp;quot; question. Firebase handled all of this transparently. Plan for two engineer-weeks on this bridge alone if you do not already have a websocket layer.&lt;/p&gt;
&lt;p&gt;What gets better: the change stream is a real database stream. You can filter, transform, batch, and join it with other tables. You can replay from any LSN. You can hook into it for analytics, audit, or webhooks without paying Firestore for a second read of every document.&lt;/p&gt;
&lt;h2&gt;The things that are genuinely worse&lt;/h2&gt;
&lt;p&gt;Honest reckoning. Here is what you give up:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Firebase Console.&lt;/strong&gt; There is no equivalent admin UI on day one. You will write queries in psql or build a small internal admin tool. Plan a sprint for this; do not pretend the existing Postgres GUIs are a substitute for non-technical staff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offline-first mobile SDK.&lt;/strong&gt; Firestore caches and syncs offline. RedDB does not. If you have an offline-heavy mobile app, you are building a sync layer yourself — this might be a reason to delay the migration until you have the staffing for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Magic scaling for write-heavy fan-out.&lt;/strong&gt; Firestore happily eats a million-write fan-out and bills you for it. RedDB will too, but you will see the load on your own servers and have to provision for it. The bill is more visible (which is the point) but so is the operational responsibility.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drop-in mobile auth.&lt;/strong&gt; Firebase Auth&amp;#39;s SDK handles password reset, social login, MFA, and email verification out of the box. You can replicate all of this on RedDB — but it is now code you maintain, not a checkbox in the console.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you are honest about the four above and your team still wants to migrate, you are ready.&lt;/p&gt;
&lt;h2&gt;A four-week cutover plan&lt;/h2&gt;
&lt;p&gt;Week 1: Stand up RedDB, build the auth bridge, run dual auth (Firebase Auth verifies, RedDB session issued). Zero user-visible changes.&lt;/p&gt;
&lt;p&gt;Week 2: Build the document tables for your top-three highest-traffic collections. Start dual-write. Backfill historical data. Reads still come from Firestore.&lt;/p&gt;
&lt;p&gt;Week 3: Flip reads to RedDB for those three collections. Monitor for one week. Migrate the remaining collections in priority order.&lt;/p&gt;
&lt;p&gt;Week 4: Translate security rules to row-level policies. Cut over real-time listeners. Begin decommissioning Firestore reads. Keep Firestore as a write-behind mirror for two more weeks as insurance.&lt;/p&gt;
&lt;p&gt;Past four weeks: turn off the Firestore mirror, cancel the contract, write the postmortem.&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;Migration is four bridges (auth, documents, authorization, real-time) cut over in order. Dual-write keeps you safe through the cutover; RedDB is the source of truth from day one. You lose Firebase Console, offline-first sync, and SDK magic — be honest about which of those actually matter for your product before you start. Plan four weeks, expect six, and budget one full engineer for the websocket layer if real-time matters to you.&lt;/p&gt;
&lt;p&gt;The companion piece in this series (&lt;a href=&quot;/blog/when-not-to-migrate-checklist&quot;&gt;When NOT to migrate: a checklist&lt;/a&gt;) is just as important. If your pain is not on the list at the top of this post, the right answer might be to stay on Firebase for another year. Migrations are expensive — spend the budget where the pain actually is.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/drift-window-stale-rag-chunks</id>
    <title>The drift window: why your RAG retrieves stale chunks</title>
    <link href="https://reddb.io/blog/drift-window-stale-rag-chunks"/>
    <updated>2026-04-27</updated>
    <published>2026-04-27</published>
    <author><name>RedDB team</name></author>
    <summary>A customer-visible failure mode unique to two-store RAG — source updates, queue lag, retrievals against the old embedding. Anatomy of one outage and how same-transaction writes eliminate the window.</summary>
    <content type="html">&lt;p&gt;A support ticket landed on a Tuesday. A customer&amp;#39;s internal assistant was telling employees the wrong on-call rotation. The runbook in the source-of-truth wiki was correct. The bot&amp;#39;s answer was confidently, specifically wrong — naming an engineer who had rotated off two weeks earlier.&lt;/p&gt;
&lt;p&gt;The wiki page had been edited eight times since that engineer rotated off. The retrieval still pulled the chunk from before the first edit.&lt;/p&gt;
&lt;p&gt;This is the canonical shape of the &lt;strong&gt;drift window&lt;/strong&gt; bug. The anchor post for this pillar (&lt;a href=&quot;/blog/rag-without-second-database/&quot;&gt;RAG without a second database&lt;/a&gt;) defines the window. This post is about how it actually breaks production, why your monitoring probably won&amp;#39;t catch it, and what specifically you have to change in the write path to make it impossible.&lt;/p&gt;
&lt;h2&gt;What the customer saw&lt;/h2&gt;
&lt;p&gt;The on-call rotation page in the wiki had this history:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;T-21d  Page created. On-call: Anita.
T-14d  Anita rotates off. Page edited: On-call: Brian.
T-12d  Brian&amp;#39;s title gets fixed. Page edited.
T-09d  Holiday coverage added. Page edited.
T-02d  Phone numbers updated. Page edited.
T-00   Employee asks the bot: &amp;quot;who&amp;#39;s on call this week?&amp;quot;
T-00   Bot answers: &amp;quot;Anita is on call.&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every edit since T-14d enqueued a re-embed job. Five jobs. The queue worker had been wedged for ten days behind a poison-pill row that nobody had alerted on. The vector store still held the embedding of the T-21d revision. The retrieval was &lt;em&gt;precisely&lt;/em&gt; correct — the chunk it returned was the best match for the query, given what was in the index. The chunk was just twenty-one days out of date.&lt;/p&gt;
&lt;p&gt;The bot did not hallucinate. The pipeline returned stale ground truth.&lt;/p&gt;
&lt;h2&gt;Why monitoring missed it&lt;/h2&gt;
&lt;p&gt;The team had monitoring. It looked roughly like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Queue depth alert: pages at &amp;gt;10k pending jobs.&lt;/li&gt;
&lt;li&gt;Worker error rate: pages at &amp;gt;1% of jobs erroring.&lt;/li&gt;
&lt;li&gt;Embedding API latency: pages on P99 &amp;gt;3s.&lt;/li&gt;
&lt;li&gt;Vector store write latency: pages on P99 &amp;gt;500ms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these fired. The queue depth was steady — five jobs sat behind the poison pill, and the rest of the system kept up. The worker wasn&amp;#39;t erroring; it was waiting. The embedding API was fine. The vector store was fine.&lt;/p&gt;
&lt;p&gt;What was missing is the one metric that actually corresponds to the bug: &lt;strong&gt;the time between a source row&amp;#39;s &lt;code&gt;updated_at&lt;/code&gt; and the time its embedding row was last written&lt;/strong&gt;. Call it &lt;code&gt;drift_age_seconds&lt;/code&gt;. Without that metric, a queue can be &amp;quot;healthy&amp;quot; by every other measure while individual rows are silently weeks stale.&lt;/p&gt;
&lt;p&gt;Most monitoring stacks don&amp;#39;t compute this because computing it requires joining the source store to the vector store, which usually live in different databases. So the metric gets postponed. Then the bug ships.&lt;/p&gt;
&lt;h2&gt;Why retries don&amp;#39;t save you&lt;/h2&gt;
&lt;p&gt;The reflexive fix to a drift-window incident is to make the pipeline more robust:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Better retry logic on the worker.&lt;/li&gt;
&lt;li&gt;Idempotency keys on embedding requests.&lt;/li&gt;
&lt;li&gt;A separate &amp;quot;stale-row scanner&amp;quot; that periodically diffs source and vector store and re-enqueues.&lt;/li&gt;
&lt;li&gt;A health-check job that picks a random row and verifies the embedding is current.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These help. They do not close the window. They shorten it, on average, while leaving the tail unchanged. A poison pill, a deploy gone sideways, a partial outage of the embedding provider — any of these and the window reopens for whichever rows get stuck behind the failure. The customer that hits one of those rows during the window sees the bug.&lt;/p&gt;
&lt;p&gt;The window exists because the source-of-truth write and the embedding write are &lt;strong&gt;two writes to two systems&lt;/strong&gt;. Any architecture that keeps that property has a drift window. Architectures that collapse it into one write do not.&lt;/p&gt;
&lt;h2&gt;The fix: one transaction, both columns&lt;/h2&gt;
&lt;p&gt;The anchor post walks through the schema. The short version: the embedding column lives on the same row as the source text. The write that updates the text is the write that updates the embedding. No queue, no worker, no separate store.&lt;/p&gt;
&lt;p&gt;The wiki-page table looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE wiki_pages (
  id            UUID PRIMARY KEY,
  slug          TEXT UNIQUE NOT NULL,
  body          TEXT NOT NULL,
  body_tsv      tsvector GENERATED ALWAYS AS (to_tsvector(&amp;#39;english&amp;#39;, body)) STORED,
  embedding     vector(1536),
  embed_model   TEXT,
  embedded_at   TIMESTAMPTZ,
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON wiki_pages USING hnsw (embedding vector_cosine_ops);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The edit endpoint runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

  UPDATE wiki_pages
     SET body = $1,
         updated_at = now()
   WHERE slug = $2
  RETURNING id, body;

  -- application embeds $body inside the same request
  UPDATE wiki_pages
     SET embedding   = $3,
         embed_model = $4,
         embedded_at = now()
   WHERE id = $5
     AND updated_at = $6;  -- CAS guard: did anyone else win the race?

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The embedding call happens between the two &lt;code&gt;UPDATE&lt;/code&gt; statements, inside the same transaction. The transaction commits only if both writes commit. The retrieval index sees &lt;code&gt;body&lt;/code&gt; and &lt;code&gt;embedding&lt;/code&gt; change atomically.&lt;/p&gt;
&lt;p&gt;There is no queue. There is no worker. There is no second store. The drift window for this row is zero by construction.&lt;/p&gt;
&lt;h2&gt;What you give up&lt;/h2&gt;
&lt;p&gt;This is a tradeoff, not a free lunch. Three things change:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write latency includes the embedding call.&lt;/strong&gt; A wiki edit no longer returns in 30ms. It returns in 200-800ms, dominated by the embedding model. For an editor UI this is fine — the human just clicked save. For a hot write path (logging, telemetry, high-frequency events) it is unacceptable, and you do want a queue. The drift window is the price you pay for write-path simplicity; for high-frequency writes the price is wrong.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A failed embedding call fails the write.&lt;/strong&gt; If the embedding provider is down, you cannot save the document. This is usually the wrong behavior. The pattern around it is to make the embedding nullable, let the write commit with &lt;code&gt;embedding IS NULL&lt;/code&gt;, and have a backfill worker pick up the null rows. The retrieval query filters out null-embedding rows or falls back to text search for them. The window for those specific rows is non-zero, but it is bounded to &amp;quot;this row was just created&amp;quot; rather than &amp;quot;any row that&amp;#39;s ever been edited.&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Re-embedding the whole corpus is no longer a separate pipeline.&lt;/strong&gt; Model upgrades require a migration, not just a worker config change. The anchor post covers the &lt;code&gt;SKIP LOCKED&lt;/code&gt; pattern for this. It&amp;#39;s straightforward but it is a thing you now have to write.&lt;/p&gt;
&lt;h2&gt;How to know if you have this bug right now&lt;/h2&gt;
&lt;p&gt;Three checks you can run today against your existing two-store RAG pipeline:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pick ten source rows that were edited in the last month.&lt;/strong&gt; For each, fetch the source &lt;code&gt;updated_at&lt;/code&gt; and the vector-store&amp;#39;s last-write timestamp for that row. Compute the gap. If any gap is more than a few minutes, you have a drift window in production right now.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Look at your worker&amp;#39;s oldest pending job age.&lt;/strong&gt; Not the queue depth — the age of the oldest job. If it&amp;#39;s older than your error budget allows, your monitoring is alerting on the wrong dimension.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Search your incident history for the phrase &amp;quot;stale&amp;quot; or &amp;quot;outdated&amp;quot; in customer-reported bugs.&lt;/strong&gt; Drift-window bugs almost always get reported as &amp;quot;the assistant gave me old information.&amp;quot; They rarely get attributed to the pipeline because the pipeline looks healthy.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If any of these turn up something, the drift window is your bug. The fix is in the schema, not the queue.&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The drift window is the gap between a source-row update and its embedding update.&lt;/li&gt;
&lt;li&gt;Standard pipeline monitoring (queue depth, error rate, latency) does not detect it. The metric you need is per-row &lt;code&gt;drift_age_seconds&lt;/code&gt;, which most stacks don&amp;#39;t compute.&lt;/li&gt;
&lt;li&gt;Retries and stale-row scanners shorten the window but cannot close it; the window exists because the source write and the embedding write are two writes to two systems.&lt;/li&gt;
&lt;li&gt;Putting the embedding on the source row and updating both in one transaction collapses the window to zero, at the cost of write latency and an embedding-provider dependency on the write path.&lt;/li&gt;
&lt;li&gt;For editor-driven content, the tradeoff is almost always worth it. For high-frequency writes, keep the queue and accept the bounded window.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Related: &lt;a href=&quot;/blog/rag-without-second-database/&quot;&gt;RAG without a second database&lt;/a&gt; (anchor), &lt;a href=&quot;/blog/agent-memory-layer-on-reddb/&quot;&gt;Building an agent memory layer on RedDB&lt;/a&gt; (atomic episodic+semantic writes use the same pattern).&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/agent-memory-layer-on-reddb</id>
    <title>Building an agent memory layer on RedDB</title>
    <link href="https://reddb.io/blog/agent-memory-layer-on-reddb"/>
    <updated>2026-04-26</updated>
    <published>2026-04-26</published>
    <author><name>RedDB team</name></author>
    <summary>A copy-pasteable schema for episodic, semantic, and procedural memory in a single database — with the queries that retrieve them and the bookkeeping that keeps them honest.</summary>
    <content type="html">&lt;p&gt;Every agent project I&amp;#39;ve watched ship spends two weeks deciding what &amp;quot;memory&amp;quot; means before writing a line of retrieval code. This post tries to skip that fortnight. It is a concrete schema — three tables, four indices, and the queries that wire them together — that you can copy into your own RedDB-backed project and start tuning instead of designing.&lt;/p&gt;
&lt;p&gt;The schema is opinionated. It distinguishes &lt;strong&gt;episodic&lt;/strong&gt; memory (what happened, in order), &lt;strong&gt;semantic&lt;/strong&gt; memory (facts the agent extracted from those events), and &lt;strong&gt;procedural&lt;/strong&gt; memory (cached outcomes of expensive tool calls). The boundaries between them matter: they have different retention rules, different retrieval paths, and different invalidation triggers. Collapsing them into one big &lt;code&gt;memories&lt;/code&gt; table is the most common mistake — and the one that turns a six-week agent project into a six-month one.&lt;/p&gt;
&lt;p&gt;This post is the practical follow-up to &lt;a href=&quot;/blog/rag-without-second-database&quot;&gt;RAG without a second database&lt;/a&gt;. The same single-database principle applies: the vector lives on the row it describes, the same transaction writes both, and retrieval is one query instead of a fan-out.&lt;/p&gt;
&lt;h2&gt;The three shapes of memory&lt;/h2&gt;
&lt;p&gt;Before the SQL, the distinctions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Episodic&lt;/strong&gt; memory is the raw stream of agent events: user turns, tool calls, tool results, model outputs, errors. It is append-only, ordered, and (mostly) immutable. You query it by conversation, by time range, and occasionally by similarity to find &amp;quot;have I seen this before.&amp;quot; Retention is bounded — most episodic rows are useful for hours to days, not months.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Semantic&lt;/strong&gt; memory is what the agent has &lt;em&gt;extracted&lt;/em&gt; from episodic events: &amp;quot;the user&amp;#39;s deploy target is us-east-1,&amp;quot; &amp;quot;the user prefers concise responses,&amp;quot; &amp;quot;the customer&amp;#39;s plan is enterprise.&amp;quot; These are durable facts. They are not append-only — facts get updated, contradicted, and superseded. Retrieval is by similarity (vector) and by tagged subject (the user, the project, the customer). Retention is long.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Procedural&lt;/strong&gt; memory is cached outcomes of expensive or non-deterministic tool calls: the result of a web fetch, a search query, an LLM-generated summary of a long document. Procedural rows have a clear cache-key (the tool name plus its arguments) and an explicit expiry. Retrieval is by exact key — vectors are not the access path here, even though the &lt;em&gt;content&lt;/em&gt; might be embedded for separate semantic retrieval.&lt;/p&gt;
&lt;p&gt;Three shapes, three access patterns, three retention rules. One database.&lt;/p&gt;
&lt;h2&gt;The schema&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Episodic: append-only stream of agent events.
CREATE TABLE episodic_events (
  id            BIGSERIAL PRIMARY KEY,
  session_id    UUID NOT NULL,
  ts            TIMESTAMPTZ NOT NULL DEFAULT now(),
  actor         TEXT NOT NULL,  -- &amp;#39;user&amp;#39; | &amp;#39;agent&amp;#39; | &amp;#39;tool:&amp;lt;name&amp;gt;&amp;#39; | &amp;#39;system&amp;#39;
  kind          TEXT NOT NULL,  -- &amp;#39;message&amp;#39; | &amp;#39;tool_call&amp;#39; | &amp;#39;tool_result&amp;#39; | &amp;#39;error&amp;#39;
  payload       JSONB NOT NULL,
  tokens_in     INTEGER,
  tokens_out    INTEGER,
  embedding     VECTOR(1536),   -- nullable; embed only when you&amp;#39;ll retrieve
  embed_model   TEXT
);

CREATE INDEX episodic_session_ts ON episodic_events (session_id, ts DESC);
CREATE INDEX episodic_embed     ON episodic_events USING hnsw (embedding vector_cosine_ops)
  WHERE embedding IS NOT NULL;

-- Semantic: extracted facts the agent should remember.
CREATE TABLE semantic_facts (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  subject       TEXT NOT NULL,  -- &amp;#39;user:42&amp;#39; | &amp;#39;project:acme&amp;#39; | &amp;#39;global&amp;#39;
  predicate     TEXT NOT NULL,  -- short label: &amp;#39;prefers&amp;#39;, &amp;#39;deploy_target&amp;#39;, &amp;#39;plan_tier&amp;#39;
  object        TEXT NOT NULL,  -- the fact value
  confidence    REAL NOT NULL DEFAULT 1.0,
  source_event  BIGINT REFERENCES episodic_events(id),
  embedding     VECTOR(1536) NOT NULL,
  embed_model   TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  superseded_by UUID REFERENCES semantic_facts(id),
  superseded_at TIMESTAMPTZ
);

CREATE INDEX semantic_subject ON semantic_facts (subject)
  WHERE superseded_at IS NULL;
CREATE INDEX semantic_embed   ON semantic_facts USING hnsw (embedding vector_cosine_ops)
  WHERE superseded_at IS NULL;

-- Procedural: cached tool outcomes.
CREATE TABLE procedural_cache (
  cache_key     TEXT PRIMARY KEY,           -- hash(tool_name || canonical(args))
  tool_name     TEXT NOT NULL,
  args          JSONB NOT NULL,
  result        JSONB NOT NULL,
  computed_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at    TIMESTAMPTZ NOT NULL,
  hit_count     INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX procedural_expires ON procedural_cache (expires_at);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few notes on the choices before we get to the queries.&lt;/p&gt;
&lt;p&gt;The episodic &lt;code&gt;embedding&lt;/code&gt; column is &lt;strong&gt;nullable&lt;/strong&gt;. Most events — system messages, tool-call envelopes, error stack traces — will never be retrieved by similarity. Embedding them costs money and bloats the HNSW index for no benefit. Embed only the events you actually search: user turns and final agent responses, typically.&lt;/p&gt;
&lt;p&gt;Semantic facts use &lt;strong&gt;soft-supersedes&lt;/strong&gt;, not hard updates. When the user changes their preference, you don&amp;#39;t &lt;code&gt;UPDATE&lt;/code&gt; the row — you insert a new row and set the old row&amp;#39;s &lt;code&gt;superseded_by&lt;/code&gt;. The partial index &lt;code&gt;WHERE superseded_at IS NULL&lt;/code&gt; keeps the live view small, and the full history is still there for &amp;quot;when did this change?&amp;quot; queries. This pattern matters more than people admit: the moment an agent contradicts itself with a stale fact, the contradiction is debuggable only if you can see the old fact and the event that should have superseded it.&lt;/p&gt;
&lt;p&gt;Procedural cache keys are &lt;strong&gt;hashes of canonicalised args&lt;/strong&gt;, not the raw JSON. Canonicalise means: sort keys, normalise whitespace, lowercase the tool name. Skip this and the same logical call with arguments in a different order becomes a cache miss, and your &amp;quot;cache&amp;quot; runs at a 20% hit rate while you wonder why the agent is slow.&lt;/p&gt;
&lt;h2&gt;Writing memory: the agent&amp;#39;s tick&lt;/h2&gt;
&lt;p&gt;The hot path is the agent loop. Every tick, the agent appends to episodic, occasionally extracts a fact into semantic, and routinely reads-and-writes procedural. The constraint is that all of this must be transactional with respect to &lt;em&gt;one event&lt;/em&gt; — partial writes mean the agent&amp;#39;s view of its own past disagrees with its view of its own outputs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Append a user turn and embed it in one transaction.
BEGIN;

WITH event AS (
  INSERT INTO episodic_events (session_id, actor, kind, payload, embedding, embed_model, tokens_in)
  VALUES ($1, &amp;#39;user&amp;#39;, &amp;#39;message&amp;#39;, $2::jsonb, $3::vector, &amp;#39;text-embedding-3-small&amp;#39;, $4)
  RETURNING id
)
INSERT INTO semantic_facts (subject, predicate, object, source_event, embedding, embed_model)
SELECT $5, $6, $7, event.id, $3::vector, &amp;#39;text-embedding-3-small&amp;#39;
FROM event
WHERE $6 IS NOT NULL;  -- only insert the fact if extraction produced one

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One transaction, one embedding cost, one round-trip. The fact row points to the event that produced it; the event row carries the same embedding it would have written to a sibling vector store in the two-database version. The drift window from the RAG post is gone in the same way it was gone there: you cannot read the event without seeing the fact, because they were inserted under the same &lt;code&gt;BEGIN&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Procedural writes are simpler — an &lt;code&gt;INSERT ... ON CONFLICT (cache_key) DO UPDATE&lt;/code&gt; keyed on the canonicalised hash. The only subtlety is to &lt;em&gt;also&lt;/em&gt; bump &lt;code&gt;hit_count&lt;/code&gt; on every read (not just on the conflict path), which you do with a separate &lt;code&gt;UPDATE&lt;/code&gt; on the cache row at retrieval time. Without that counter you have no signal for which cached tool calls are pulling weight and which are dead weight that should be evicted early.&lt;/p&gt;
&lt;h2&gt;Reading memory: the four queries&lt;/h2&gt;
&lt;p&gt;Four queries cover ~90% of agent retrieval needs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Recent context.&lt;/strong&gt; The last N events of the current session, in order. This is the conversation history you feed back to the model.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT actor, kind, payload, ts
FROM episodic_events
WHERE session_id = $1
ORDER BY ts DESC
LIMIT $2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The compound index &lt;code&gt;(session_id, ts DESC)&lt;/code&gt; makes this a range scan. No vector cost, no fan-out — this is the cheapest query in the system and you will run it many times per tick.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Similar past events.&lt;/strong&gt; Used for &amp;quot;have I seen something like this user question before, across sessions?&amp;quot; This is where episodic embeddings earn their keep.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, session_id, ts, payload,
       1 - (embedding &amp;lt;=&amp;gt; $1::vector) AS similarity
FROM episodic_events
WHERE embedding IS NOT NULL
  AND ts &amp;gt; now() - interval &amp;#39;30 days&amp;#39;  -- bound the search by retention
ORDER BY embedding &amp;lt;=&amp;gt; $1::vector
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ts &amp;gt; now() - interval &amp;#39;30 days&amp;#39;&lt;/code&gt; clause is the retention boundary doing real work — without it, the HNSW search degrades over time and the results get less and less relevant because older events dominate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Subject&amp;#39;s live facts.&lt;/strong&gt; Everything the agent currently believes about a user or project.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT predicate, object, confidence, created_at
FROM semantic_facts
WHERE subject = $1
  AND superseded_at IS NULL
ORDER BY confidence DESC, created_at DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The partial index &lt;code&gt;WHERE superseded_at IS NULL&lt;/code&gt; means this is an index-only scan in the common case. Live facts only. Supersession bookkeeping pays off here: you never accidentally feed the model a stale belief alongside the current one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Cache lookup.&lt;/strong&gt; The procedural read-and-bump.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE procedural_cache
SET hit_count = hit_count + 1
WHERE cache_key = $1
  AND expires_at &amp;gt; now()
RETURNING result, computed_at;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;UPDATE ... RETURNING&lt;/code&gt; pattern collapses read + write-back-stats into one round-trip. If the row is missing or expired, the result set is empty and the agent falls through to recomputing.&lt;/p&gt;
&lt;h2&gt;Supersession in practice&lt;/h2&gt;
&lt;p&gt;The semantic-supersession pattern needs one helper. When the agent decides a new fact contradicts an existing one, mark the old one superseded &lt;em&gt;in the same transaction&lt;/em&gt; as the new insert:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

INSERT INTO semantic_facts (subject, predicate, object, source_event, embedding, embed_model, confidence)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id;  -- capture the new id, call it $new_id

UPDATE semantic_facts
SET superseded_by = $new_id,
    superseded_at = now()
WHERE subject = $1
  AND predicate = $2
  AND superseded_at IS NULL
  AND id &amp;lt;&amp;gt; $new_id;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;subject&lt;/code&gt;+&lt;code&gt;predicate&lt;/code&gt; match is the supersession key — &amp;quot;user 42&amp;#39;s preferred deploy target&amp;quot; is a single slot in the conceptual model, and only one row should be live per slot at a time. The transaction is what makes this safe under concurrent agent ticks: the partial index sees a consistent live-view at any point.&lt;/p&gt;
&lt;h2&gt;Retention and bounded growth&lt;/h2&gt;
&lt;p&gt;Without retention every memory table grows unboundedly. Three rules covers the realistic shape:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Episodic&lt;/strong&gt; older than 30 days, except events referenced by a still-live &lt;code&gt;semantic_facts.source_event&lt;/code&gt;. A scheduled &lt;code&gt;DELETE&lt;/code&gt; with a &lt;code&gt;NOT EXISTS&lt;/code&gt; subquery does the work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semantic&lt;/strong&gt; with &lt;code&gt;superseded_at &amp;lt; now() - interval &amp;#39;180 days&amp;#39;&lt;/code&gt; is hard-deleted. Soft-supersedes preserve recent history; long-tail history goes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Procedural&lt;/strong&gt; with &lt;code&gt;expires_at &amp;lt; now()&lt;/code&gt; is deleted at a fixed cadence, &lt;em&gt;or&lt;/em&gt; on eviction pressure (track &lt;code&gt;pg_relation_size&lt;/code&gt; against a budget).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the difference between a memory layer and a memory leak. Without these, the agent&amp;#39;s recall gets slower every week and you eventually rewrite it as a sibling vector store with a CDC pipeline — which is exactly the architecture the multi-model approach exists to avoid.&lt;/p&gt;
&lt;h2&gt;What this schema is not&lt;/h2&gt;
&lt;p&gt;This is not a replacement for a working-set context buffer (the last few thousand tokens of the current conversation, held in memory or in the agent process&amp;#39;s own state). That belongs in the agent runtime, not the database. Episodic memory is the &lt;em&gt;durable&lt;/em&gt; record of the same conversation, available across sessions and across agents.&lt;/p&gt;
&lt;p&gt;It also isn&amp;#39;t a replacement for vector-only embedding databases at very high cardinality — if you have a billion fact rows and need single-digit-millisecond ANN, the access pattern looks different. The schema here is sized for the typical agent project: thousands to millions of events, tens of thousands to low millions of facts. That is where most projects live for the first two years, and it is exactly the size where the operational overhead of a second database hurts the most.&lt;/p&gt;
&lt;h2&gt;The shorter version&lt;/h2&gt;
&lt;p&gt;Three tables, separated by &lt;em&gt;access pattern&lt;/em&gt; not by data type:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;episodic_events&lt;/code&gt; — ordered, mostly immutable, nullable embedding, retained for weeks.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;semantic_facts&lt;/code&gt; — soft-superseded, subject-keyed, always embedded, retained for months.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;procedural_cache&lt;/code&gt; — hash-keyed, explicit TTL, hit-counted, retained until expiry.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One transaction writes an event plus any extracted facts. One query reads the conversation; one query reads the subject&amp;#39;s live facts; one query reads the cache. The drift window is gone because the writes are atomic. The supersession history is preserved because updates aren&amp;#39;t destructive. Retention is bounded because every table has an answer to &amp;quot;when do rows leave.&amp;quot;&lt;/p&gt;
&lt;p&gt;Copy the schema. Tune the embedding dimensions, the retention intervals, the HNSW parameters. The shape stays.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/self-hosting-reddb-real-world-checklist</id>
    <title>Self-hosting RedDB: the real-world checklist</title>
    <link href="https://reddb.io/blog/self-hosting-reddb-real-world-checklist"/>
    <updated>2026-04-25</updated>
    <published>2026-04-25</published>
    <author><name>RedDB team</name></author>
    <summary>An operator-grade checklist for running RedDB on your own infrastructure — container images, k8s manifests, volume sizing, monitoring, backups, network policies, and the secrets-management decisions that bite later.</summary>
    <content type="html">&lt;p&gt;This is the post we wish existed when we first put RedDB on a customer&amp;#39;s cluster. It is not a marketing surface — it is the literal checklist our SRE walks through before declaring an environment &amp;quot;production-ready.&amp;quot; If you can answer every item below with &amp;quot;yes, and here&amp;#39;s the evidence,&amp;quot; you have a self-host that will not page you at 03:00.&lt;/p&gt;
&lt;p&gt;Everything here assumes Kubernetes because that is where ~90% of self-hosters land. The same shape applies on plain VMs with &lt;code&gt;systemd&lt;/code&gt; or on Nomad; the artifacts change, the decisions do not.&lt;/p&gt;
&lt;h2&gt;The five-minute version&lt;/h2&gt;
&lt;p&gt;Run this checklist before you put traffic on the cluster:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;StatefulSet, not Deployment, for the data plane.&lt;/li&gt;
&lt;li&gt;PersistentVolumeClaim sized at &lt;strong&gt;3× expected hot working set&lt;/strong&gt;, on SSD-class storage with &lt;code&gt;fsync&lt;/code&gt; honored.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;readinessProbe&lt;/code&gt; that checks the WAL is fsynced and a follower has caught up to within 5 seconds.&lt;/li&gt;
&lt;li&gt;Prometheus scrape against &lt;code&gt;/metrics&lt;/code&gt; on the admin port (not the data port).&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;CronJob&lt;/code&gt; that runs a backup against object storage, with &lt;code&gt;retention &amp;gt;= compliance window + 7 days&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;NetworkPolicy that only allows the application namespace, the monitoring namespace, and the backup job to reach the data port.&lt;/li&gt;
&lt;li&gt;KMS-backed secret for the encryption-at-rest key (see &lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;vault key rotation&lt;/a&gt; for why this matters).&lt;/li&gt;
&lt;li&gt;A documented restore drill that someone ran in the last 90 days.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The rest of this post is the long form of those eight bullets.&lt;/p&gt;
&lt;h2&gt;Container image: pin, don&amp;#39;t &lt;code&gt;:latest&lt;/code&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;image: registry.reddb.io/reddb:1.8.4
imagePullPolicy: IfNotPresent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two reasons we belabor this. First, &lt;code&gt;:latest&lt;/code&gt; defeats the whole point of immutable infra — a node reschedule silently rolls you forward. Second, our minor versions occasionally change defaults (compaction priority, bloom sizing) and you want the choice to be deliberate. Pin the patch, read the changelog before you bump the minor.&lt;/p&gt;
&lt;p&gt;Image size is ~80 MiB compressed. We ship a &lt;code&gt;-debug&lt;/code&gt; variant with &lt;code&gt;pprof&lt;/code&gt; and a shell; do not use it as your default runtime — it has a strictly larger attack surface for no operational benefit.&lt;/p&gt;
&lt;h2&gt;StatefulSet, not Deployment&lt;/h2&gt;
&lt;p&gt;The data plane is stateful. The right Kubernetes primitive is &lt;code&gt;StatefulSet&lt;/code&gt;. A working manifest looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: reddb
  namespace: reddb-system
spec:
  serviceName: reddb-headless
  replicas: 3
  podManagementPolicy: Parallel
  selector:
    matchLabels:
      app: reddb
  template:
    metadata:
      labels:
        app: reddb
    spec:
      terminationGracePeriodSeconds: 120
      securityContext:
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
      containers:
        - name: reddb
          image: registry.reddb.io/reddb:1.8.4
          args: [&amp;quot;--config=/etc/reddb/config.toml&amp;quot;]
          ports:
            - name: data
              containerPort: 5432
            - name: replica
              containerPort: 5433
            - name: admin
              containerPort: 9180
          env:
            - name: REDDB_NODE_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: REDDB_KMS_KEY_ARN
              valueFrom:
                secretKeyRef:
                  name: reddb-kms
                  key: arn
          volumeMounts:
            - name: data
              mountPath: /var/lib/reddb
            - name: config
              mountPath: /etc/reddb
              readOnly: true
          resources:
            requests:
              cpu: &amp;quot;2&amp;quot;
              memory: 8Gi
            limits:
              memory: 12Gi
          readinessProbe:
            httpGet:
              path: /readyz
              port: admin
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /livez
              port: admin
            periodSeconds: 30
            failureThreshold: 3
            initialDelaySeconds: 60
      volumes:
        - name: config
          configMap:
            name: reddb-config
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: [&amp;quot;ReadWriteOnce&amp;quot;]
        storageClassName: gp3-fsync-honored
        resources:
          requests:
            storage: 200Gi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few decisions inside this we want to call out, because the defaults regularly trip people:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;podManagementPolicy: Parallel&lt;/code&gt;&lt;/strong&gt;. The default is &lt;code&gt;OrderedReady&lt;/code&gt;, which serializes startup. RedDB nodes form a quorum on boot — they need to come up roughly together, not one after the other waiting for the previous to be &lt;code&gt;Ready&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;terminationGracePeriodSeconds: 120&lt;/code&gt;&lt;/strong&gt;. The data plane needs time to flush the WAL, hand off the leader role if it owned one, and close client connections cleanly. 30 seconds (the default) will cause WAL replay on restart, which adds startup latency and shows up as a recurring availability dip.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPU request &lt;code&gt;2&lt;/code&gt;, no limit&lt;/strong&gt;. Compaction is bursty. Setting a CPU limit will cause throttling during compaction windows and you will see the exact latency spike that &lt;a href=&quot;/blog/lsm-tree-compaction-notes/&quot;&gt;the LSM compaction notes&lt;/a&gt; describe. Memory limits, however, are fine and recommended — RedDB respects the cgroup memory limit and shrinks its block cache accordingly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;livenessProbe.initialDelaySeconds: 60&lt;/code&gt;&lt;/strong&gt;. WAL replay on a cold node can take a minute. Without the delay, a slow-starting node gets killed mid-replay, replays again, gets killed again. We have seen production clusters stuck in this loop for hours.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Storage: &lt;code&gt;gp3-fsync-honored&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;This is the single line item that decides whether your self-host is durable or theatrical. RedDB&amp;#39;s durability guarantees assume &lt;code&gt;fsync&lt;/code&gt; actually persists data before returning. Many cloud storage classes lie about this for performance. The classes that do not lie, by cloud:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AWS&lt;/strong&gt;: &lt;code&gt;gp3&lt;/code&gt; (set &lt;code&gt;iopsPerGB&lt;/code&gt; to at least 3, and confirm the EBS-CSI driver is not running with &lt;code&gt;dataPlaneOptions.fsyncDisabled&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GCP&lt;/strong&gt;: &lt;code&gt;pd-ssd&lt;/code&gt; (the default &lt;code&gt;pd-standard&lt;/code&gt; does honor fsync but the IOPS profile is unsuitable).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Azure&lt;/strong&gt;: &lt;code&gt;Premium SSD v2&lt;/code&gt; (the older &lt;code&gt;Premium_LRS&lt;/code&gt; works but costs more for less).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bare metal&lt;/strong&gt;: NVMe with &lt;code&gt;LITTLE_ENDIAN&lt;/code&gt; write barriers enabled in the filesystem (&lt;code&gt;ext4&lt;/code&gt; with &lt;code&gt;data=ordered&lt;/code&gt;, the default, is correct; &lt;code&gt;data=writeback&lt;/code&gt; is not).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Size the PVC at &lt;strong&gt;3× expected hot working set&lt;/strong&gt;. The 3× accounts for: 1× the live data, ~0.5× the SSTables waiting on compaction, ~0.5× temporary space for compaction itself, and the rest as headroom for &lt;code&gt;pgbench&lt;/code&gt;-style load tests and the occasional bloat. Resizing PVCs is supported but disruptive; oversize on day one.&lt;/p&gt;
&lt;h2&gt;Monitoring: the four metrics that matter&lt;/h2&gt;
&lt;p&gt;RedDB exports ~120 Prometheus metrics. Watch these four; alert on the first three:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;th&gt;Page threshold&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reddb_wal_fsync_seconds{quantile=&amp;quot;0.99&amp;quot;}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Storage subsystem health&lt;/td&gt;
&lt;td&gt;&amp;gt; 50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reddb_replication_lag_bytes{follower=~&amp;quot;.*&amp;quot;}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Follower divergence&lt;/td&gt;
&lt;td&gt;&amp;gt; 100 MiB sustained 5min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reddb_compaction_score{level=~&amp;quot;.*&amp;quot;}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compaction debt&lt;/td&gt;
&lt;td&gt;&amp;gt; 50 sustained 10min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reddb_block_cache_hit_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Working set fits&lt;/td&gt;
&lt;td&gt;(track, don&amp;#39;t page)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;If &lt;code&gt;wal_fsync_seconds&lt;/code&gt; spikes, your storage is the problem, full stop. Do not start tuning RedDB until that number is back under 10ms. We have spent days helping customers tune things that were not the bottleneck because they were watching the wrong dashboard.&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;compaction_score&lt;/code&gt; is climbing without bound, you are writing faster than the cluster can compact. The fix is either more storage IOPS, more CPU, or write backpressure on the application side. RedDB has a built-in backpressure mechanism that returns &lt;code&gt;SQLSTATE 53300&lt;/code&gt; (cannot connect now); turn it on with &lt;code&gt;compaction.backpressure.enabled = true&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Backups: object storage, encrypted, drilled&lt;/h2&gt;
&lt;p&gt;The backup job we recommend is a &lt;code&gt;CronJob&lt;/code&gt; that triggers RedDB&amp;#39;s built-in &lt;code&gt;pg_basebackup&lt;/code&gt;-equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: batch/v1
kind: CronJob
metadata:
  name: reddb-backup
  namespace: reddb-system
spec:
  schedule: &amp;quot;0 3 * * *&amp;quot;
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 7
  failedJobsHistoryLimit: 14
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          serviceAccountName: reddb-backup
          containers:
            - name: backup
              image: registry.reddb.io/reddb-backup:1.8.4
              args:
                - --source=reddb-0.reddb-headless.reddb-system.svc:5432
                - --destination=s3://acme-reddb-backups/$(NODE)/$(DATE)/
                - --encrypt-key-arn=$(BACKUP_KMS_ARN)
                - --retention-days=35
              env:
                - name: NODE
                  value: reddb-0
                - name: DATE
                  valueFrom:
                    fieldRef:
                      fieldPath: metadata.name
                - name: BACKUP_KMS_ARN
                  valueFrom:
                    secretKeyRef:
                      name: reddb-backup-kms
                      key: arn
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to verify in your environment:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Backups land where you think.&lt;/strong&gt; Run &lt;code&gt;aws s3 ls s3://acme-reddb-backups/ --recursive | head&lt;/code&gt; the morning after the first run. A backup job that exits 0 but writes to the wrong prefix is the textbook silent failure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retention matches compliance.&lt;/strong&gt; &lt;code&gt;--retention-days=35&lt;/code&gt; covers a 30-day compliance window with a week of buffer. If you are in a 7-year regulated industry, you want a tiered policy (daily for 30 days, monthly for 7 years) — the simplest implementation is two separate &lt;code&gt;CronJobs&lt;/code&gt; writing to two separate prefixes with two separate lifecycle rules on the bucket.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You have done a restore drill recently.&lt;/strong&gt; Backups that have never been restored are wishes. Once per quarter, restore to a scratch cluster, run a smoke query, write down the wall-clock time. If your RTO target is 1 hour and your last drill took 2 hours, you have a problem you would rather discover now than during the incident.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Network policy: deny-by-default, then carve out&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: reddb-data-plane
  namespace: reddb-system
spec:
  podSelector:
    matchLabels:
      app: reddb
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              app.kubernetes.io/name: application
      ports:
        - protocol: TCP
          port: 5432
    - from:
        - namespaceSelector:
            matchLabels:
              app.kubernetes.io/name: monitoring
      ports:
        - protocol: TCP
          port: 9180
    - from:
        - podSelector:
            matchLabels:
              job-name: reddb-backup
      ports:
        - protocol: TCP
          port: 5432
    - from:
        - podSelector:
            matchLabels:
              app: reddb
      ports:
        - protocol: TCP
          port: 5433
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The deny-by-default is the important bit. Almost every incident we have helped with where someone exfiltrated data started with &amp;quot;the data port was reachable from somewhere we did not realize.&amp;quot; Carve out the three flows the cluster actually needs (app → data, monitoring → admin, backup-job → data, replica → replica) and nothing else.&lt;/p&gt;
&lt;h2&gt;Secrets: KMS-backed, not literal Kubernetes Secrets&lt;/h2&gt;
&lt;p&gt;The encryption-at-rest key is the one secret that, if leaked, ruins your night even if you rotate it immediately (because the leaked version covers everything written under it). Treat it accordingly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Store the &lt;strong&gt;KMS ARN&lt;/strong&gt; in a Kubernetes Secret, not the key material itself. RedDB unwraps at boot via the pod&amp;#39;s IAM role / workload identity.&lt;/li&gt;
&lt;li&gt;Rotate the KMS key on the documented schedule (we walked through the zero-downtime mechanism in &lt;a href=&quot;/blog/vault-key-rotation-zero-downtime/&quot;&gt;the vault rotation post&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Audit access to the KMS key, not just access to the cluster. Most Kubernetes audit dashboards do not surface &amp;quot;someone called &lt;code&gt;kms:Decrypt&lt;/code&gt; against the production key from outside the cluster&amp;#39;s IAM role,&amp;quot; which is exactly the alert you want.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Things this checklist deliberately skips&lt;/h2&gt;
&lt;p&gt;A few topics that come up and that you should think about separately, not as part of &amp;quot;is the cluster ready&amp;quot;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multi-region.&lt;/strong&gt; The above is for a single region. Multi-region is the topic of &lt;a href=&quot;/blog/disaster-recovery-for-reddb/&quot;&gt;the disaster-recovery post&lt;/a&gt; — RPO/RTO trade-offs, async vs synchronous replication, and the operational tax both impose.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connection pooling.&lt;/strong&gt; Run a pooler (PgBouncer, RedDB&amp;#39;s bundled &lt;code&gt;reddb-pool&lt;/code&gt;) between your app and the data plane. Sizing the pool is a function of your app, not the cluster.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema migrations.&lt;/strong&gt; Use whatever your team already uses (Atlas, sqitch, plain &lt;code&gt;psql&lt;/code&gt; scripts in CI). The cluster does not care, and any opinion this post had would just be cargo-culted on day one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Capacity planning.&lt;/strong&gt; Out of scope for &amp;quot;get to production-ready.&amp;quot; Re-evaluate every quarter after you have real traffic.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;Pin the image, use a StatefulSet, give it &lt;code&gt;fsync&lt;/code&gt;-honoring SSD storage at 3× hot working set, watch four metrics, run encrypted backups to object storage and actually restore them, deny-by-default networking, KMS-back the at-rest key. If you can show evidence for each of those, you are production-ready. If you cannot, the cluster will tell you which one you skipped — usually around 03:00.&lt;/p&gt;
&lt;p&gt;The companion DR post picks up from here: what to do when the region itself goes away.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/best-of-breed-loses-small-scale</id>
    <title>Why &apos;best of breed&apos; loses at small scale</title>
    <link href="https://reddb.io/blog/best-of-breed-loses-small-scale"/>
    <updated>2026-04-24</updated>
    <published>2026-04-24</published>
    <author><name>RedDB team</name></author>
    <summary>The ops cost of running four engines instead of one, measured in person-hours at team sizes of 2, 5, and 15 engineers. Multi-model isn&apos;t only a scale concern — it&apos;s a small-team unlock.</summary>
    <content type="html">&lt;p&gt;The &amp;quot;best of breed per workload&amp;quot; line has a comforting shape: pick the perfect tool for each job, glue them together, ship faster than the team that picked one general-purpose engine. It reads as engineering maturity. In practice, at the team sizes where most products live — between two and fifteen engineers — it is a tax that almost nobody finishes paying.&lt;/p&gt;
&lt;p&gt;This post tries to put a number on that tax. Not &amp;quot;it feels slower&amp;quot; but person-hours per quarter, per engine, per team size. The numbers below are not from a benchmark; they are the rolling average of what we and our design partners have observed across teams running between two and seven storage engines in production.&lt;/p&gt;
&lt;h2&gt;The stack we are measuring&lt;/h2&gt;
&lt;p&gt;The &amp;quot;best of breed&amp;quot; reference stack:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Postgres&lt;/strong&gt; for relational rows&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Elasticsearch&lt;/strong&gt; (or OpenSearch) for full-text search&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A vector store&lt;/strong&gt; (Pinecone, Weaviate, or pgvector-on-a-second-cluster) for RAG&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redis&lt;/strong&gt; for sessions, rate limits, and a queue&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Four engines. Each is, in isolation, a strong choice. The question this post is asking: what does the seam between them cost, and how does that cost scale with team size?&lt;/p&gt;
&lt;p&gt;The single-engine alternative: one multi-model store (RedDB, or for the sake of argument any system that handles the four shapes under one transaction and one operator surface).&lt;/p&gt;
&lt;h2&gt;The hidden line items&lt;/h2&gt;
&lt;p&gt;Before the table, the hidden costs people forget when they sketch the &amp;quot;best of breed&amp;quot; decision:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Training&lt;/strong&gt;. Each engine has its own query language, failure modes, tuning knobs, backup procedure. A new hire is not productive on the stack until they can debug all of them. With one engine, &amp;quot;productive on the database&amp;quot; is one onboarding milestone; with four, it is four.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On-call rotation per system&lt;/strong&gt;. If you take on-call seriously, every engine in production has runbooks, alerts, and a person who knows it well enough to wake up at 3am. With four engines, either four people each own one (and the bus factor is one across the stack) or every on-call person owns four (and the runbook reading list is long).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IAM sprawl&lt;/strong&gt;. Every engine has its own auth model, its own service accounts, its own audit log to wire into the SIEM. Four times the IAM surface is four times the place a misconfigured policy hides.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-engine consistency code&lt;/strong&gt;. This is the line item nobody budgets for. When the source-of-truth row in Postgres updates and the search index in Elastic and the embedding in the vector store have to follow, somebody writes — and maintains, forever — the outbox, the queue, the retry policy, the dead-letter handler, the reconciliation job. Per pair of engines. With four engines and three data flows between them, that is three independent pieces of glue, each with its own failure mode.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backup correlation&lt;/strong&gt;. A backup of Postgres taken at 02:00, an Elastic snapshot taken at 02:15, and a Pinecone export from yesterday are not a coherent point-in-time view of your system. Restoring is a science project. With one engine, the backup is the backup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local dev parity&lt;/strong&gt;. Each engine needs to run on the developer&amp;#39;s laptop or be mocked. With four, the docker-compose file becomes a load-bearing artifact and &amp;quot;it works locally&amp;quot; stops being a useful signal.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these costs show up in the per-engine pricing page. All of them show up in the team&amp;#39;s calendar.&lt;/p&gt;
&lt;h2&gt;The ops cost table&lt;/h2&gt;
&lt;p&gt;Person-hours per quarter, by engine, by team size. &amp;quot;Engine&amp;quot; here means one production storage system the team owns end-to-end. &amp;quot;Glue&amp;quot; is the cross-engine consistency code and reconciliation work.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Activity&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Per engine, 2-person team&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Per engine, 5-person team&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Per engine, 15-person team&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Patching + version upgrades&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;8 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;12 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;20 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup verification + restore drill&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;6 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;10 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;16 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capacity planning + cost review&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;4 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;8 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;14 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runbook maintenance + on-call training&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;4 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;10 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;22 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incident response (avg, amortized)&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;12 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;18 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;28 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema/index/config change reviews&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;6 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;14 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;30 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Per-engine subtotal&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;40 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;72 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;130 h&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;And the cross-engine line items:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Activity&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;2-person&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;5-person&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;15-person&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Glue maintenance (per pair of engines)&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;8 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;14 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;22 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-engine incidents&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;10 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;16 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;30 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema drift reconciliation&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;6 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;12 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;20 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Running the math&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Best-of-breed stack: four engines, three glue pairs.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team size&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Per-engine cost (×4)&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Glue cost&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Total q/quarter&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;% of team capacity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;160 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;72 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;232 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;~48 % of one engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;288 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;126 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;414 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;~34 % of one engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;520 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;216 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;736 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;~20 % of one engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Single-engine stack: one engine, no glue.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team size&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Per-engine cost&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Glue cost&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Total q/quarter&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;% of team capacity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;40 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;40 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;~8 % of one engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;72 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;72 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;~6 % of one engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;130 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0 h&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;130 h&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;~3.5 % of one engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Assumes one engineer-quarter = ~480 productive hours (60 working days × 8 h, lightly discounted for meetings and PRs).&lt;/p&gt;
&lt;h2&gt;What the table says, in English&lt;/h2&gt;
&lt;p&gt;At &lt;strong&gt;two engineers&lt;/strong&gt;, the best-of-breed tax is roughly half of one engineer, every quarter, forever. That is a co-founder. Half of one of two people is doing nothing but keeping the seams between engines from tearing. If those two people were trying to find product-market fit, they have just lost a quarter of the company&amp;#39;s total capacity to glue.&lt;/p&gt;
&lt;p&gt;At &lt;strong&gt;five engineers&lt;/strong&gt;, it is closer to a third of a person — but a five-person team that gives up a third of a person to undifferentiated ops is one that is not shipping the thing customers are asking for. The team feels it as &amp;quot;we are always firefighting.&amp;quot; That is what the table is measuring.&lt;/p&gt;
&lt;p&gt;At &lt;strong&gt;fifteen engineers&lt;/strong&gt;, the percentage drops to a fifth of a person — but the absolute number is 736 hours per quarter, the equivalent of more than four engineer-months annually. At fifteen people, that often pays for the dedicated platform team that the team needed in order to run the stack. The cost has not gone away; it has been institutionalized.&lt;/p&gt;
&lt;p&gt;The single-engine column, by contrast, scales sub-linearly. There is no glue, so adding engines to the stack does not add the worst line item. The increase from a two-person to a fifteen-person team is roughly 3× — and most of that is &amp;quot;more engineers means more change reviews,&amp;quot; not more ops surface.&lt;/p&gt;
&lt;h2&gt;&amp;quot;But we&amp;#39;re not paying that cost&amp;quot;&lt;/h2&gt;
&lt;p&gt;Three common objections, with what we have seen in practice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Our cloud-managed services handle ops.&amp;quot;&lt;/strong&gt; Partly. Managed Postgres, managed Elastic, managed Redis, managed vector store — each one removes the patching line item and some of the capacity planning. It does not remove the runbooks, the IAM sprawl, the cross-engine glue, the backup correlation, the on-call surface, or the dev-parity problem. Maybe halve the per-engine row. The glue row does not budge, because that is your code, not the provider&amp;#39;s.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;We have one person who owns each engine.&amp;quot;&lt;/strong&gt; This is the bus-factor-one reframing of the cost, not its elimination. The work is the same; it has been concentrated on individuals whose departure will be load-bearing. It also concentrates expertise where it cannot be reviewed, which is its own incident waiting to happen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;We do not have all four engines yet.&amp;quot;&lt;/strong&gt; Then the calculation is the one you should be doing now, before adding the third. The pattern we have seen is that engines are added one at a time, each addition is locally justified (&amp;quot;we just need full-text search&amp;quot;), and the team realizes only at engine four that the integration tax has compounded. Each additional engine adds another glue pair, not a constant.&lt;/p&gt;
&lt;h2&gt;Where the framing breaks&lt;/h2&gt;
&lt;p&gt;This is not a universal claim. There are real reasons to run a best-of-breed stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The single-engine option is genuinely worse at one of your shapes.&lt;/strong&gt; If you need true OLAP — billion-row scans, columnar compression — a multi-model engine is not the right tool. ClickHouse exists for a reason.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You have a regulatory requirement that names a specific engine.&lt;/strong&gt; Use the named engine; the auditor&amp;#39;s report is not the place to be clever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One of the shapes is so high-volume that it dominates the cost.&lt;/strong&gt; If 95 % of your read traffic is Redis-shaped (millions of cache hits per second), running Redis separately and keeping it tuned is genuinely cheaper than putting that load on a general-purpose engine.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are real cases. They are also the cases where, at any team size, the math works out. Most teams do not have them. Most teams have four engines because each was added under local pressure, not because the stack was designed.&lt;/p&gt;
&lt;h2&gt;Multi-model as a small-team unlock&lt;/h2&gt;
&lt;p&gt;The framing this piece is most arguing against: &amp;quot;multi-model is for scale.&amp;quot; The table says the opposite. The team that benefits most from one engine instead of four is the two-person team, because the absolute ops cost is the same fraction of a much smaller capacity. A 200-engineer company can run seven engines and not feel it. Two co-founders cannot.&lt;/p&gt;
&lt;p&gt;This reframes when to consider multi-model. Not when you grow into it; before you grow. The choice to run one engine instead of four is one of the highest-leverage decisions a small team makes, and almost nobody frames it that way at the moment of decision, because each engine looks free in isolation.&lt;/p&gt;
&lt;p&gt;We are not the only multi-model option. Postgres, with its growing extension ecosystem, covers more shapes every year and is a reasonable single-engine answer for many teams — we said as much in &lt;a href=&quot;/blog/when-you-dont-need-reddb/&quot;&gt;When you don&amp;#39;t need RedDB&lt;/a&gt;. The point of this post is not &amp;quot;pick RedDB.&amp;quot; It is &amp;quot;pick one engine, whichever one matches your shapes.&amp;quot;&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Person-hours per quarter on the ops/glue surface of a four-engine stack: ~232 h at 2 people, ~414 h at 5, ~736 h at 15.&lt;/li&gt;
&lt;li&gt;Same calculation for a one-engine stack: ~40 h, ~72 h, ~130 h.&lt;/li&gt;
&lt;li&gt;The percentage cost is highest at the smallest team — multi-model is a small-team unlock, not a scale concern.&lt;/li&gt;
&lt;li&gt;Managed services reduce the per-engine row, not the glue row. The glue is your code.&lt;/li&gt;
&lt;li&gt;Where best-of-breed wins: true OLAP, named regulatory requirements, one workload dominates the cost. Most teams do not have these.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Related: &lt;a href=&quot;/blog/when-you-dont-need-reddb/&quot;&gt;When you don&amp;#39;t need RedDB&lt;/a&gt;, &lt;a href=&quot;/blog/multi-model-storage-tradeoffs/&quot;&gt;Multi-model storage: the actual tradeoffs&lt;/a&gt;, &lt;a href=&quot;/blog/rag-without-second-database/&quot;&gt;RAG without a second database&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://reddb.io/blog/when-you-dont-need-reddb</id>
    <title>When you don&apos;t need RedDB</title>
    <link href="https://reddb.io/blog/when-you-dont-need-reddb"/>
    <updated>2026-04-22</updated>
    <published>2026-04-22</published>
    <author><name>RedDB team</name></author>
    <summary>An honest list of workloads where Postgres + pgvector, SQLite, or a single-purpose store will serve you better than RedDB. If your shape is on this list, save yourself the migration.</summary>
    <content type="html">&lt;p&gt;We sell a database. Telling you when not to buy it is, on the face of it, a strange use of a marketing surface. We do it anyway because the alternative — watching someone migrate onto RedDB, hit a wall that was visible from a mile away, and churn six months later — is a worse outcome for everyone. A reader who saves a migration they would have regretted is a reader who trusts the next post.&lt;/p&gt;
&lt;p&gt;This piece is the list. If your shape is here, the right answer is &amp;quot;stay where you are&amp;quot; or &amp;quot;pick the boring single-purpose store.&amp;quot; We will be specific about which one.&lt;/p&gt;
&lt;h2&gt;The frame: what RedDB is actually for&lt;/h2&gt;
&lt;p&gt;RedDB earns its keep when &lt;strong&gt;a single workload needs more than one storage shape at the same time&lt;/strong&gt;. Relational rows with vector search next to them. Documents with full-text search next to them. Event log with derived materialized views next to it. The pitch is that the second shape is in the same transaction, the same auth model, the same backup, the same operator surface. If you do not have two shapes — or you have two but they never need to be consistent with each other — most of that value evaporates and you are paying the cost of a less-mature engine for no return.&lt;/p&gt;
&lt;p&gt;Everything below is a variant of &amp;quot;you only have one shape, or your two shapes never need to be consistent.&amp;quot;&lt;/p&gt;
&lt;h2&gt;1. Single-modality OLTP with no vectors and no documents&lt;/h2&gt;
&lt;p&gt;If your data model is &amp;quot;ten well-normalized tables, some foreign keys, a handful of indexes, and the hardest query is a three-way join,&amp;quot; &lt;strong&gt;use Postgres&lt;/strong&gt;. Not pgvector, not a multi-model anything — just Postgres.&lt;/p&gt;
&lt;p&gt;Postgres at this shape is the boring right answer for reasons that have very little to do with the database itself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every cloud has a managed Postgres with a 10-year operational track record. The on-call story is solved without you doing anything.&lt;/li&gt;
&lt;li&gt;Every ORM, every BI tool, every analyst-facing dashboard speaks Postgres natively. You do not have to argue with anyone about driver support.&lt;/li&gt;
&lt;li&gt;Every senior engineer you will ever hire has debugged a Postgres query plan before. Ramp-up is zero.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The RedDB pitch in this shape reduces to &amp;quot;maybe slightly different operational defaults.&amp;quot; That is not enough to justify being the one team in your company on a less-deployed engine. Stay on Postgres.&lt;/p&gt;
&lt;h2&gt;2. Embedded / single-user / single-process workloads&lt;/h2&gt;
&lt;p&gt;If your workload runs in a desktop app, a mobile app, a CLI, an edge device, or a serverless function with a local writable disk, &lt;strong&gt;use SQLite&lt;/strong&gt;. (For mobile-first sync, Turso or Cloudflare D1 sit on top of the same engine and add the sync layer.)&lt;/p&gt;
&lt;p&gt;SQLite is not &amp;quot;Postgres lite.&amp;quot; It is a fundamentally different operating model — in-process, zero servers, the file &lt;em&gt;is&lt;/em&gt; the database. RedDB is a server process with a network protocol; running it embedded would be a category error. The minute your data fits this shape, every &amp;quot;but RedDB has feature X&amp;quot; is irrelevant because the cost of running a server next to your app dwarfs the value of feature X.&lt;/p&gt;
&lt;p&gt;Heuristic: if your &amp;quot;production deployment&amp;quot; is &amp;quot;ship a binary that touches a local file,&amp;quot; SQLite is the answer.&lt;/p&gt;
&lt;h2&gt;3. Workloads where the second shape never needs to be transactionally consistent&lt;/h2&gt;
&lt;p&gt;This is the subtle one. Just having &amp;quot;two shapes&amp;quot; does not mean you need a multi-model database. The honest question is: &lt;strong&gt;do the two shapes need to be consistent with each other on every write?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Take the classic &amp;quot;I want logs and metrics next to my OLTP data.&amp;quot; You almost certainly do not need those to be in the same transaction as your business writes. The log write losing a record because of a transient downstream outage is fine. The metric being a few seconds stale on a dashboard is fine. Stream them into the dedicated tool (Loki, Prometheus, ClickHouse) and let the OLTP database be the OLTP database. The cost of putting them in the same engine is real; the consistency benefit is zero because no consumer of those streams expects strict consistency.&lt;/p&gt;
&lt;p&gt;A useful test: if you can imagine an outage where one shape lags the other by 30 seconds and no user ever notices, you do not need transactional consistency between them, and you do not need a multi-model database. You need two databases and an event bus.&lt;/p&gt;
&lt;p&gt;The RedDB shape that earns its keep is the inverse — the search index that lags the document by 30 seconds &lt;em&gt;will&lt;/em&gt; show a user a result that no longer matches the source. Drift visible to users is the bar.&lt;/p&gt;
&lt;h2&gt;4. Hyper-low write volume with no operational team&lt;/h2&gt;
&lt;p&gt;If you are a two-person team running a marketing site, an internal tool, or a side project that does fewer than ~1 write per second on a peak day, the operational overhead of running &lt;em&gt;any&lt;/em&gt; dedicated database is larger than the storage problem you are solving.&lt;/p&gt;
&lt;p&gt;Concretely: &lt;strong&gt;use the database your hosting provider offers in the same control plane as your app.&lt;/strong&gt; Supabase Postgres, Vercel Postgres, Neon, PlanetScale, Cloud SQL — whichever is one click from where your app already runs. You will not benefit from RedDB&amp;#39;s multi-model story at this volume because you do not have time to build a system that &lt;em&gt;uses&lt;/em&gt; the multi-model story. You have time to ship features, and a managed Postgres with zero ops on your plate is what enables that.&lt;/p&gt;
&lt;p&gt;A subtler version of this rule: even if you &lt;em&gt;do&lt;/em&gt; have two shapes (you want vector search for an AI feature), if you are a two-person team, &lt;strong&gt;use Postgres + pgvector&lt;/strong&gt; until you outgrow it. It is one engine, one backup, one connection pool. The drift-window problem is real but tolerable at low write volume, and you will know when you have outgrown it because users will start complaining about stale search results.&lt;/p&gt;
&lt;h2&gt;5. Teams without ops appetite for a less-mature engine&lt;/h2&gt;
&lt;p&gt;This is the most uncomfortable item on the list, so we will be direct: &lt;strong&gt;RedDB is younger than Postgres&lt;/strong&gt;. It has fewer years of production deployments, fewer post-mortems published, fewer Stack Overflow answers for obscure error codes. The engineering is solid, the test coverage is good, the public benchmarks hold up — but maturity is a function of calendar time and number of operators, and there is no shortcut.&lt;/p&gt;
&lt;p&gt;If your organization treats database choice as a near-irreversible commitment, if your security team requires three years of public CVE history before approving an engine, if your on-call rotation does not have one engineer who is comfortable reading source code when a tool misbehaves — those are entirely reasonable constraints, and the rational choice under them is the most-deployed engine in your category. Pick Postgres. Pick the cloud-native option from your provider. Revisit RedDB in 18 months when more deployments have shaken out the edges.&lt;/p&gt;
&lt;p&gt;We would rather you make that call clear-eyed than have you adopt RedDB, hit an operational surprise, and conclude the engine is unfit when the actual mismatch was risk appetite.&lt;/p&gt;
&lt;h2&gt;6. Workloads that are really about analytics, not transactions&lt;/h2&gt;
&lt;p&gt;If the dominant query pattern is &amp;quot;scan large slices of historical data and aggregate,&amp;quot; you have an analytical workload, and the right answer is an analytical engine: &lt;strong&gt;ClickHouse, DuckDB, BigQuery, Snowflake&lt;/strong&gt;, depending on your scale and budget. Putting analytical scans on a transactional engine — any transactional engine, RedDB included — gets you a much worse cost-per-query than the dedicated tool, and you lose the columnar storage and vectorized execution that make analytical engines fast.&lt;/p&gt;
&lt;p&gt;The mixed shape (some OLTP, some analytical) is real and is one of the places RedDB is genuinely interesting, but the threshold is &amp;quot;the analytical queries are a minority that need to see fresh transactional data.&amp;quot; If your analytical queries are the majority of your workload and they can tolerate a 5-minute replication lag from a source-of-truth OLTP store, the two-engine answer (Postgres → ClickHouse via CDC) is cheaper and more performant.&lt;/p&gt;
&lt;h2&gt;7. You already have the four engines and they are working&lt;/h2&gt;
&lt;p&gt;If you are currently running Postgres + Elasticsearch + Pinecone + Redis and the team is happy, the on-call is quiet, and your latency numbers are within budget — &lt;strong&gt;do not migrate&lt;/strong&gt;. The &amp;quot;small-team unlock&amp;quot; story for RedDB (&lt;a href=&quot;/blog/multi-model-storage-tradeoffs&quot;&gt;we wrote a separate post on the ops math&lt;/a&gt;) is about teams who would otherwise &lt;em&gt;adopt&lt;/em&gt; four engines. The cost of migrating an already-working four-engine stack is almost never recovered by the operational simplification, because the migration itself burns the budget that would have funded the simplification.&lt;/p&gt;
&lt;p&gt;The rule of thumb we use internally: a migration earns its budget back if it is paying down a problem the team is &lt;em&gt;currently&lt;/em&gt; losing sleep over. If nobody is currently losing sleep, leave the stack alone and spend the engineering time on features.&lt;/p&gt;
&lt;h2&gt;8. Strict regulatory requirements naming a specific engine&lt;/h2&gt;
&lt;p&gt;A small number of regulated industries have certifications that name specific database engines, or specific cloud-managed offerings, in the compliance scope. If your auditor&amp;#39;s checklist says &amp;quot;Postgres with FedRAMP-authorized hosting&amp;quot; and RedDB is not on that list, the cost of getting it added to the authorized boundary exceeds the value, period. Use the named engine.&lt;/p&gt;
&lt;p&gt;This is not a knock on the engine — it is a recognition that compliance scopes are slow-moving by design, and a database choice is not the right place to spend novelty budget when an auditor is in the loop.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The shapes where RedDB does earn its keep&lt;/h2&gt;
&lt;p&gt;For balance, here is the inverse list — shapes where the multi-model story actually pays off:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RAG workloads&lt;/strong&gt; where the embedding must stay consistent with the source document on every write (the drift-window problem). &lt;a href=&quot;/blog/rag-without-second-database&quot;&gt;Long-form version of the argument here.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agent memory layers&lt;/strong&gt; where episodic events, semantic facts, and procedural caches are written and read together in the same request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Search-over-mutable-data products&lt;/strong&gt; where the search index lagging the source by even seconds is a user-visible bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-tenant SaaS with per-tenant isolation&lt;/strong&gt; where running N copies of four engines is cost-prohibitive but per-tenant rows in one engine is tractable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small teams (2–5 engineers) who would otherwise stand up four engines&lt;/strong&gt; — the ops cost math flips here, not at high scale.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are not in one of those shapes, the honest answer is the boring one. Save the migration for a problem that needs it.&lt;/p&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;Stay on what you have if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One storage shape, no vectors, no documents → &lt;strong&gt;Postgres&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Embedded / single-process → &lt;strong&gt;SQLite&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Two shapes that never need to be consistent → &lt;strong&gt;two databases and an event bus&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Two-person team, tiny write volume → &lt;strong&gt;whatever your hosting provider runs&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Risk-averse org with hard maturity requirements → &lt;strong&gt;Postgres&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Analytics-dominant workload → &lt;strong&gt;ClickHouse / DuckDB / BigQuery&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Four engines that are working and quiet → &lt;strong&gt;leave them alone&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Auditor named a specific engine → &lt;strong&gt;use the named engine&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We will be here when one of the inverse shapes shows up.&lt;/p&gt;
</content>
  </entry>
</feed>
