Browse Source

spec+plan: v2.0 storage engine architecture + execution plan (phase C T2)

Architecture decision record:
  docs/superpowers/specs/2026-05-15-v2.0-storage-engine-architecture.md
  Operator-approved 2026-05-15 with all six recommendations:
    1. buffer_pool_size_mb config knob, 50% RAM default
    2. v1.x feature parity only (no new indexes in v2.0)
    3. Auto-detect v1.x dir on first boot, auto-migrate
    4. Keep v1.x WAL-streaming replication (decoupled from substrate)
    5. Vectors in separate _vectors_<collection> sub-db
    6. Same Migrate RPC, semantically equivalent
  Operator-mandated rule: every metadata field + internal sub-db name
  uses _-prefix convention (extends v1.x _id/_created_at/_vector pattern).

Execution plan:
  docs/superpowers/plans/2026-05-15-v2.0-storage-engine-phase-c-execution.md
  10 stages, ordered for clean substrate-then-integration progression:
    1. LMDB scaffolding (storage/ module + env/txn wrappers)
    2. DocumentStore interface + LmdbDocumentStore impl
    3. v1.x → v2.0 migration tool with auto-detect on boot
    4. gRPC integration + delete v1.x MemoryStore/PersistenceManager
    5. Vector + history + files + views sub-db wiring
    6. Replication preservation (WAL-streaming unchanged)
    7. Migrations RPC ported
    8. Eviction + recovery-mode cleanup
    9. Perf gates: multi-producer write, RSS-under-cgroup, crash injection
    10. v2.0.0 release

Plan ready for subagent dispatch per superpowers:subagent-driven-development.
fszontagh 2 tháng trước cách đây
mục cha
commit
3a2e4ce4f4

+ 391 - 0
docs/superpowers/plans/2026-05-15-v2.0-storage-engine-phase-c-execution.md

