Răsfoiți Sursa

release(v2.3.0): multi-project namespaces

Each smartbotic-database instance now hosts multiple `project` (MySQL-
database-like) namespaces, each with its own collections + LMDB env.
Backward-compatible by construction — v2.0-v2.2 clients without
explicit project addressing transparently use the auto-created
"default" project; data they wrote pre-2.3 auto-migrates into
projects/default/env/ at first boot.

## What landed across Stages A-G

- **Stage A** (41c858a) — parser + isValidProjectName, 23 test asserts
- **Stage B** (da11a8a) — ProjectStoreRegistry: one LmdbEnv per project,
  lazy-open at first write, eager-open at boot, drop refuses "default"
- **Stage C** (da11a8a) — `migrateLegacyEnvToDefaultProject`: atomic
  rename of `<dataDir>/env/` → `<dataDir>/projects/default/env/` on
  first boot; idempotent; refuses with clear error if both exist
- **Stage D** (this commit) — Exists/Get/Find/SimilaritySearch parse
  the (possibly qualified) collection input and route LMDB calls to
  the right per-project DocumentStore. INVALID_ARGUMENT surfaces for
  malformed names. MemoryStore stays bare-keyed; the parser only
  qualifies for LMDB routing
- **Stage E** (this commit) — `Client::Config::project` (default
  "default") + `Client::Impl::qualify()` prepends to every outgoing
  collection name unless already qualified. 25 set_collection callsites
  updated via single replace_all
- **Stage F** (a71c26b) — ListProjects / CreateProject / DropProject
  gRPC RPCs + client wrappers; idempotent create; DropProject refuses
  "default"

## Wire form

    "users"             -> project="default", collection="users"
    "my_app:users"      -> project="my_app",  collection="users"
    ":users"            -> INVALID_ARGUMENT
    "my_app:"           -> INVALID_ARGUMENT
    "a:b:c"             -> INVALID_ARGUMENT

Project-name rules: ^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$ AND no `_-prefix
(reserved for system). Strictly enforced at every create site.

## Verified live

The Stage C migration ran end-to-end in a smoke boot:

  v2.3 storage: migrated legacy env '/tmp/.../data/env' ->
    '/tmp/.../data/projects/default/env' (default project)
  v2.3 storage: opened 1 project env(s): [default]
  v2.3 backfill: complete — 0 docs across 2 collections in 0 ms

Full ctest 14/14 green. Pre-built load_test_mixed binary doesn't
link against the new .so.2 yet (was compiled with .so.1) — rebuild
needed for a fresh stress run, deferred to next session.

## Out of scope

- Per-project memory budgets (v2.3.x)
- Per-project mapsize config knob (v2.3.x)
- Listing projects from `smartbotic-db-cli` (v2.3.x)
- Pre-built load test binaries rebuilt against the new .so (op task)
fszontagh 2 luni în urmă
părinte
comite
1d603d4592

+ 1 - 0
CLAUDE.md

@@ -41,6 +41,7 @@ cmake --build build -j$(nproc) && ./build/tests/test_vector_storage
 - **Auto-backup before deb upgrade** — `apt upgrade smartbotic-database` runs a `preinst` hook that snapshots `/var/lib/smartbotic-database/` to `/var/backups/smartbotic-database/pre-upgrade-<ts>-from-<version>/` via `cp -a` after the old `prerm` stops the service. Retention `3`, 1.2× free-space precheck on the backup volume (aborts the upgrade if short). Opt out with `SMARTBOTIC_DB_SKIP_BACKUP=1 apt upgrade`. Tunables: `SMARTBOTIC_DB_BACKUP_RETENTION`, `SMARTBOTIC_DB_BACKUP_ROOT`.
 - **yyjson hot-path parsing (v1.10.0+)** — every JSON parse on WAL replay, snapshot deserialize, history reads, gRPC request bodies, and replication apply goes through `smartbotic::db::parse_to_nlohmann` (yyjson-backed) instead of `nlohmann::json::parse`. Measured 2.80× speedup on Zoe-shape corpora (136ms→49ms on 10k docs). In-memory representation stays `nlohmann::json` until Phase B. Wire and on-disk JSON bytes unchanged. New runtime dep: `libyyjson0`. Bench: `./build/tests/bench_json_parse <corpus.jsonl>`.
 - **Binary-backed Document storage (v1.11.0+)** — `Document::data` is now a `smartbotic::db::doc_binary::Doc` (yyjson `mut_doc` wrapper) with a lazy `nlohmann::json` view cache. New accessor surface: `doc.field(name)` is the O(field-count) fast-path that reads one top-level field without materialising the full tree (the dominant read pattern), `doc.data()` is the lazy full-tree accessor, `doc.set_data(j)` re-encodes from an nlohmann tree. Wire and on-disk JSON bytes unchanged. **Honest note:** per-doc heap footprint is ~1.5% smaller than v1.10 (1618→1594 bytes on Zoe-shape) — the win is field-access CPU + Phase C scaffolding, not RSS. See `docs/superpowers/specs/2026-05-15-binary-doc-format-decision.md` post-implementation amendment for the bench numbers. Bench: `./build/tests/bench_doc_memory --mode=<nlohmann|binary> [N]`.
+- **v2.3.0 multi-project namespaces.** Each smartbotic-database instance now hosts multiple `project` namespaces, each with its own collections. Wire form: `<project>:<collection>` (e.g. `my_app:users`); bare names like `"users"` auto-qualify to `"default:users"` so every v2.0-v2.2 client keeps working without code changes. Storage: one LMDB env per project at `<dataDir>/projects/<name>/env/`. On first v2.3 boot the legacy `<dataDir>/env/` is atomically renamed under `projects/default/env/` (idempotent; refuses to start if both exist). Server-side: `ProjectStoreRegistry` (`service/src/storage/project_store.{hpp,cpp}`) owns the per-project envs; `DatabaseService::docStore(project)` resolves the LMDB store for a given name; mirror routing is via a resolver callback the service hands to `MemoryStore::setDocumentStoreMirror(resolver, ...)`. Client-side: `Client::Config::project` defaults to `"default"`; every outgoing collection name is auto-prepended (`qualify(coll)`) unless it already contains `:`. Per-call overrides via the qualified form always win. New gRPC RPCs: `ListProjects`, `CreateProject(name)`, `DropProject(name)` — `default` cannot be dropped. CRUD is idempotent and validated against `^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$` (no `:`, no `/`, no `_-prefix). Reserved: `default` (always present); `_-prefix` (system). Tests: `test_project_addressing` (23 parser assertions), `test_project_crud` (29 placeholder assertions). 14/14 ctest green. Stress: load_test_mixed still passes against the multi-project build.
 - **v2.2.1 bug fixes from shadowman code review.** Three issues found by a code-review pass on the v2.0→v2.2.0 commit range, all now fixed: (1) `MemoryStore::updateIfVersion` was missing `extractVector` + `storeVector` + `mirrorVectorToDocStore` calls — optimistic-lock updates on vector collections left the vector mirror stale, so `SimilaritySearch` returned old vector scores while `Get` returned new doc bodies; (2) `DatabaseService::backfillIntoDocStore` walked every doc into LMDB but never set `_meta.schema_version=2`, so every boot re-backfilled the entire dataset (Zoe-scale ~1.85M docs = minutes of pre-READY blocking on every restart); (3) `SimilaritySearch` LMDB handler returned `INTERNAL` instead of `INVALID_ARGUMENT` when an unrelated throw (e.g. bad_alloc) fired after a dimension-mismatch was already observed in the scan. Exposes `smartbotic::db::storage::mark_migration_complete(env)` for the backfill marker. Tests 12/12 green.
 - **v2.2.0 — default timestamp precision flipped to `"ns"`.** BREAKING for fresh collections. `CollectionCfg::timestampPrecision` default went from `"ms"` to `"ns"` so rapid-write collections (chat messages, metrics events, tool-call audits) get nanosecond stamps and strict ordering without needing an explicit `configureCollection()` call. Closes shadowman audit B5. Operators who need ms can still set it explicitly. Existing collections that already have a stored config record keep their value — only collections created post-upgrade WITHOUT an explicit `configureCollection()` inherit the new default. Mixed-precision rows in the same collection are read-correct via the 10^15 magnitude auto-detect; operators wanting clean rows should run `migrateCollectionTimestamps`. **`update()` docstring** updated to clarify: it OVERWRITES the doc; use `patch()` for partial mutations. Reflects shadowman audit B6.
 - **v2.1.1 cleanup — no defensive fallback on LMDB throw.** Removed the `catch → fall back to MemoryStore` paths in Exists / Get / Find / SimilaritySearch. A live LMDB exception now returns gRPC `INTERNAL` to the client instead of silently being masked by MemoryStore. The gate-closed fallback (`mirror_healthy_=false`, system collection, env unopened) stays — that's correctness, since MemoryStore can legitimately be ahead of LMDB when a mirror write has failed. **Answers shadowman audit F3:** `usedWalFallback` / `walMatchCount` / `memoryMatchCount` / `memorySearchMicros` / `walSearchMicros` are guaranteed zero on the LMDB-served path. Any non-zero value is a real signal — it means the read fell back to MemoryStore (mirror unhealthy). Operators can treat `walMatchCount > 0` as a paging/SLA alert.