@@ -0,0 +1,391 @@
+# v2.0 Storage Engine — Phase C Execution Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Each task ends with a buildable, testable, committable state.
+>
+> **Authoritative companions** (read these first):
+> - Architecture: `docs/superpowers/specs/2026-05-15-v2.0-storage-engine-architecture.md`
+> - Spike: `docs/superpowers/spikes/2026-05-15-storage-engine-spike.md`
+> - Phase C roadmap stub: `docs/superpowers/plans/2026-05-15-v2.0-storage-engine-phase-c.md`
+
+**Goal:** Replace the v1.x in-memory storage (MemoryStore + persistence subsystem) with an LMDB-backed substrate while preserving every v1.x feature on the gRPC wire surface. Ship as v2.0.0.
+
+**Architecture (in one sentence):** Each user collection becomes one LMDB sub-database (`<collection>` for documents) plus optional sidecar sub-dbs (`_vectors_<collection>`, `_history_<collection>`), with `_meta` / `_collections` / `_files` / `_views` for system state. Document ops are batched into single-writer LMDB transactions; reads use ad-hoc reader txns. Replication WAL stays at the document-op level (decoupled from substrate).
+
+**Naming rule (operator-mandated):** every metadata field on documents and every internal sub-database name starts with `_`. Already enforced for `_id`, `_created_at`, `_updated_at`, `_vector`, `_event`. v2.0 inherits and extends.
+
+**Tech Stack:** C++20, LMDB 0.9.31 (libsmartbotic-db-* runtime dep `liblmdb0`, build dep `liblmdb-dev`), yyjson (Phase A), doc_binary (Phase B), nlohmann::json, spdlog, jemalloc, LZ4, gRPC/Protobuf.
+
+---
+
+## Plan structure
+
+The work is grouped into **stages**. Each stage produces a buildable, committable state. Stages are ordered so the v1.x → v2.0 boundary stays clean: build the new substrate, prove it independently, then integrate.
+
+1. **Stage 1 — LMDB substrate scaffolding.** New `service/src/storage/` directory with env + transaction wrappers and unit tests. Production binary still uses v1.x MemoryStore; new code is dead until Stage 4.
+2. **Stage 2 — Document storage layer.** `DocumentStore` interface + LMDB implementation. Tested standalone.
+3. **Stage 3 — Migration tool.** `--migrate-from-v1` mode that reads v1.x snapshots + WAL and populates a fresh LMDB env. Tested with synthetic v1.x data dirs.
+4. **Stage 4 — gRPC integration.** Swap handlers to use `DocumentStore`. Eviction machinery, recovery modes, snapshot/WAL files all deleted in this stage.
+5. **Stage 5 — Vector + history + files + views.** Sub-db wiring for the remaining v1.x features.
+6. **Stage 6 — Replication preservation.** Validate WAL streaming still works.
+7. **Stage 7 — Migrations RPC.** Update the migration runner to call the new storage layer.
+8. **Stage 8 — Eviction + recovery cleanup.** Confirm-and-delete the v1.x machinery declared dead in Stage 4.
+9. **Stage 9 — Performance gates + soak.** Multi-producer benchmark, RSS-under-cgroup, crash injection.
+10. **Stage 10 — Release.** VERSION bump, CLAUDE.md, deb build, operator-gated rollout.
+
+---
+
+## Stage 1 — LMDB substrate scaffolding
+
+**Files to create:**
+- `service/src/storage/lmdb_env.hpp` — RAII wrapper over `MDB_env*` with `mdb_env_create` / `mdb_env_set_mapsize` / `mdb_env_set_maxdbs` / `mdb_env_open` baked in. Exposes `begin_write()` / `begin_read()` returning RAII `WriteTxn` / `ReadTxn`.
+- `service/src/storage/lmdb_env.cpp` — implementation; the only file that includes `<lmdb.h>`.
+- `service/src/storage/lmdb_txn.hpp` — `WriteTxn` / `ReadTxn` types; commit-on-destruction with explicit `abort()` opt-out.
+- `service/src/storage/lmdb_dbi.hpp` — opens a named sub-database within a txn; convenience methods `put` / `get` / `del` / `cursor`.
+- `tests/test_lmdb_env.cpp` — tests env create/open, mapsize enforcement, multiple sub-db open, basic put/get round-trip, txn abort vs commit semantics.
+
+**Files to modify:**
+- `service/CMakeLists.txt` — add `pkg_check_modules(LMDB REQUIRED lmdb)` (or find_path/find_library fallback), append `${LMDB_LIBRARIES}` + `${LMDB_INCLUDE_DIRS}` to the service target. Add `src/storage/lmdb_env.cpp` to `DATABASE_SERVICE_SOURCES`.
+- `tests/CMakeLists.txt` — register `test_lmdb_env` mirroring `test_doc_binary` pattern (file-scope LMDB resolution near the top, target wiring, CTest registration).
+- `packaging/Dockerfile.base` — append `liblmdb-dev \` to apt-get install; bump base-image stamp `v4` → `v5`.
+- `packaging/deb/templates/control.server` — append `, liblmdb0` to `Depends:`.
+
+### Task 1.1 — Build deps + env stub
+
+- [ ] Add `liblmdb-dev` to `Dockerfile.base`, bump stamp to `v5`.
+- [ ] Add `, liblmdb0` to `control.server` Depends.
+- [ ] Wire LMDB into `service/CMakeLists.txt` via `pkg_check_modules` or `find_path`/`find_library` (LMDB doesn't ship pkg-config on Debian 13).
+- [ ] Build verifies (link succeeds, no source uses LMDB yet).
+- [ ] Commit: `build: add lmdb build + runtime dependency (v2.0 scaffolding)`.
+
+### Task 1.2 — `LmdbEnv` RAII wrapper
+
+- [ ] Write `tests/test_lmdb_env.cpp` with failing tests for env creation, mapsize ≥ on-disk-size enforcement, opening multiple sub-dbs.
+- [ ] Implement `service/src/storage/lmdb_env.{hpp,cpp}` to pass tests.
+- [ ] Build and run tests: GREEN.
+- [ ] Commit: `feat(storage): LmdbEnv RAII wrapper`.
+
+### Task 1.3 — Transaction wrappers
+
+- [ ] Extend `tests/test_lmdb_env.cpp` with write/read txn round-trip tests (commit, abort, reader-during-write).
+- [ ] Implement `service/src/storage/lmdb_txn.hpp` with RAII commit-on-success / abort-on-exception.
+- [ ] Implement `service/src/storage/lmdb_dbi.hpp` with `put`/`get`/`del`/cursor primitives.
+- [ ] Build and test: GREEN.
+- [ ] Commit: `feat(storage): WriteTxn + ReadTxn + LmdbDbi primitives`.
+
+**Stage 1 done state:** new `storage/` module exists, has unit tests, doesn't yet touch the service binary's hot path. The whole stage is a "library addition" PR-shape — fully reviewable in isolation.
+
+---
+
+## Stage 2 — Document storage layer
+
+**Files to create:**
+- `service/src/storage/document_store.hpp` — `DocumentStore` interface: `put(collection, id, doc)`, `get(collection, id)`, `del(collection, id)`, `scan(collection, filter, sort, limit, offset)`, `count(collection)`.
+- `service/src/storage/document_store_lmdb.hpp/.cpp` — `LmdbDocumentStore` implementation. Wraps the v1.x `MemoryStore` interface enough to be a drop-in once Stage 4 lands.
+- `tests/test_document_store.cpp` — exercises the interface against synthetic docs. Includes filter/sort/projection regression tests against v1.x expected outputs.
+
+**Files to modify:**
+- `service/src/storage/document_store_lmdb.cpp` — uses `doc_binary::to_json_text` for encode, `parse_to_nlohmann` for decode (preserves Phase A/B work).
+
+### Task 2.1 — `DocumentStore` interface + tests
+
+- [ ] Write `tests/test_document_store.cpp` with failing tests: put/get/del round-trip, scan with filter on `_id`, scan with empty result, count, collection isolation (writes to collection A don't appear in collection B).
+- [ ] Define `DocumentStore` interface (pure-virtual or template).
+- [ ] Build verifies the interface.
+- [ ] Commit: `feat(storage): DocumentStore interface + failing tests`.
+
+### Task 2.2 — LMDB-backed implementation
+
+- [ ] Implement `LmdbDocumentStore` against `LmdbEnv`. One sub-db per collection (lazy-created on first write).
+- [ ] Tests pass.
+- [ ] Commit: `feat(storage): LmdbDocumentStore implementation`.
+
+### Task 2.3 — Filter + sort + projection parity tests
+
+- [ ] Port the existing v1.x filter/sort/projection coverage from `test_views.cpp` / `test_timestamp_precision.cpp` to exercise `LmdbDocumentStore`. Same input → same output as v1.x.
+- [ ] Tests pass.
+- [ ] Commit: `test(storage): port filter+sort+projection coverage to DocumentStore`.
+
+**Stage 2 done state:** `LmdbDocumentStore` is a working drop-in for `MemoryStore`'s document interface. Still not wired into the gRPC handlers; production binary still uses v1.x storage.
+
+---
+
+## Stage 3 — Migration tool
+
+**Files to create:**
+- `service/src/storage/migrate_v1_to_v2.hpp/.cpp` — one-shot migration: read v1.x `snapshots/` (latest), replay v1.x `wal/` after the snapshot, populate fresh LMDB env. Writes `_meta.schema_version = 2` on success.
+- `tests/test_migrate_v1_to_v2.cpp` — generates a synthetic v1.x data dir using the v1.x snapshot/WAL writers, runs migration, verifies round-trip equivalence (every doc in v1.x dir appears in v2.0 env with same `_id`, same content, same metadata).
+- `tests/load_test/migration_v1_to_v2.cpp` — realistic-scale: populate a v1.x dir with 1M docs (`load_test_crash_insert`-style), migrate, verify counts and a random sample.
+
+**Files to modify:**
+- `service/src/main.cpp` — recognize `--migrate-from-v1` CLI flag (and a `--no-auto-migrate` opt-out per the ADR). On startup, detect `snapshots/` + missing `_meta.schema_version` → run migration before opening for gRPC.
+
+### Task 3.1 — Migration core
+
+- [ ] Write `tests/test_migrate_v1_to_v2.cpp` with failing tests: empty v1.x dir → empty v2.0 env; one collection with N docs → same N docs in v2.0; deleted-in-WAL docs are absent in v2.0.
+- [ ] Implement `migrate_v1_to_v2`: read snapshot (via existing `service/src/persistence/snapshot.cpp` loader), then replay WAL (via existing `wal.cpp` reader), accumulate into a `LmdbDocumentStore`.
+- [ ] Tests pass.
+- [ ] Commit: `feat(storage): v1.x → v2.0 migration core`.
+
+### Task 3.2 — Auto-detect + CLI flags
+
+- [ ] Add `--migrate-from-v1` and `--no-auto-migrate` flags to `main.cpp` argv parsing.
+- [ ] Wire auto-detect: if `snapshots/` dir exists and `_meta.schema_version` is unset or < 2, run migration (unless `--no-auto-migrate`).
+- [ ] `sd_notify(STATUS="Migrating from v1.x: <docs_migrated>/<docs_total>")` updates during migration.
+- [ ] Build verifies; integration test in `tests/load_test/migration_v1_to_v2.cpp` writes a real v1.x data dir, boots a v2.0 binary, observes migration completing.
+- [ ] Commit: `feat: auto-migrate v1.x data dirs on boot`.
+
+### Task 3.3 — Vector migration
+
+- [ ] Extend the migration to extract `_vector` fields during the v1→v2 copy and write to `_vectors_<collection>` sub-db. JSON doc retains `_vector` for wire-format round-trip.
+- [ ] Test: a v1.x dir with vector collections migrates with all vectors readable from the new sub-db.
+- [ ] Commit: `feat(storage): migrate vector fields to _vectors_<collection> sub-db`.
+
+### Task 3.4 — Migration safety nets
+
+- [ ] On migration failure, do NOT overwrite v1.x snapshots/wal. Service refuses to start with operator-actionable error pointing at the v1.9.5 backup at `/var/backups/smartbotic-database/pre-upgrade-*-from-1.x/`.
+- [ ] On migration success, write `_meta.schema_version = 2`; subsequent boots see this and skip migration.
+- [ ] On migration in-progress (process killed mid-way), `_meta.schema_version` remains unset and the next boot reruns migration (idempotent — clears the partial LMDB env first).
+- [ ] Crash-injection test: kill -9 during migration, restart, verify clean re-migration.
+- [ ] Commit: `feat(storage): migration safety nets (failure, restart, idempotence)`.
+
+**Stage 3 done state:** v1.x data dirs migrate cleanly to v2.0 LMDB envs. Production binary still uses v1.x for live serving; v2.0 substrate is parallel.
+
+---
+
+## Stage 4 — gRPC integration (the big swap)
+
+**Files to modify:**
+- `service/src/database_service.cpp` — replace `std::unique_ptr<MemoryStore> store_` with `std::unique_ptr<DocumentStore> store_`. Boot path opens `LmdbEnv` instead of starting `PersistenceManager`.
+- `service/src/database_grpc_impl.cpp` — every handler that called `store_->insert(...)` / `update(...)` / `find(...)` continues to do so against the new interface. Most handler bodies don't change shape because Stage 2 made `LmdbDocumentStore` match `MemoryStore`'s public interface.
+- `service/src/main.cpp` — boot order: parse args → detect+migrate (Stage 3) → open LMDB env → construct `LmdbDocumentStore` → start gRPC.
+
+**Files to delete (in this stage; verify Stage 8 nothing depends on them):**
+- `service/src/memory_store.cpp/.hpp` — replaced.
+- `service/src/persistence/persistence_manager.cpp/.hpp` — replaced.
+- `service/src/persistence/snapshot.cpp/.hpp` — gone (LMDB has its own checkpoint).
+- `service/src/persistence/wal.cpp/.hpp` — gone (LMDB has its own journal). **But** the v1.x WAL reader code path is needed by Stage 3's migration; either keep it under `service/src/storage/v1_compat/` or inline the reader logic into `migrate_v1_to_v2.cpp`.
+
+### Task 4.1 — Swap `store_` type
+
+- [ ] Replace `MemoryStore` references with `DocumentStore` in `database_service.cpp`.
+- [ ] Build will fail at every call site that uses MemoryStore-specific methods not in the DocumentStore interface — track these as Task 4.2's inventory.
+- [ ] Commit: `feat: swap database_service to DocumentStore interface (WIP, build will break)`.
+
+### Task 4.2 — Migrate handler call sites
+
+- [ ] For each handler in `database_grpc_impl.cpp`, port to the new interface. Most are trivial (interface preserved). Edge cases: anything that touched eviction, WAL fallback for evicted docs, or recovery-mode logic — those features are gone in v2.0 (see ADR).
+- [ ] Tests in `tests/load_test/` (load_test_mixed, load_test_integrity, etc.) all pass.
+- [ ] Commit: `feat: migrate gRPC handlers to LmdbDocumentStore`.
+
+### Task 4.3 — Delete v1.x storage code
+
+- [ ] Remove `service/src/memory_store.{cpp,hpp}`, `persistence/persistence_manager.*`, `persistence/snapshot.*`, `persistence/wal.*` (or keep wal.cpp's reader under `storage/v1_compat/` for the migration tool).
+- [ ] Remove their entries from `service/CMakeLists.txt`'s `DATABASE_SERVICE_SOURCES`.
+- [ ] Remove eviction-specific test files (`test_eviction.cpp` — coverage subsumed by `test_lmdb_env` reader-cap tests if needed).
+- [ ] Build cleanly; all remaining tests pass.
+- [ ] Commit: `chore: remove v1.x MemoryStore + persistence (subsumed by LMDB substrate)`.
+
+**Stage 4 done state:** the production binary now uses LMDB end-to-end. v1.x storage code is gone. gRPC wire surface unchanged.
+
+---
+
+## Stage 5 — Vector, history, files, views
+
+### Task 5.1 — Vector sub-db integration
+
+- [ ] On Insert/Update/Patch with `_vector` field present and collection has `vector_dimension > 0`: write to `_vectors_<collection>` in the same txn as the doc.
+- [ ] On Delete: also delete from `_vectors_<collection>`.
+- [ ] `SimilaritySearch` handler opens `_vectors_<collection>` read txn, cursor-iterates, runs SIMD cosine (AVX2/SSE4.1 paths from v1.x preserved unchanged), returns top-N.
+- [ ] Test: `test_vector_storage.cpp` migrates to the new layout; all existing assertions pass.
+- [ ] Commit: `feat(vector): _vectors_<collection> sub-db wiring + SIMD search`.
+
+### Task 5.2 — History store rewrite
+
+- [ ] `_history_<collection>` sub-db keyed by `<doc_id>:<version>` storing JSON-text `DocumentVersion`. Only created when collection's `maxVersions > 0`.
+- [ ] On Update/Delete: emit history entry inside the same txn.
+- [ ] `GetVersion` / `ListVersions` handlers read from the sub-db.
+- [ ] Old `service/src/persistence/history_store.cpp` is deleted; its tests port to LMDB.
+- [ ] Commit: `feat(history): _history_<collection> sub-db replaces .hlog files`.
+
+### Task 5.3 — File store metadata in `_files`
+
+- [ ] `_files` sub-db stores file metadata (id → metadata + blob_ref); blob bytes stay in `files/` (unchanged from v1.x).
+- [ ] `UploadFile` / `DownloadFile` / file-CRUD handlers updated.
+- [ ] Existing file-store tests pass.
+- [ ] Commit: `feat(files): _files sub-db for metadata; blobs unchanged`.
+
+### Task 5.4 — Views in `_views`
+
+- [ ] `_views` sub-db stores view definitions (name → JSON-text View). Existing view CRUD handlers ported.
+- [ ] `test_views.cpp` ports to the new layout.
+- [ ] Commit: `feat(views): _views sub-db replaces in-memory view manager state`.
+
+**Stage 5 done state:** every v1.x feature on the gRPC surface works against LMDB. No functional regressions.
+
+---
+
+## Stage 6 — Replication preservation
+
+The ADR keeps WAL-streaming replication; only the local persistence layer changed. This stage validates that decoupling.
+
+### Task 6.1 — Replication WAL = LMDB-emitted record stream
+
+- [ ] Define a per-write WAL record stream — for each LMDB write txn, emit a sequence of document-op records (PUT/DELETE/VEC_PUT/VEC_DELETE) into the replication WAL ring buffer. This is the *replication* WAL — separate from LMDB's internal journal.
+- [ ] Followers consume the same record format as v1.x. Apply path unchanged.
+- [ ] `load_test_replication` passes.
+- [ ] Commit: `feat(replication): emit document-op records into replication WAL on LMDB commit`.
+
+### Task 6.2 — Replica boot from leader stream
+
+- [ ] Replica boot: open empty LMDB env, request snapshot+WAL stream from leader, apply records into LMDB.
+- [ ] `load_test_replica_eviction` passes — eviction no longer exists in v2.0, but the test exercises the boot path and apply-during-pressure scenario; rename / adapt as needed.
+- [ ] Commit: `feat(replication): replica bootstrap into LMDB`.
+
+**Stage 6 done state:** leader + follower run on LMDB; same wire protocol as v1.x.
+
+---
+
+## Stage 7 — Migrations RPC
+
+### Task 7.1 — Migration runner on LMDB
+
+- [ ] `service/src/migrations/migration_runner.cpp` ported to call `DocumentStore` methods instead of `MemoryStore`.
+- [ ] JSON migration files on disk (shadowman, callerai) unchanged.
+- [ ] Existing migration tests pass.
+- [ ] Commit: `feat(migrations): port migration runner to DocumentStore`.
+
+### Task 7.2 — `_meta.schema_version` migration log
+
+- [ ] Track applied migrations under `_meta.migrations_applied` (JSON-text array of migration IDs).
+- [ ] On boot, run pending migrations idempotently (same semantic as v1.x's "applied migrations" set, just persisted in `_meta`).
+- [ ] Commit: `feat(migrations): persist applied-migrations log in _meta sub-db`.
+
+**Stage 7 done state:** migrations RPC works against LMDB; existing consumer scripts unaffected.
+
+---
+
+## Stage 8 — Eviction + recovery cleanup
+
+Mostly already deleted in Stage 4. This stage is the **audit**: confirm nothing lingers, deprecation warnings, doc updates.
+
+### Task 8.1 — Eviction config deprecation
+
+- [ ] Remove `max_memory_mb`, `eviction_chunk_size`, `eviction_chunk_pause_ms`, `hot_write_floor_ms`, `memory_priority` from config schema. Emit a deprecation log line for one release if the operator's config still sets them.
+- [ ] Rename `max_memory_mb` → `buffer_pool_size_mb` in config schema with a back-compat shim that maps the old key.
+- [ ] Commit: `chore(config): deprecate eviction knobs; rename max_memory_mb to buffer_pool_size_mb`.
+
+### Task 8.2 — Recovery mode deprecation
+
+- [ ] Remove `recovery.mode` config handling. Emit a deprecation log line if set. Remove the `recovery.lock` codepath entirely.
+- [ ] `--recovery-mode` and `--force-readwrite` CLI flags become no-ops with a "deprecated, ignored" warning.
+- [ ] Commit: `chore(recovery): remove v1.x tiered-recovery modes; LMDB recovery is built-in`.
+
+### Task 8.3 — Memory-pressure events
+
+- [ ] Decide whether `MEMORY_PRESSURE_HIGH` / `MEMORY_EVICTION_BURST` events stay on the event bus. Probably yes — the substrate change doesn't eliminate the *concept* of pressure, just changes who manages it (OS page cache now). Wire them to LMDB cache miss-rate or to RSS-near-cgroup-limit signals.
+- [ ] Commit: `feat(events): MEMORY_PRESSURE_HIGH wired to LMDB cache pressure signal`.
+
+**Stage 8 done state:** all v1.x machinery declared dead in the ADR is gone or formally deprecated.
+
+---
+
+## Stage 9 — Performance gates + soak
+
+### Task 9.1 — Multi-producer write benchmark
+
+- [ ] `tests/bench_v2_write.cpp` runs N producer threads each emitting K puts/sec sustained. Measure p50/p99 commit latency, single-writer slot contention.
+- [ ] Gate: sustained ≥ Zoe-projected write rate (5k docs/sec) with p99 commit < 50 ms.
+- [ ] Commit: `test(bench): multi-producer write benchmark for v2.0`.
+
+### Task 9.2 — RSS under cgroup constraint
+
+- [ ] `tests/bench_v2_rss.cpp` loads a populated env into a process running under a 256 MB / 512 MB / 1 GB cgroup, exercises a hot/cold access pattern, reports RSS plateau.
+- [ ] Gate: RSS plateaus at cgroup limit (not OOM, not unbounded).
+- [ ] Commit: `test(bench): RSS-under-cgroup benchmark for v2.0`.
+
+### Task 9.3 — Crash injection
+
+- [ ] `tests/load_test/crash_injection_v2.cpp` writes N docs, kills the process mid-commit, restarts, verifies LMDB recovery is clean and no doc loss occurred (modulo the last uncommitted txn).
+- [ ] Gate: zero doc loss from any committed txn; LMDB recovery completes in < 1s on Zoe-shape data.
+- [ ] Commit: `test(crash): v2.0 crash-injection coverage`.
+
+### Task 9.4 — 24-hour soak
+
+- [ ] Real-world workload simulation against a Zoe-shape copy of production data. Sustained gRPC traffic for 24h. Watch RSS, write tput, error counts.
+- [ ] Gate: zero crashes, RSS plateau < cgroup limit, sustained tput ≥ v1.11 baseline.
+- [ ] Operator-supervised, not subagent-runnable.
+- [ ] Commit (post-soak): `test(soak): v2.0 24h soak baseline captured`.
+
+**Stage 9 done state:** v2.0 has measured numbers backing the ADR's claims. Risks (single-writer ceiling, bounded RSS) are validated or escalated.
+
+---
+
+## Stage 10 — Release
+
+### Task 10.1 — VERSION + CLAUDE.md
+
+- [ ] Bump `VERSION` from `1.11.0` to `2.0.0`.
+- [ ] Add CLAUDE.md Key Features bullets:
+  - LMDB-backed storage substrate
+  - Auto-migrate v1.x data dirs
+  - Removed: in-memory MemoryStore, snapshot files, eviction machinery, tiered recovery modes
+  - Operator runbook for `buffer_pool_size_mb` resize, hot-copy via `mdb_env_copy2`, repair procedures
+- [ ] Commit: `release(v2.0.0): LMDB storage substrate (phase C)`.
+
+### Task 10.2 — deb build
+
+- [ ] `./packaging/build.sh --local` produces v2.0.0 debs.
+- [ ] Verify `liblmdb0` in `Depends:` and `ldd` shows `liblmdb.so.0` linked.
+- [ ] Verify the deb upgrade path: install v1.11.0 deb, populate a data dir, install v2.0.0 deb, observe auto-migration on first boot.
+- [ ] Commit: (none — packaging changes already in Stage 1).
+
+### Task 10.3 — Operator-gated rollout
+
+- [ ] Push branch.
+- [ ] Operator decision on `./packaging/build.sh --sync --suite trixie`.
+- [ ] Operator-supervised Zoe upgrade (v1.11.0 → v2.0.0); v1.9.5 backup-before-upgrade fires.
+- [ ] Smoke: gRPC works, data is intact, replication healthy, RSS bounded.
+
+---
+
+## Out of scope (deferred to v2.1+)
+
+- Secondary user-defined indexes.
+- Online schema evolution (add column without rewriting docs).
+- Distributed clustering beyond v1.x's leader+follower.
+- LMDB writer-pool sharding (single-writer for v2.0).
+- Adaptive `buffer_pool_size_mb` auto-tune.
+- New gRPC RPCs.
+- Vector index variants (IVF, HNSW). v2.0 keeps the v1.x linear-scan SIMD path.
+
+---
+
+## Success criteria
+
+1. Every `tests/test_*.cpp` and `tests/load_test/*` from v1.11 passes against the v2.0 binary (after the eviction-specific tests are removed/adapted per Stage 4 / Stage 8).
+2. A v1.11.0 data dir auto-migrates to v2.0.0 on first boot with no manual operator action (modulo `--no-auto-migrate` opt-out).
+3. Multi-producer write benchmark sustains ≥ 5k docs/sec with p99 commit < 50 ms.
+4. RSS under a 512 MB cgroup plateaus below the limit on a 5+ GB dataset.
+5. 24h soak completes with zero crashes and RSS bounded.
+6. v1.9.5 backup-before-upgrade still fires on v1.x → v2.0 upgrade (verified in throwaway VM).
+
+---
+
+## Open questions retired during implementation
+
+These were called out in the ADR's "What this ADR does NOT cover" section. Track resolutions in their own commits:
+
+- Exact LMDB env flag set (Task 1.2).
+- Write-batch sizing default (Stage 4 + tuned in Stage 9).
+- Writer queue ordering (Stage 4).
+- Operator runbook (Task 10.1).
+
+---
+
+## Risks (in priority order)
+
+1. **Single-writer ceiling under sustained Zoe write rate.** Mitigated by Task 9.1's benchmark gate; if exceeded, fall back to RocksDB candidate (would need its own spike and partial re-do of Stage 1-2).
+2. **Migration tool latency on large data dirs.** Operator-visible — `sd_notify(STATUS=...)` reports progress. If migration of Zoe-scale data exceeds 10 minutes, gate-block and consider parallelising.
+3. **LMDB mapsize set too low.** Operator-actionable — config knob with safe default (50% RAM). Document the resize procedure in CLAUDE.md.
+4. **Vector layout migration corner cases.** Mitigated by Task 3.3's vector-migration tests.
+5. **Replication WAL format change** (if needed). Followers built against v2.0 read the v2.0 format; legacy v1.x followers cannot follow a v2.0 leader. Operator must upgrade all replicas in lockstep — already implicit in the "no intermediate-release deployment" stance.

+ 206 - 0
docs/superpowers/specs/2026-05-15-v2.0-storage-engine-architecture.md

@@ -0,0 +1,206 @@
+# v2.0 Storage Engine — Architecture Decision Record
+
+**Status:** Decided 2026-05-15 by operator (fszontagh) following the LMDB spike (`docs/superpowers/spikes/2026-05-15-storage-engine-spike.md`).
+
+**Scope statement:** v2.0 = v1.x feature surface ported to LMDB substrate. Smallest viable architectural change; biggest possible substrate change. New features beyond v1.x parity are explicitly deferred to v2.1+ unless they fall out for free from the substrate switch.
+
+---
+
+## Naming convention (load-bearing)
+
+**Project convention:** all metadata fields on documents are prefixed with `_`. Examples already in v1.x: `_id`, `_created_at`, `_updated_at`, `_vector`, `_event`, `_event_*`. v2.0 inherits and extends this convention:
+
+- New v2.0 metadata fields (if any) start with `_`. No exceptions.
+- Internal LMDB sub-database names start with `_` (e.g., `_meta`, `_collections`, `_vectors`) so they can never collide with a user-named collection.
+- Any new schema-migration op-codes that touch document fields enforce the `_` prefix on metadata-style fields.
+
+Anywhere in this document where a field name appears, assume the `_`-prefix rule. The execution plan must call out specific examples for reviewers.
+
+---
+
+## The six decisions (operator-approved 2026-05-15)
+
+### 1. Buffer-pool sizing policy
+
+**Decision:** Config knob `buffer_pool_size_mb` in `config.json` defaulting to 50% of detected system RAM. Maps to `mdb_env_set_mapsize`. Static at process start — resize requires restart and a brief read quiesce.
+
+**Implementation notes:**
+- `service/src/config/config_loader.cpp` adds the field; default = `sysconf(_SC_PHYS_PAGES) * page_size / 2 / (1024*1024)`.
+- `service/src/persistence/...` (or its v2.0 replacement) calls `mdb_env_set_mapsize(env, buffer_pool_size_mb * 1024 * 1024)` before `mdb_env_open`.
+- If config-set mapsize ≤ current on-disk size, fail-fast at startup with a clear error pointing the operator at the config key.
+- `GetMemoryStats` RPC adds `buffer_pool_used_mb` and `buffer_pool_capacity_mb` to its response.
+
+**Rejected alternatives:**
+- Adaptive auto-tune to system memory pressure: meaningful complexity for marginal gain; LMDB's mmap already gets the OS page cache for free.
+
+### 2. Index strategy — v1.x parity only
+
+**Decision:** v2.0 ships two indexes:
+- **Primary index** — `id → doc JSON-text`, the LMDB main database for each collection.
+- **Vector index** — separate per-collection LMDB sub-database `_vectors_<collection>` storing `_id → [f32...]` raw bytes (SIMD-friendly layout for `SimilaritySearch`).
+
+Secondary user-defined indexes are **out of scope** for v2.0; deferred to v2.1 (or later).
+
+**Implementation notes:**
+- Each user collection gets two LMDB sub-databases: `<collection>` (primary) and `_vectors_<collection>` (vector, only created if `vector_dimension > 0`).
+- Internal sub-databases: `_meta` (env-level metadata, schema version, settings), `_collections` (per-collection options).
+- The `Find` RPC continues to do full-scan filtering on non-indexed fields — same semantic as v1.x.
+- `SimilaritySearch` reads vectors from the `_vectors_<collection>` sub-db sequentially (LMDB cursor scan), runs the existing SIMD-accelerated cosine similarity (AVX2/SSE4.1 paths from v1.x are preserved), returns top-N.
+
+**Rejected alternatives:**
+- Secondary indexes in v2.0: scope creep; the operator can add user-named indexes once the substrate is stable.
+
+### 3. Migration UX — auto-detect on first boot
+
+**Decision:** Auto-detect on first boot. If `/var/lib/smartbotic-database/snapshots/` exists AND no `_meta` sub-db is present in the LMDB env, run `--migrate-from-v1` automatically with loud log + `sd_notify(STATUS="Migrating from v1.x")`. Operator can opt out with `--no-auto-migrate` and trigger manually.
+
+**Implementation notes:**
+- Migration is one-shot, idempotent (writes a `_meta.schema_version = 2` marker on success; later boots see the marker and skip).
+- Failure mode: migration failure leaves both data dirs intact (v1.x snapshots/wal/history untouched, LMDB env partially populated). The service refuses to start with a clear error pointing at the v1.9.5 backup at `/var/backups/smartbotic-database/pre-upgrade-*-from-1.x/`.
+- During migration the service is not listening on gRPC (gRPC starts only after `_meta.schema_version = 2` is written). `sd_notify(STATUS=...)` reports progress as a `docs_migrated / docs_total` count.
+- Test infrastructure: `tests/load_test/migration_v1_to_v2.cpp` — populates a v1.x data dir, boots the v2.0 binary, verifies round-trip equivalence.
+
+**Rejected alternatives:**
+- Operator-triggered only: extra step prone to mis-sequencing on `apt upgrade`. Auto-on-boot is what consumers (shadowman, callerai) expect.
+
+### 4. Replication model — keep v1.x WAL-streaming
+
+**Decision:** v1.x WAL-streaming replication is preserved. The WAL records continue to describe **document operations** (`PUT(id, doc)`, `DELETE(id)`, etc.), not LMDB page-level changes. Followers apply via the same `apply_replicated_entry` code path — only the local persistence layer (the substrate) changes.
+
+**Implementation notes:**
+- The new WAL stays at the document level. Format may change to accommodate v2.0 semantics (e.g., per-collection sub-db reference) but the **shape** stays the same — records describe what changed, not how it was stored.
+- v1.x WAL-format records continue to be readable by v2.0 for the migration phase, then v2.0 emits the new format going forward.
+- The replication apply path (`service/src/database_service.cpp`) is unchanged.
+- The leader→follower wire protocol is unchanged; followers see the same gRPC stream.
+
+**Rejected alternatives:**
+- Page-level replication (Postgres-physical-replication-style): much bigger refactor, ties followers to the leader's substrate version, no clear win at our scale.
+
+### 5. Vector storage — separate LMDB sub-database
+
+**Decision:** Per-collection vectors live in a separate LMDB sub-database named `_vectors_<collection>` storing `_id → [f32...]` raw bytes. JSON doc retains `_vector` field for round-trip JSON compatibility (consumers serialise/deserialise vectors as JSON arrays as today), but the index reads vectors from the sub-db for SIMD-friendly memory layout.
+
+**Implementation notes:**
+- On `Insert` / `Update` / `PatchDocument`: if collection has `vector_dimension > 0` AND doc has `_vector` field, write the float array to `_vectors_<collection>` keyed by `_id`. Always inside the same LMDB txn as the doc write.
+- On `Delete`: also delete from `_vectors_<collection>`.
+- `SimilaritySearch` opens a read txn on `_vectors_<collection>`, cursor-iterates, runs SIMD cosine similarity over each batch, returns top-N.
+- Vector dimension is enforced at write time per existing v1.x logic (`vector_dimension` collection option, immutable after creation).
+- WAL emits one record for the doc write and one record for the vector write — same atomic semantic as v1.x.
+
+**Rejected alternatives:**
+- Inline JSON storage (current spike model): simpler write path but loses SIMD-friendly memory layout, makes similarity search read the JSON doc + decode the array per comparison.
+
+### 6. Schema / index migrations — same RPC, semantically equivalent
+
+**Decision:** The `Migrate` RPC continues. v1.x migration op-codes (`create_collection`, `add_field`, `rename_field`, etc.) are preserved with v2.0-aware implementations. New op-code `create_vector_index` if needed (currently subsumed by `create_collection` with `vector_dimension > 0`).
+
+**Implementation notes:**
+- `service/src/migrations/migration_runner.cpp` is updated to call the new storage layer instead of the v1.x `MemoryStore`/`PersistenceManager`. Same JSON file format on disk.
+- Shadowman and callerai migration directories (`/opt/shadowman/share/shadowman/migrations/json`, `/etc/callerai/migrations`) keep working without changes.
+- `_meta.schema_version` field tracks migration application — preserves the existing v1.x semantic of "run pending migrations on boot, idempotent."
+
+**Rejected alternatives:**
+- New migration tooling: breaks downstream consumer scripts, more work, no benefit.
+
+---
+
+## LMDB substrate details
+
+### Environment structure
+
+```
+/var/lib/smartbotic-database/
+├── env/                        # LMDB env (the only data dir contents for v2.0)
+│   ├── data.mdb                # LMDB main file
+│   └── lock.mdb                # LMDB locking file
+├── files/                      # Blob store (unchanged from v1.x)
+└── (v1.x snapshots/wal/history removed after successful migration)
+```
+
+### Sub-database layout
+
+| Sub-db | Key | Value | Purpose |
+|--------|-----|-------|---------|
+| `_meta` | string | JSON-text | Schema version, env settings, migration application log |
+| `_collections` | collection name | JSON-text (CollectionOptions) | Per-collection config |
+| `<collection>` | document `_id` | JSON-text doc | Primary KV per user collection |
+| `_vectors_<collection>` | document `_id` | float bytes | Vector index (only if `vector_dimension > 0`) |
+| `_history_<collection>` | `_id + version` | JSON-text DocumentVersion | Version history (only if `maxVersions > 0`) |
+| `_files` | file `id` | metadata + blob_ref | File store metadata (blob bytes still in `files/`) |
+| `_views` | view name | JSON-text View | View definitions |
+
+Sub-db count grows with collections. LMDB's default `maxdbs` of 0 must be raised — set to e.g. 256 at env creation. Doc-storage callers use `mdb_dbi_open(txn, name, MDB_CREATE, &dbi)`.
+
+### Concurrency model
+
+- **Single writer, many readers** — LMDB's native mode. The v2.0 service has a writer thread (or pool with a serialising lock) and unlimited reader txns.
+- **Write batching** — gRPC handlers acquire the writer slot, accumulate work for ≤1ms or N=128 ops, commit as one txn. Same latency budget as v1.x.
+- **Reader txns are cheap** — open in handler, close on RPC completion. No reader-side lock contention.
+
+### Crash recovery
+
+- LMDB is journaled and ACID on commit boundary. On crash, the next open recovers automatically.
+- v2.0's startup path: `mdb_env_open` → check `_meta.schema_version` → if migration in progress (flag set) and incomplete, log and refuse to start with operator-actionable error.
+- No more v1.x tiered-recovery modes (`snapshot_fallback` / `wal_only` / `best_effort` / `force_empty`). These are subsumed by LMDB's built-in recovery. The `recovery.mode` config key becomes a no-op with a deprecation warning for one release, then removed.
+
+### What gets deleted from v1.x
+
+- **`MemoryStore`** — replaced by the new storage layer. The in-memory cache it provided is now LMDB's mmap (managed by the OS).
+- **Eviction machinery** (v1.7.0 chunked + pressure levels + WAL fallback + `docWalSeq_` + `hot_write_floor_ms` + `eviction_chunk_size`) — gone. `max_memory_mb` → `buffer_pool_size_mb`. `memory_priority` per collection still exists but maps to LMDB read cursor priority (or is no-op for v2.0 and revisited if needed).
+- **`PersistenceManager`** — replaced by the new storage layer's WAL + LMDB env.
+- **Snapshot writer / loader** — gone. LMDB's checkpoint is the env state.
+- **History store as a separate file tree** — moved into `_history_<collection>` sub-dbs.
+- **Recovery modes** — gone (see above).
+- **`recovery.lock`** — gone. The "operator must acknowledge non-trivial recovery" guard from v1.7.0 doesn't apply to v2.0's mode.
+
+### What stays
+
+- gRPC API surface (`proto/database.proto`) — unchanged.
+- Wire format — unchanged.
+- Client library (`client/`) — unchanged (clients see no behaviour difference).
+- yyjson + nlohmann::json + spdlog + LZ4 + jemalloc deps — preserved. yyjson is the JSON parser; nlohmann is the document AST; spdlog is logging; LZ4 keeps compressing blobs in `files/`; jemalloc keeps bounding glibc malloc fragmentation under the writer-thread churn.
+- AES-256-GCM field-level encryption — unchanged; encrypted bytes live inside the JSON doc, same as v1.x.
+- File storage (`files/`) with blob dedup — unchanged.
+- Replication WAL streaming — preserved (decision 4).
+- Migrations RPC + JSON files — preserved (decision 6).
+
+---
+
+## Memory model
+
+- **No per-process RSS goal.** RSS is now bounded by OS page cache management of the LMDB mmap. Operator caps via `buffer_pool_size_mb` (mapsize) and cgroup memory limits.
+- **Read path** — `mdb_get` returns a pointer into the mmap'd region. The doc bytes (JSON text) are decoded via `parse_to_nlohmann` (Phase A) and optionally wrapped in `doc_binary::Doc` (Phase B) for the gRPC response.
+- **Write path** — `doc_binary::to_json_text` produces the bytes that go into LMDB. Same encoding as v1.x WAL/snapshot, so the migration tool reads v1.x JSON and writes it byte-for-byte into LMDB.
+- **No more in-memory `MemoryStore`** — there's nothing to evict. LMDB's mmap + OS page cache is the cache.
+
+---
+
+## Risks called out (carry-forward from spike)
+
+1. **Single-writer ceiling.** Spike showed 40k docs/sec with single-doc commits. Batched commits should push to 100k+ but un-measured. Phase C execution plan must include a multi-producer benchmark.
+2. **`mdb_env_set_mapsize` is permanent within env lifetime.** Resize requires restart + read quiesce. Solution: ship a generous default (50% RAM) and document the resize procedure for ops.
+3. **No multi-process writers.** Single-process smartbotic-database; not a real constraint.
+4. **LMDB durability semantics.** Default mode (`MDB_SYNC`) fsyncs every commit. Same durability as v1.x WAL + jemalloc-backed memory. Operators with weaker durability needs can opt into `MDB_NOSYNC` or `MDB_NOMETASYNC` via config — defaults are safe.
+5. **Schema-on-top.** v2.0 is "v1.x semantics on LMDB" — every v1.x feature has to be re-implemented against the new substrate, but the **shape** of features doesn't change.
+6. **Vector layout migration.** v1.x stores `_vector` inline in JSON; v2.0 moves to the sub-db. The migration tool must handle this transparently — extract `_vector` during v1→v2 doc copy, write to sub-db, leave JSON doc with the `_vector` field intact for wire-format compatibility.
+
+---
+
+## What this ADR does NOT cover
+
+The following are deferred to the execution plan or to in-flight design during implementation:
+
+- Exact LMDB env flag set (mostly defaults — `MDB_NOTLS` for shared txns, no `MDB_NOSYNC`).
+- Exact write-batch sizing (start: 128 ops or 1 ms, tune in soak testing).
+- gRPC handler ordering for txn acquisition (probably FIFO queue with bounded depth).
+- Operator runbook for resize / repair / hot-copy.
+- Performance gates for v2.0 release (TBD in execution plan: at minimum, ≥ Zoe-shape sustained throughput parity with v1.11).
+
+---
+
+## Decision metadata
+
+- **Decided:** 2026-05-15
+- **Decider:** Operator (fszontagh) approved all six recommendations with the explicit reminder that `_`-prefix convention applies to all new metadata fields and internal sub-db names.
+- **Discussion:** Single-session brainstorm following the LMDB spike. Operator picked "v2.0 = v1.x feature surface on LMDB" (minimal scope) over expanded or trimmed alternatives.
+- **Re-open if:** Phase C execution exposes a hard wall (e.g., LMDB single-writer ceiling becomes the binding constraint at Zoe-projected write rates), or operator decides to add v2.0-only features ahead of v2.1.