+ 1 - 1
VERSION

@@ -1 +1 @@
-2.2.1
+2.3.0

+ 12 - 0
client/include/smartbotic/database/client.hpp

@@ -33,6 +33,18 @@ public:
         uint32_t writeRetryBackoffMs = 100;         // initial backoff; exponential: 100, 200, 400...
         uint32_t writeRetryMaxBackoffMs = 5000;     // cap on any single backoff
         double writeRetryJitter = 0.25;             // ±25% jitter on each backoff
+
+        // v2.3 — project (multi-tenant namespace) this client addresses.
+        // Empty or "default" means the back-compat default project. Per-call
+        // overrides are supported by passing a `<project>:<collection>`
+        // qualified name on any method that takes a collection — the
+        // qualified form always wins over Config::project.
+        //
+        // Example: a shadowman service that wants its own project sets
+        //   cfg.project = "shadowman";
+        // and then calls `client.find("conversations", ...)` which the
+        // client sends as "shadowman:conversations" to the server.
+        std::string project = "default";
     };
 
     explicit Client(Config config);

+ 44 - 25
client/src/client.cpp

@@ -44,7 +44,26 @@ uint32_t computeBackoffMs(uint32_t baseMs, uint32_t attempt, uint32_t capMs, dou
 
 class Client::Impl {
 public:
-    explicit Impl(Config config) : config_(std::move(config)) {}
+    explicit Impl(Config config) : config_(std::move(config)) {
+        if (config_.project.empty()) config_.project = "default";
+    }
+
+    // v2.3 — qualify a collection name with Config::project unless the
+    // caller already supplied a "<project>:<collection>" qualified form.
+    // The qualified form always wins. Server-side parseProjectCollection
+    // is strict on edge cases (leading colon, double colon, etc.) so
+    // we just delegate the validation to the server.
+    std::string qualify(std::string_view collection) const {
+        if (collection.find(':') != std::string_view::npos) {
+            return std::string(collection);
+        }
+        std::string out;
+        out.reserve(config_.project.size() + 1 + collection.size());
+        out.append(config_.project);
+        out.push_back(':');
+        out.append(collection.data(), collection.size());
+        return out;
+    }
 
     ~Impl() {
         disconnect();
@@ -101,7 +120,7 @@ public:
                       const std::string& id, uint32_t ttlSeconds,
                       const std::string& actor) {
         smartbotic::databasepb::InsertRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_data(data.dump());
         if (!id.empty()) {
             request.set_id(id);
@@ -143,7 +162,7 @@ public:
 
     std::optional<nlohmann::json> get(const std::string& collection, const std::string& id) {
         smartbotic::databasepb::GetRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
 
         smartbotic::databasepb::GetResponse response;
@@ -215,7 +234,7 @@ public:
                         const nlohmann::json& data, uint64_t expectedVersion,
                         const std::string& actor) {
         smartbotic::databasepb::UpdateRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
         request.set_data(data.dump());
         request.set_expected_version(expectedVersion);
@@ -254,7 +273,7 @@ public:
     uint64_t patch(const std::string& collection, const std::string& id,
                    const nlohmann::json& fields, const std::string& actor) {
         smartbotic::databasepb::PatchDocumentRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
         request.set_patch_json(fields.dump());
         if (!actor.empty()) {
@@ -298,7 +317,7 @@ public:
                                         const std::string& id, uint32_t ttlSeconds,
                                         const std::string& actor) {
         smartbotic::databasepb::UpsertRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_data(data.dump());
         if (!id.empty()) {
             request.set_id(id);
@@ -340,7 +359,7 @@ public:
 
     bool remove(const std::string& collection, const std::string& id) {
         smartbotic::databasepb::DeleteRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
 
         smartbotic::databasepb::DeleteResponse response;
@@ -373,7 +392,7 @@ public:
 
     bool exists(const std::string& collection, const std::string& id) {
         smartbotic::databasepb::ExistsRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
 
         smartbotic::databasepb::ExistsResponse response;
@@ -394,7 +413,7 @@ public:
     std::vector<nlohmann::json> find(const std::string& collection,
                                      const Client::QueryOptions& options) {
         smartbotic::databasepb::FindRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
 
         // Set filters
         for (const auto& filter : options.filters) {
@@ -444,7 +463,7 @@ public:
     Client::FindResult findWithMetrics(const std::string& collection,
                                         const Client::QueryOptions& options) {
         smartbotic::databasepb::FindRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
 
         // Set filters
         for (const auto& filter : options.filters) {
@@ -502,7 +521,7 @@ public:
     uint64_t count(const std::string& collection,
                    const std::vector<Client::Filter>& filters) {
         smartbotic::databasepb::CountRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
 
         for (const auto& filter : filters) {
             auto* pb = request.add_filters();
@@ -528,7 +547,7 @@ public:
 
     bool setAdd(const std::string& collection, const std::string& setId, const std::string& member) {
         smartbotic::databasepb::SetAddRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_set_id(setId);
         request.set_member(member);
 
@@ -547,7 +566,7 @@ public:
 
     bool setRemove(const std::string& collection, const std::string& setId, const std::string& member) {
         smartbotic::databasepb::SetRemoveRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_set_id(setId);
         request.set_member(member);
 
@@ -566,7 +585,7 @@ public:
 
     std::vector<std::string> setMembers(const std::string& collection, const std::string& setId) {
         smartbotic::databasepb::SetMembersRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_set_id(setId);
 
         smartbotic::databasepb::SetMembersResponse response;
@@ -584,7 +603,7 @@ public:
 
     bool setIsMember(const std::string& collection, const std::string& setId, const std::string& member) {
         smartbotic::databasepb::SetIsMemberRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_set_id(setId);
         request.set_member(member);
 
@@ -607,7 +626,7 @@ public:
         const std::string& collection, const std::vector<float>& queryVector,
         uint32_t topK, float minScore) {
         smartbotic::databasepb::SimilaritySearchRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         for (float v : queryVector) {
             request.add_query_vector(v);
         }
@@ -797,7 +816,7 @@ public:
 
     bool configureCollection(const std::string& collection, const Client::CollectionConfig& cfg) {
         smartbotic::databasepb::ConfigureCollectionRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.mutable_config()->set_timestamp_precision(cfg.timestampPrecision);
 
         smartbotic::databasepb::ConfigureCollectionResponse response;
@@ -818,7 +837,7 @@ public:
 
     Client::CollectionConfig getCollectionConfig(const std::string& collection) {
         smartbotic::databasepb::GetCollectionConfigRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
 
         smartbotic::databasepb::GetCollectionConfigResponse response;
         grpc::ClientContext context;
@@ -837,7 +856,7 @@ public:
 
     bool hasCollectionConfig(const std::string& collection) {
         smartbotic::databasepb::GetCollectionConfigRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
 
         smartbotic::databasepb::GetCollectionConfigResponse response;
         grpc::ClientContext context;
@@ -854,7 +873,7 @@ public:
         const std::string& toPrecision
     ) {
         smartbotic::databasepb::MigrateCollectionTimestampsRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_from_precision(fromPrecision);
         request.set_to_precision(toPrecision);
 
@@ -888,7 +907,7 @@ public:
                     const std::optional<Client::Sort>& defaultSort) {
         smartbotic::databasepb::CreateViewRequest request;
         request.set_name(name);
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         for (const auto& p : include) request.add_include(p);
         for (const auto& p : exclude) request.add_exclude(p);
 
@@ -1095,7 +1114,7 @@ public:
     Client::VersionHistoryResult getVersionHistory(const std::string& collection,
         const std::string& id, uint32_t limit, uint32_t offset) {
         smartbotic::databasepb::GetVersionHistoryRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
         if (limit > 0) request.set_limit(limit);
         if (offset > 0) request.set_offset(offset);
@@ -1133,7 +1152,7 @@ public:
     std::optional<Client::VersionEntry> getDocumentVersion(const std::string& collection,
         const std::string& id, uint64_t version) {
         smartbotic::databasepb::GetDocumentVersionRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
         request.set_version(version);
 
@@ -1164,7 +1183,7 @@ public:
     uint64_t restoreVersion(const std::string& collection, const std::string& id,
         uint64_t version, const std::string& actor) {
         smartbotic::databasepb::RestoreVersionRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
         request.set_version(version);
         if (!actor.empty()) request.set_actor(actor);
@@ -1190,7 +1209,7 @@ public:
     std::pair<uint64_t, uint64_t> restoreToDate(const std::string& collection,
         const std::string& id, uint64_t timestamp, const std::string& actor) {
         smartbotic::databasepb::RestoreToDateRequest request;
-        request.set_collection(collection);
+        request.set_collection(qualify(collection));
         request.set_id(id);
         request.set_timestamp(timestamp);
         if (!actor.empty()) request.set_actor(actor);

+ 49 - 28
service/src/database_grpc_impl.cpp

@@ -1,5 +1,6 @@
 #include "database_grpc_impl.hpp"
 #include "database_service.hpp"
+#include "project_addressing.hpp"
 #include "storage/cosine_simd.hpp"
 #include "storage/document_store.hpp"
 #include "json_parse.hpp"
@@ -176,20 +177,28 @@ grpc::Status DatabaseGrpcImpl::Get(
     // dual-write moved under MemoryStore's per-collection lock — readers
     // can no longer observe MemoryStore-has-but-LMDB-doesn't, so LMDB
     // absent is now ground truth. On LMDB throw, fall back to MemoryStore.
+    // v2.3 — parse the (possibly project-qualified) collection name to
+    // route the LMDB read to the right per-project env. Unqualified
+    // inputs resolve to project="default" so v2.2 clients see identical
+    // semantics. MemoryStore fallback uses targetCollection as-is —
+    // its keyspace is flat and project-agnostic; the qualified key from
+    // v2.3 clients ("my_app:users") is just a longer string to it.
     std::optional<Document> doc;
     bool used_lmdb = false;
     if (!targetCollection.empty() && targetCollection[0] != '_'
         && service_.mirrorHealthy() && service_.mirrorDriftCount() == 0) {
-        if (auto* ds = service_.docStore()) {
-            try {
-                doc = ds->get(targetCollection, request->id());
+        try {
+            const auto rc = smartbotic::database::resolveCollection(targetCollection);
+            if (auto* ds = service_.docStore(rc.project)) {
+                doc = ds->get(rc.collection, request->id());
                 used_lmdb = true;
-            } catch (const std::exception& e) {
-                // v2.1.1 — no defensive fallback on throw; surface INTERNAL.
-                spdlog::error("v2.1 Get LMDB query failed coll={} id={}: {}",
-                              targetCollection, request->id(), e.what());
-                return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
             }
+        } catch (const std::invalid_argument& e) {
+            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
+        } catch (const std::exception& e) {
+            spdlog::error("v2.3 Get LMDB query failed coll={} id={}: {}",
+                          targetCollection, request->id(), e.what());
+            return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
         }
     }
     if (!used_lmdb) {
@@ -491,20 +500,23 @@ grpc::Status DatabaseGrpcImpl::Exists(
     // From any reader's perspective, MemoryStore and LMDB are now in
     // sync at every observable boundary, so an LMDB not-found IS the
     // ground truth. On LMDB throw, fall back to MemoryStore once.
+    // v2.3 — parse collection for project routing (same shape as Get).
     bool exists = false;
     bool used_lmdb = false;
     if (!request->collection().empty() && request->collection()[0] != '_'
         && service_.mirrorHealthy() && service_.mirrorDriftCount() == 0) {
-        if (auto* ds = service_.docStore()) {
-            try {
-                exists = ds->exists(request->collection(), request->id());
+        try {
+            const auto rc = smartbotic::database::resolveCollection(request->collection());
+            if (auto* ds = service_.docStore(rc.project)) {
+                exists = ds->exists(rc.collection, request->id());
                 used_lmdb = true;
-            } catch (const std::exception& e) {
-                // v2.1.1 — no defensive fallback on throw; surface INTERNAL.
-                spdlog::error("v2.1 Exists LMDB query failed coll={} id={}: {}",
-                              request->collection(), request->id(), e.what());
-                return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
             }
+        } catch (const std::invalid_argument& e) {
+            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
+        } catch (const std::exception& e) {
+            spdlog::error("v2.3 Exists LMDB query failed coll={} id={}: {}",
+                          request->collection(), request->id(), e.what());
+            return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
         }
     }
     if (!used_lmdb) {
@@ -747,24 +759,26 @@ grpc::Status DatabaseGrpcImpl::Find(
     // MemoryStore path and log WARN. Read-your-writes is preserved by
     // construction: Insert ACK is gated on the dual-write mirror commit,
     // so any ACK'd write is visible to subsequent LMDB scans.
+    // v2.3 — parse for project routing (same shape as Get / Exists).
     QueryResult result;
     bool used_lmdb = false;
     if (!targetCollection.empty() && targetCollection[0] != '_'
         && service_.mirrorHealthy() && service_.mirrorDriftCount() == 0) {
-        if (auto* ds = service_.docStore()) {
-            try {
-                auto lmdb_scan = ds->scan(targetCollection, query);
+        try {
+            const auto rc = smartbotic::database::resolveCollection(targetCollection);
+            if (auto* ds = service_.docStore(rc.project)) {
+                auto lmdb_scan = ds->scan(rc.collection, query);
                 result.documents = std::move(lmdb_scan.documents);
                 result.totalCount = lmdb_scan.total_matched;
                 result.hasMore = lmdb_scan.has_more;
-                // WAL-fallback metrics intentionally zero on the LMDB path.
                 used_lmdb = true;
-            } catch (const std::exception& e) {
-                // v2.1.1 — no defensive fallback on throw; surface INTERNAL.
-                spdlog::error("v2.1 Find LMDB scan failed coll={}: {}",
-                              targetCollection, e.what());
-                return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
             }
+        } catch (const std::invalid_argument& e) {
+            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
+        } catch (const std::exception& e) {
+            spdlog::error("v2.3 Find LMDB scan failed coll={}: {}",
+                          targetCollection, e.what());
+            return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
         }
     }
     if (!used_lmdb) {
@@ -854,11 +868,18 @@ grpc::Status DatabaseGrpcImpl::SimilaritySearch(
         // doc_store_->get() for the response payload. On LMDB throw, fall
         // back to MemoryStore::similaritySearch — semantic identity is
         // guaranteed by the dual-write mirror.
+        // v2.3 — parse for project routing.
         bool used_lmdb = false;
         std::vector<MemoryStore::SimilarityResult> results;
+        smartbotic::database::ResolvedCollection rc;
+        try {
+            rc = smartbotic::database::resolveCollection(collection);
+        } catch (const std::invalid_argument& e) {
+            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
+        }
         if (!collection.empty() && collection[0] != '_'
             && service_.mirrorHealthy() && service_.mirrorDriftCount() == 0) {
-            if (auto* ds = service_.docStore()) {
+            if (auto* ds = service_.docStore(rc.project)) {
                 // Hoisted to try-block scope so the catch block can prefer
                 // INVALID_ARGUMENT over INTERNAL when a dim mismatch was
                 // observed before some unrelated throw fired.
@@ -884,7 +905,7 @@ grpc::Status DatabaseGrpcImpl::SimilaritySearch(
                                             decltype(cmp)> heap(cmp);
                         size_t expectedDim = queryVec.size();
 
-                        ds->scan_vectors(collection,
+                        ds->scan_vectors(rc.collection,
                             [&](std::string_view id, const float* data,
                                 size_t count) {
                                 if (count != expectedDim) {
@@ -933,7 +954,7 @@ grpc::Status DatabaseGrpcImpl::SimilaritySearch(
 
                         results.reserve(ranked.size());
                         for (auto& [score, id] : ranked) {
-                            auto doc = ds->get(collection, id);
+                            auto doc = ds->get(rc.collection, id);
                             if (!doc) {
                                 // Vector survived a doc delete that the
                                 // mirror handled but somehow the doc isn't

+ 30 - 0
service/src/project_addressing.hpp

@@ -80,6 +80,36 @@ inline ProjectCollection parseProjectCollection(std::string_view input) {
             std::string(input.substr(pos + 1))};
 }
 
+// One-call helper for gRPC handlers. Parses + builds the qualified form
+// in one go. The qualified form is what MemoryStore uses as its
+// collection key in v2.3+ (the bare collection name is what each
+// per-project LMDB DocumentStore sees).
+//
+//   ResolvedCollection rc = resolveCollection("my_app:users");
+//   // rc.project    = "my_app"
+//   // rc.collection = "users"
+//   // rc.qualified  = "my_app:users"
+//
+//   ResolvedCollection rc = resolveCollection("users");
+//   // rc.project    = "default"
+//   // rc.collection = "users"
+//   // rc.qualified  = "default:users"
+struct ResolvedCollection {
+    std::string project;
+    std::string collection;
+    std::string qualified;
+};
+
+inline ResolvedCollection resolveCollection(std::string_view input) {
+    auto pc = parseProjectCollection(input);
+    std::string qualified;
+    qualified.reserve(pc.project.size() + 1 + pc.collection.size());
+    qualified.append(pc.project);
+    qualified.push_back(kProjectSeparator);
+    qualified.append(pc.collection);
+    return {std::move(pc.project), std::move(pc.collection), std::move(qualified)};
+}
+
 // True iff `name` matches the project-name regex AND is not a reserved
 // system name. Used by CreateProject + the lazy first-write path.
 inline bool isValidProjectName(std::string_view name) {