Ver Fonte

release(v2.4.4): sub-db identity sentinel + placement audit/repair

The other half of the v2.4.3 stale-MDB_dbi bug. A user hit an impossible
triple on 2.4.3: get(image_hashes/id) not found, get(executions/id)
readable, insert(image_hashes/id) "already exists". There is no id index -
the three operations consult different substrates. Get/Find/Exists are
LMDB-first; insert's duplicate check reads MemoryStore. MemoryStore was
right, LMDB had the document in the wrong sub-db.

MDB_dbi is an index into the env's shared handle table, not a pointer.
Pre-2.4.3, try_open_for_read cached a handle opened inside a ReadTxn; the
abort released the slot without bumping me_numdbs, so the next
mdb_dbi_open for an unrelated collection got the same number. EINVAL (the
v2.4.3 drain incident) or a silently wrong sub-db depended only on whether
something had reclaimed the slot. Writes through a reclaimed slot succeed
into a stranger's collection, and mirror_healthy_ only tracks writes that
throw, so nothing noticed.

Found in production: 31 misfiled rows in smartbotic-automation - 23 in
executions declaring image_hashes, 6 in workflows declaring sessions, 1 in
workflows declaring users, 1 in nsfw_temp declaring workflows. default was
clean. 29 of 31 existed only in the wrong sub-db, so deleting the strays
would have destroyed them.

Prevention: storage/subdb_identity.{hpp,cpp} - each sub-db carries a
reserved key naming itself; open_for_write verifies it on every cached
handle reuse and throws on mismatch. count() subtracts it, scan() and
scan_vectors() skip it. A missing sentinel is "unknown", not "wrong", so
pre-2.4.4 sub-dbs keep working.

Detection: DatabaseService::auditSubdbPlacement() runs per project at boot,
logs ERROR with a summary and the repair command. Advisory only.

Repair: storage/subdb_placement.{hpp,cpp}, built into both service and CLI.
  smartbotic-db-cli verify-subdbs --env PATH [--project N]
  smartbotic-db-cli reconcile-subdbs --env PATH [--project N] --apply
Repair never deletes - a row moves to its declared home only if that home
is free, otherwise the stray is parked in _orphans_<subdb>. The document's
own `collection` field is the oracle, so no cross-referencing is needed.

--stamp-identity is opt-in and requires the server to be >= 2.4.4 already;
a 2.4.3 binary does not skip the sentinel and would throw in scan().
Order on an existing box: repair placement, upgrade, then stamp.

Surfaced but NOT fixed here: Count calls store_.count() (MemoryStore only)
while Get/Find/Exists are LMDB-first, so count() under-reports when the two
diverge.

Tests: tests/test_subdb_identity.cpp, 19 assertions. ctest 15/15.
fszontagh há 1 mês atrás
pai
commit
55f9209b79

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
CLAUDE.md


+ 1 - 1
VERSION

@@ -1 +1 @@
-2.4.3
+2.4.4

+ 16 - 1
cli/CMakeLists.txt

@@ -1,8 +1,23 @@
 find_package(OpenSSL REQUIRED)
 
-add_executable(smartbotic-db-cli main.cpp)
+# LMDB — needed by the offline sub-db audit/repair commands, which operate
+# directly on an env on disk rather than through the gRPC API.
+if(NOT LMDB_INCLUDE_DIR OR NOT LMDB_LIBRARY)
+    find_path(LMDB_INCLUDE_DIR lmdb.h)
+    find_library(LMDB_LIBRARY NAMES lmdb)
+    if(NOT LMDB_INCLUDE_DIR OR NOT LMDB_LIBRARY)
+        message(FATAL_ERROR "LMDB not found. Install liblmdb-dev.")
+    endif()
+endif()
+
+add_executable(smartbotic-db-cli main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/subdb_placement.cpp)
+target_include_directories(smartbotic-db-cli PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
+    ${LMDB_INCLUDE_DIR}
+)
 target_link_libraries(smartbotic-db-cli PRIVATE
     smartbotic-db-client
     OpenSSL::SSL
     OpenSSL::Crypto
+    ${LMDB_LIBRARY}
 )

+ 86 - 1
cli/main.cpp

@@ -1,6 +1,8 @@
 #include <smartbotic/database/client.hpp>
 #include <nlohmann/json.hpp>
 
+#include "storage/subdb_placement.hpp"
+
 #include <openssl/bio.h>
 #include <openssl/bn.h>
 #include <openssl/err.h>
@@ -72,7 +74,11 @@ void printUsage() {
               << C_BOLD << "Offline helpers" << C_RESET << " " << C_DIM << "(no server connection required)" << C_RESET << ":\n"
               << "  " << C_CYAN << "generate-auth-key" << C_RESET << "                    Emit a base64 32-byte API key\n"
               << "  " << C_CYAN << "generate-tls-cert" << C_RESET << " --bind <addr>      Generate a self-signed TLS cert + key\n"
-              << "    " << C_DIM << "[--out-cert PATH] [--out-key PATH] [--days N]" << C_RESET << "\n\n"
+              << "    " << C_DIM << "[--out-cert PATH] [--out-key PATH] [--days N]" << C_RESET << "\n"
+              << "  " << C_CYAN << "verify-subdbs" << C_RESET << " --env <path>          Report documents filed under the wrong collection\n"
+              << "    " << C_DIM << "[--project NAME]   read-only; safe against a running server" << C_RESET << "\n"
+              << "  " << C_CYAN << "reconcile-subdbs" << C_RESET << " --env <path>       Repair misfiled documents (dry run by default)\n"
+              << "    " << C_DIM << "[--project NAME] [--apply]   STOP THE SERVICE and back up before --apply" << C_RESET << "\n\n"
               << C_BOLD << "Options:" << C_RESET << "\n"
               << "  --address HOST:PORT    Database address (default: localhost:9004)\n";
 }
@@ -648,6 +654,82 @@ std::vector<std::string> tokenize(const std::string& line) {
     return tokens;
 }
 
+// v2.4.4 — offline sub-db placement audit / repair.
+//
+// `verify-subdbs` is read-only and safe against a running server.
+// `reconcile-subdbs --apply` rewrites document placement and REQUIRES the
+// service to be stopped: it holds an LMDB write txn over the whole env.
+int cmdSubdbs(const std::string& command, const std::vector<std::string>& params) {
+    std::string env_path, project;
+    bool apply = false;
+    bool stamp = false;
+
+    for (size_t i = 0; i < params.size(); ++i) {
+        const std::string& p = params[i];
+        if (p == "--env" && i + 1 < params.size()) {
+            env_path = params[++i];
+        } else if (p == "--project" && i + 1 < params.size()) {
+            project = params[++i];
+        } else if (p == "--apply") {
+            apply = true;
+        } else if (p == "--stamp-identity") {
+            stamp = true;
+        } else {
+            printError("unknown argument: " + p);
+            return 2;
+        }
+    }
+
+    if (env_path.empty()) {
+        printError("--env <path> is required (e.g. "
+                   "/var/lib/smartbotic-database/projects/default/env)");
+        return 2;
+    }
+    if (command == "verify-subdbs" && apply) {
+        printError("--apply is not valid for verify-subdbs; use reconcile-subdbs");
+        return 2;
+    }
+
+    try {
+        auto report = smartbotic::db::storage::audit(env_path, project);
+        smartbotic::db::storage::print_audit(report);
+
+        if (command == "verify-subdbs") {
+            return report.misplaced.empty() ? 0 : 1;
+        }
+
+        if (!apply) {
+            std::cout << "\n" << C_YELLOW << "Dry run." << C_RESET
+                      << " Re-run with --apply to perform the repair.\n"
+                      << C_DIM
+                      << "Stop the service first, and take a backup: the repair "
+                         "rewrites document placement in place.\n"
+                      << C_RESET;
+            return report.misplaced.empty() ? 0 : 1;
+        }
+
+        if (stamp) {
+            std::cout << "\n" << C_YELLOW << "--stamp-identity:" << C_RESET
+                      << " writing sentinels. This REQUIRES the server binary to be"
+                         " v2.4.4 or newer;\n  earlier builds do not skip the sentinel"
+                         " and will fail on scan.\n";
+        }
+        std::cout << "\nApplying...\n";
+        auto res = smartbotic::db::storage::repair(env_path, report, stamp);
+        std::cout << C_GREEN << "done." << C_RESET
+                  << "  moved=" << res.moved
+                  << " quarantined=" << res.quarantined
+                  << " stamped=" << res.stamped << "\n";
+        for (const auto& e : res.errors) {
+            std::cerr << C_RED << "  warn: " << C_RESET << e << "\n";
+        }
+        return res.errors.empty() ? 0 : 1;
+    } catch (const std::exception& e) {
+        printError(e.what());
+        return 1;
+    }
+}
+
 } // anonymous namespace
 
 int main(int argc, char* argv[]) {
@@ -680,6 +762,9 @@ int main(int argc, char* argv[]) {
     if (args.command == "generate-tls-cert") {
         return cmdGenerateTlsCert(args.params);
     }
+    if (args.command == "verify-subdbs" || args.command == "reconcile-subdbs") {
+        return cmdSubdbs(args.command, args.params);
+    }
 
     // Connect
     smartbotic::database::Client client({.address = args.address});

+ 2 - 0
service/CMakeLists.txt

@@ -63,6 +63,8 @@ set(DATABASE_SERVICE_SOURCES
     src/storage/lmdb_txn.cpp
     src/storage/lmdb_dbi.cpp
     src/storage/document_store_lmdb.cpp
+    src/storage/subdb_identity.cpp
+    src/storage/subdb_placement.cpp
     src/storage/migrate_v1_to_v2.cpp
     src/storage/project_store.cpp
     src/tls/cert_generator.cpp

+ 49 - 0
service/src/database_service.cpp

@@ -9,9 +9,11 @@
 
 #include <fstream>
 #include <sstream>
+#include <map>
 #include "storage/lmdb_env.hpp"
 #include "storage/migrate_v1_to_v2.hpp"
 #include "storage/project_store.hpp"
+#include "storage/subdb_placement.hpp"
 
 #include <grpcpp/grpcpp.h>
 #include <grpcpp/resource_quota.h>
@@ -207,6 +209,15 @@ bool DatabaseService::initialize() {
         // the read-flip downstream would see an empty mirror.
         backfillIntoDocStore();
 
+        // v2.4.4 — placement audit. Reports documents sitting in a sub-db
+        // other than the one they declare, which is the signature of a
+        // misbound MDB_dbi (see storage/subdb_identity.hpp). Read-only and
+        // advisory: it never blocks startup, because a misplacement is a
+        // data-location problem an operator repairs offline with
+        // `smartbotic-db-cli reconcile-subdbs`, not a reason to refuse
+        // service on the other 99% of the dataset.
+        auditSubdbPlacement();
+
         spdlog::info("Database service initialized successfully");
         return true;
 
@@ -296,6 +307,44 @@ bool DatabaseService::runMigrations() {
     return migrationRunner_->runMigrations();
 }
 
+void DatabaseService::auditSubdbPlacement() {
+    if (!projects_) return;
+
+    for (const auto& name : projects_->listOpen()) {
+        auto h = projects_->getHandle(name);
+        if (!h.env) continue;
+        try {
+            // audit() opens the env read-only on its own handle, so it does
+            // not contend with the service's write path.
+            const auto rep = smartbotic::db::storage::audit(h.env->path(), name);
+            if (rep.misplaced.empty()) {
+                spdlog::debug("placement audit: project '{}' consistent ({} rows)",
+                              name, rep.rows_scanned);
+                continue;
+            }
+
+            // Summarise per (physical -> declared home) pair; one line per
+            // affected document would be unreadable at scale.
+            std::map<std::string, uint64_t> pairs;
+            for (const auto& m : rep.misplaced) {
+                ++pairs[m.physical_subdb + " -> " + m.home_subdb];
+            }
+            spdlog::error("placement audit: project '{}' has {} document(s) in the "
+                          "wrong sub-db. Reads served from LMDB will not find them "
+                          "under their own collection. Repair offline with: "
+                          "smartbotic-db-cli reconcile-subdbs --env {} --project {} --apply",
+                          name, rep.misplaced.size(), h.env->path(), name);
+            for (const auto& [pair, count] : pairs) {
+                spdlog::error("placement audit:   {} document(s)  {}", count, pair);
+            }
+        } catch (const std::exception& e) {
+            // Advisory only — a failed audit must not take the service down.
+            spdlog::warn("placement audit: project '{}' could not be audited: {}",
+                         name, e.what());
+        }
+    }
+}
+
 bool DatabaseService::backfillIntoDocStore() {
     if (!projects_) {
         // Registry open failed earlier; nothing to back-fill into.

+ 5 - 0
service/src/database_service.hpp

@@ -284,6 +284,11 @@ private:
     // do not abort boot (the read-flip downstream is the gate).
     bool backfillIntoDocStore();
 
+    // v2.4.4 — audit every open project env for documents whose declared
+    // `collection` disagrees with the sub-db they occupy. Logs ERROR per
+    // affected project and leaves a summary; never fails startup.
+    void auditSubdbPlacement();
+
     // v2.3 Stage C — atomic rename of <dataDir>/env/ into
     // <dataDir>/projects/default/env/ when the v2.2 layout is detected
     // and the new layout doesn't yet exist. Idempotent. Refuses to start

+ 39 - 5
service/src/storage/document_store_lmdb.cpp

@@ -44,6 +44,7 @@
 #include "storage/lmdb_dbi.hpp"
 #include "storage/lmdb_env.hpp"
 #include "storage/lmdb_txn.hpp"
+#include "storage/subdb_identity.hpp"
 
 namespace smartbotic::db::storage {
 
@@ -206,12 +207,26 @@ unsigned int LmdbDocumentStore::open_for_write(WriteTxn& wtxn,
     {
         std::lock_guard<std::mutex> lock(cache_mutex_);
         auto it = dbi_cache_.find(key);
-        if (it != dbi_cache_.end()) return it->second;
+        if (it != dbi_cache_.end()) {
+            // v2.4.4 — a cached handle is the one thing that can go stale.
+            // MDB_dbi is an index into the env's shared table, so an
+            // invalidated handle does not dangle detectably: it addresses
+            // whichever sub-db LMDB has since put in that slot. Verify the
+            // sentinel before writing through it. Costs one mdb_get per
+            // write; buys us a loud failure instead of silent cross-
+            // collection corruption. See storage/subdb_identity.hpp.
+            verify_subdb_identity(wtxn, it->second, collection);
+            return it->second;
+        }
     }
     MDB_dbi raw_dbi = 0;
     std::string name = to_cstr(collection);
     mdb_check(mdb_dbi_open(wtxn.raw(), name.c_str(), MDB_CREATE, &raw_dbi),
               "dbi_open (collection create)");
+    // Stamp on every fresh open, not only on creation, so sub-dbs that
+    // pre-date v2.4.4 acquire a sentinel on their next write without a
+    // migration pass. Idempotent.
+    write_subdb_identity(wtxn, raw_dbi, collection);
     {
         std::lock_guard<std::mutex> lock(cache_mutex_);
         dbi_cache_[key] = raw_dbi;
@@ -307,7 +322,15 @@ uint64_t LmdbDocumentStore::count(std::string_view collection) {
     if (!dbi_opt) return 0;
     MDB_stat st{};
     mdb_check(mdb_stat(rtxn.raw(), *dbi_opt, &st), "stat");
-    return st.ms_entries;
+    // The identity sentinel occupies one entry but is not a document. Sub-dbs
+    // written before v2.4.4 have none, hence the probe rather than a blind -1.
+    uint64_t entries = st.ms_entries;
+    if (entries > 0) {
+        MDB_val k = to_val(kSubdbIdentityKey);
+        MDB_val v{0, nullptr};
+        if (mdb_get(rtxn.raw(), *dbi_opt, &k, &v) == MDB_SUCCESS) --entries;
+    }
+    return entries;
 }
 
 ScanResult LmdbDocumentStore::scan(std::string_view collection,
@@ -330,9 +353,14 @@ ScanResult LmdbDocumentStore::scan(std::string_view collection,
     MDB_val v{0, nullptr};
     int rc = mdb_cursor_get(cursor, &k, &v, MDB_FIRST);
     while (rc == MDB_SUCCESS) {
-        smartbotic::database::Document doc = decode_document(to_sv(v));
-        if (smartbotic::db::storage::filter_eval::matchesFilters(doc, query.filters)) {
-            matches.push_back(std::move(doc));
+        // Skip the identity sentinel — it is not JSON and would throw in
+        // decode_document. It sorts first (leading NUL), so this is one
+        // comparison at the head of the scan in practice.
+        if (!is_identity_key(to_sv(k))) {
+            smartbotic::database::Document doc = decode_document(to_sv(v));
+            if (smartbotic::db::storage::filter_eval::matchesFilters(doc, query.filters)) {
+                matches.push_back(std::move(doc));
+            }
         }
         rc = mdb_cursor_get(cursor, &k, &v, MDB_NEXT);
     }
@@ -501,6 +529,12 @@ void LmdbDocumentStore::scan_vectors(
     MDB_val v{0, nullptr};
     int rc = mdb_cursor_get(cursor, &k, &v, MDB_FIRST);
     while (rc == MDB_SUCCESS) {
+        if (is_identity_key(to_sv(k))) {
+            // Not a vector — skip before the size check below, which would
+            // otherwise reject the sentinel's byte length.
+            rc = mdb_cursor_get(cursor, &k, &v, MDB_NEXT);
+            continue;
+        }
         if (v.mv_size % sizeof(float) != 0) {
             throw std::runtime_error(
                 "scan_vectors: stored bytes are not a multiple of sizeof(float)");

+ 75 - 0
service/src/storage/subdb_identity.cpp

@@ -0,0 +1,75 @@
+// v2.4.4 — sub-database identity sentinel implementation.
+
+#include "storage/subdb_identity.hpp"
+
+#include <lmdb.h>
+
+#include <stdexcept>
+#include <string>
+
+#include "storage/lmdb_txn.hpp"
+
+namespace smartbotic::db::storage {
+
+namespace {
+
+inline MDB_val to_val(std::string_view sv) {
+    return MDB_val{sv.size(), const_cast<char*>(sv.data())};
+}
+
+inline std::string_view to_sv(const MDB_val& v) {
+    return std::string_view(static_cast<const char*>(v.mv_data), v.mv_size);
+}
+
+}  // namespace
+
+void write_subdb_identity(WriteTxn& txn, unsigned int dbi, std::string_view name) {
+    MDB_val k = to_val(kSubdbIdentityKey);
+    MDB_val v = to_val(name);
+    int rc = mdb_put(txn.raw(), dbi, &k, &v, 0);
+    if (rc != MDB_SUCCESS) {
+        throw std::runtime_error(std::string("LMDB write_subdb_identity: ") +
+                                 mdb_strerror(rc));
+    }
+}
+
+void verify_subdb_identity(WriteTxn& txn, unsigned int dbi, std::string_view expected) {
+    MDB_val k = to_val(kSubdbIdentityKey);
+    MDB_val v{0, nullptr};
+    int rc = mdb_get(txn.raw(), dbi, &k, &v);
+
+    if (rc == MDB_NOTFOUND) {
+        // Pre-v2.4.4 sub-db: no sentinel yet. Unknown, not wrong.
+        return;
+    }
+    if (rc != MDB_SUCCESS) {
+        throw std::runtime_error(std::string("LMDB verify_subdb_identity: ") +
+                                 mdb_strerror(rc));
+    }
+
+    const std::string_view actual = to_sv(v);
+    if (actual != expected) {
+        // A misbound MDB_dbi. Refusing the write is the whole point: the
+        // alternative is silently corrupting `actual`'s data with `expected`'s.
+        throw std::runtime_error(
+            "LMDB sub-db handle misbound: caller asked for '" +
+            std::string(expected) + "' but the handle addresses '" +
+            std::string(actual) +
+            "'. Refusing the write. This indicates an invalidated MDB_dbi was "
+            "reused after its slot was reassigned.");
+    }
+}
+
+std::string read_subdb_identity(ReadTxn& txn, unsigned int dbi) {
+    MDB_val k = to_val(kSubdbIdentityKey);
+    MDB_val v{0, nullptr};
+    int rc = mdb_get(txn.raw(), dbi, &k, &v);
+    if (rc == MDB_NOTFOUND) return {};
+    if (rc != MDB_SUCCESS) {
+        throw std::runtime_error(std::string("LMDB read_subdb_identity: ") +
+                                 mdb_strerror(rc));
+    }
+    return std::string(to_sv(v));
+}
+
+}  // namespace smartbotic::db::storage

+ 75 - 0
service/src/storage/subdb_identity.hpp

@@ -0,0 +1,75 @@
+// v2.4.4 — sub-database identity sentinel.
+//
+// Why this exists
+// ---------------
+// MDB_dbi is a bare `unsigned int` index into the environment's shared handle
+// table, not an opaque pointer. A handle that has been invalidated does not
+// dangle in any way the compiler or the CPU can catch — it simply indexes a
+// slot that LMDB may since have reassigned to a *different* sub-database.
+//
+// That is exactly what happened in production before v2.4.3:
+// `try_open_for_read` cached a handle opened inside a ReadTxn; ReadTxn always
+// aborts, LMDB released the slot without bumping `me_numdbs`, and the next
+// `mdb_dbi_open` for an unrelated collection was handed the same number. Every
+// later write through the stale cache entry then succeeded — into the wrong
+// collection's sub-db. 31 documents in `smartbotic-automation` landed in
+// `executions`, `workflows` and `nsfw_temp` while declaring themselves to be
+// `image_hashes`, `sessions`, `users` and `workflows`.
+//
+// v2.4.3 removed the specific cache path that produced the stale handle. This
+// header addresses the deeper problem: nothing verified that a handle still
+// referred to the sub-db the caller asked for. A misbound handle was
+// indistinguishable from a good one, so the failure was silent and the damage
+// was only found when a user noticed `get` and `insert` disagreeing.
+//
+// The mechanism
+// -------------
+// Every sub-db carries one reserved key whose value is the sub-db's own name.
+// Writers verify it before operating through a cached handle. A misbound
+// handle now fails loudly at the point of harm instead of corrupting data.
+//
+// The key begins with a NUL byte so it cannot collide with a document id
+// (ids are UTF-8 text) nor with a vector key (also a document id). Readers,
+// scans and counts skip it — see `is_identity_key`.
+
+#pragma once
+
+#include <string>
+#include <string_view>
+
+namespace smartbotic::db::storage {
+
+class WriteTxn;
+class ReadTxn;
+
+// Reserved key holding the sub-db's own name. Leading NUL keeps it out of the
+// document-id keyspace entirely.
+inline constexpr std::string_view kSubdbIdentityKey{"\0__subdb_identity__", 19};
+
+// True if `key` is the reserved identity sentinel. Callers iterating a sub-db
+// (scan, scan_vectors, count) must skip keys for which this returns true so
+// the sentinel never surfaces as a document or inflates a count.
+inline bool is_identity_key(std::string_view key) {
+    return key == kSubdbIdentityKey;
+}
+
+// Stamp `name` into the sub-db addressed by `dbi`. Idempotent: rewrites the
+// same value if already present. Called when a sub-db handle is first opened
+// for write, so sub-dbs created before v2.4.4 acquire a sentinel on their next
+// write without needing a migration pass.
+void write_subdb_identity(WriteTxn& txn, unsigned int dbi, std::string_view name);
+
+// Verify that the sub-db addressed by `dbi` is the one called `expected`.
+//
+// Throws std::runtime_error on mismatch — a misbound handle. Returns normally
+// when the sentinel matches, and ALSO when no sentinel is present: a sub-db
+// written by v2.4.3 or earlier has none, and refusing to serve it would break
+// every existing deployment on upgrade. Absence is therefore "unknown", not
+// "wrong"; the first write stamps it and every subsequent write is checked.
+void verify_subdb_identity(WriteTxn& txn, unsigned int dbi, std::string_view expected);
+
+// Read the recorded name, or empty if unstamped. Used by the audit and the
+// reconciler to inspect without throwing.
+std::string read_subdb_identity(ReadTxn& txn, unsigned int dbi);
+
+}  // namespace smartbotic::db::storage

+ 340 - 0
service/src/storage/subdb_placement.cpp

@@ -0,0 +1,340 @@
+// v2.4.4 — offline sub-db placement audit and repair. See subdb_placement.hpp.
+
+#include "storage/subdb_placement.hpp"
+
+#include <lmdb.h>
+
+#include <algorithm>
+#include <cstring>
+#include <filesystem>
+#include <iostream>
+#include <map>
+#include <set>
+#include <stdexcept>
+
+#include <nlohmann/json.hpp>
+
+#include "storage/subdb_identity.hpp"
+
+namespace smartbotic::db::storage {
+
+namespace {
+
+namespace fs = std::filesystem;
+using nlohmann::json;
+
+// The sentinel key itself comes from storage/subdb_identity.hpp — constexpr,
+// so including it costs no link dependency. This file deliberately uses raw
+// mdb_* calls rather than the WriteTxn-based helpers there, because it also
+// builds into the CLI, which does not link the service storage stack.
+constexpr std::string_view kIdentityKey = kSubdbIdentityKey;
+
+std::string_view to_sv(const MDB_val& v) {
+    return std::string_view(static_cast<const char*>(v.mv_data), v.mv_size);
+}
+
+MDB_val to_val(std::string_view sv) {
+    return MDB_val{sv.size(), const_cast<char*>(sv.data())};
+}
+
+void ck(int rc, const char* where) {
+    if (rc != MDB_SUCCESS) {
+        throw std::runtime_error(std::string("LMDB ") + where + ": " + mdb_strerror(rc));
+    }
+}
+
+// A sub-db is "system" if it is internal bookkeeping rather than a user
+// collection: _meta, _vectors_*, _history_*, _files, _views, _orphans_*.
+bool is_system_subdb(std::string_view name) {
+    return !name.empty() && name.front() == '_';
+}
+
+// Infer the project from .../projects/<name>/env.
+std::string infer_project(const std::string& env_path) {
+    fs::path p(env_path);
+    if (p.filename() == "env" && p.has_parent_path()) {
+        return p.parent_path().filename().string();
+    }
+    return "default";
+}
+
+// Strip a leading "<project>:" if present.
+std::string dequalify(const std::string& name, const std::string& project) {
+    const std::string prefix = project + ":";
+    if (name.rfind(prefix, 0) == 0) return name.substr(prefix.size());
+    return name;
+}
+
+// Does a row declaring `declared` belong in the sub-db physically named
+// `physical`? Both the qualified and bare spellings are accepted, because the
+// envs in the field contain a mix of the two (the `default` project has
+// sub-dbs named both "conversations" and "default:mypure_test_col").
+bool declaration_matches(const std::string& declared,
+                         const std::string& physical,
+                         const std::string& project) {
+    if (declared == physical) return true;
+    if (dequalify(declared, project) == physical) return true;
+    if (declared == dequalify(physical, project)) return true;
+    return false;
+}
+
+std::vector<std::string> list_subdbs(MDB_txn* txn) {
+    std::vector<std::string> names;
+    MDB_dbi root = 0;
+    int rc = mdb_dbi_open(txn, nullptr, 0, &root);
+    if (rc == MDB_NOTFOUND) return names;
+    ck(rc, "dbi_open (root)");
+
+    MDB_cursor* cur = nullptr;
+    ck(mdb_cursor_open(txn, root, &cur), "cursor_open (root)");
+    MDB_val k{}, v{};
+    int crc = mdb_cursor_get(cur, &k, &v, MDB_FIRST);
+    while (crc == MDB_SUCCESS) {
+        names.emplace_back(to_sv(k));
+        crc = mdb_cursor_get(cur, &k, &v, MDB_NEXT);
+    }
+    mdb_cursor_close(cur);
+    std::sort(names.begin(), names.end());
+    return names;
+}
+
+// Pull the "collection" field out of a stored document without building the
+// whole tree twice. Returns empty on parse failure or missing field.
+std::string declared_collection_of(std::string_view payload) {
+    try {
+        json j = json::parse(payload);
+        if (!j.is_object()) return {};
+        auto it = j.find("collection");
+        if (it == j.end() || !it->is_string()) return {};
+        return it->get<std::string>();
+    } catch (const std::exception&) {
+        return {};
+    }
+}
+
+struct EnvHandle {
+    MDB_env* env = nullptr;
+    explicit EnvHandle(const std::string& path, bool readonly) {
+        ck(mdb_env_create(&env), "env_create");
+        ck(mdb_env_set_maxdbs(env, 512), "set_maxdbs");
+        // Match the service's map size ceiling; repair may add sub-dbs.
+        ck(mdb_env_set_mapsize(env, 2ULL << 30), "set_mapsize");
+        unsigned int flags = readonly ? MDB_RDONLY : 0;
+        int rc = mdb_env_open(env, path.c_str(), flags, 0664);
+        if (rc != MDB_SUCCESS) {
+            mdb_env_close(env);
+            env = nullptr;
+            ck(rc, "env_open");
+        }
+    }
+    ~EnvHandle() { if (env) mdb_env_close(env); }
+    EnvHandle(const EnvHandle&) = delete;
+    EnvHandle& operator=(const EnvHandle&) = delete;
+};
+
+}  // namespace
+
+AuditReport audit(const std::string& env_path, const std::string& project_in) {
+    AuditReport rep;
+    rep.project = project_in.empty() ? infer_project(env_path) : project_in;
+
+    EnvHandle eh(env_path, /*readonly=*/true);
+    MDB_txn* txn = nullptr;
+    ck(mdb_txn_begin(eh.env, nullptr, MDB_RDONLY, &txn), "txn_begin (audit)");
+
+    const auto all = list_subdbs(txn);
+
+    // First pass: which ids exist in which sub-db, so we can tell a move from
+    // a quarantine without a second env open.
+    std::map<std::string, std::set<std::string>> ids_by_subdb;
+
+    for (const auto& name : all) {
+        if (is_system_subdb(name)) continue;
+        rep.subdbs.push_back(name);
+
+        MDB_dbi dbi = 0;
+        if (mdb_dbi_open(txn, name.c_str(), 0, &dbi) != MDB_SUCCESS) continue;
+
+        // Identity sentinel present?
+        {
+            MDB_val k = to_val(kIdentityKey);
+            MDB_val v{};
+            if (mdb_get(txn, dbi, &k, &v) != MDB_SUCCESS) {
+                rep.unstamped.push_back(name);
+            }
+        }
+
+        MDB_cursor* cur = nullptr;
+        ck(mdb_cursor_open(txn, dbi, &cur), "cursor_open (audit)");
+        MDB_val k{}, v{};
+        int rc = mdb_cursor_get(cur, &k, &v, MDB_FIRST);
+        while (rc == MDB_SUCCESS) {
+            std::string_view key = to_sv(k);
+            if (key != kIdentityKey) {
+                ids_by_subdb[name].insert(std::string(key));
+                ++rep.rows_scanned;
+                const std::string declared = declared_collection_of(to_sv(v));
+                if (declared.empty()) {
+                    ++rep.rows_undecodable;
+                } else if (!declaration_matches(declared, name, rep.project)) {
+                    MisplacedRow m;
+                    m.physical_subdb = name;
+                    m.declared_collection = declared;
+                    m.id = std::string(key);
+                    rep.misplaced.push_back(std::move(m));
+                }
+            }
+            rc = mdb_cursor_get(cur, &k, &v, MDB_NEXT);
+        }
+        mdb_cursor_close(cur);
+    }
+
+    // Resolve each misplaced row's destination. Prefer an existing sub-db
+    // spelled exactly as declared; else the de-qualified spelling, which is the
+    // dominant convention. Then note whether the home is already occupied.
+    const std::set<std::string> existing(all.begin(), all.end());
+    for (auto& m : rep.misplaced) {
+        const std::string bare = dequalify(m.declared_collection, rep.project);
+        if (existing.count(m.declared_collection)) {
+            m.home_subdb = m.declared_collection;
+        } else if (existing.count(bare)) {
+            m.home_subdb = bare;
+        } else {
+            m.home_subdb = bare;  // will be created
+        }
+        auto it = ids_by_subdb.find(m.home_subdb);
+        m.home_occupied = (it != ids_by_subdb.end()) && it->second.count(m.id) > 0;
+    }
+
+    mdb_txn_abort(txn);
+    return rep;
+}
+
+RepairResult repair(const std::string& env_path,
+                    const AuditReport& report,
+                    bool stamp_identity) {
+    RepairResult res;
+    if (report.misplaced.empty() && !(stamp_identity && !report.unstamped.empty())) {
+        return res;
+    }
+
+    EnvHandle eh(env_path, /*readonly=*/false);
+    MDB_txn* txn = nullptr;
+    ck(mdb_txn_begin(eh.env, nullptr, 0, &txn), "txn_begin (repair)");
+
+    try {
+        // Cache handles for the duration of this single write txn. Safe here
+        // precisely because the txn commits at the end — the failure mode this
+        // whole tool exists to repair comes from caching across an ABORT.
+        std::map<std::string, MDB_dbi> handles;
+        auto open_db = [&](const std::string& name) -> MDB_dbi {
+            auto it = handles.find(name);
+            if (it != handles.end()) return it->second;
+            MDB_dbi dbi = 0;
+            ck(mdb_dbi_open(txn, name.c_str(), MDB_CREATE, &dbi), "dbi_open (repair)");
+            handles[name] = dbi;
+            return dbi;
+        };
+
+        for (const auto& m : report.misplaced) {
+            MDB_dbi src = open_db(m.physical_subdb);
+            MDB_val k = to_val(m.id);
+            MDB_val v{};
+            int rc = mdb_get(txn, src, &k, &v);
+            if (rc == MDB_NOTFOUND) {
+                res.errors.push_back("row vanished before repair: " +
+                                     m.physical_subdb + "/" + m.id);
+                continue;
+            }
+            ck(rc, "get (repair)");
+            // Copy out of the mmap before any write invalidates the page.
+            const std::string payload(static_cast<const char*>(v.mv_data), v.mv_size);
+
+            std::string dest;
+            if (m.home_occupied) {
+                // Never overwrite a document that is already correctly filed.
+                // Park the stray copy for manual comparison instead.
+                dest = "_orphans_" + m.physical_subdb;
+                ++res.quarantined;
+            } else {
+                dest = m.home_subdb;
+                ++res.moved;
+            }
+
+            MDB_dbi dst = open_db(dest);
+            MDB_val dk = to_val(m.id);
+            MDB_val dv = to_val(payload);
+            ck(mdb_put(txn, dst, &dk, &dv, 0), "put (repair)");
+
+            MDB_val delk = to_val(m.id);
+            int drc = mdb_del(txn, src, &delk, nullptr);
+            if (drc != MDB_SUCCESS && drc != MDB_NOTFOUND) {
+                ck(drc, "del (repair)");
+            }
+        }
+
+        // Identity sentinels — opt-in, because a pre-v2.4.4 server does not
+        // know to skip them and would choke on the extra key. See the header.
+        if (stamp_identity) {
+            std::set<std::string> to_stamp(report.unstamped.begin(),
+                                           report.unstamped.end());
+            for (const auto& m : report.misplaced) to_stamp.insert(m.home_subdb);
+            for (const auto& name : to_stamp) {
+                if (is_system_subdb(name)) continue;
+                MDB_dbi dbi = open_db(name);
+                MDB_val k = to_val(kIdentityKey);
+                MDB_val v = to_val(name);
+                ck(mdb_put(txn, dbi, &k, &v, 0), "put (identity stamp)");
+                ++res.stamped;
+            }
+        }
+
+        ck(mdb_txn_commit(txn), "txn_commit (repair)");
+    } catch (...) {
+        mdb_txn_abort(txn);
+        throw;
+    }
+
+    return res;
+}
+
+void print_audit(const AuditReport& rep) {
+    std::cout << "project:        " << rep.project << "\n"
+              << "sub-dbs:        " << rep.subdbs.size() << "\n"
+              << "rows scanned:   " << rep.rows_scanned << "\n";
+    if (rep.rows_undecodable) {
+        std::cout << "undecodable:    " << rep.rows_undecodable
+                  << "  (no parseable `collection` field — not repairable here)\n";
+    }
+    std::cout << "unstamped:      " << rep.unstamped.size()
+              << "  (sub-dbs with no identity sentinel)\n";
+
+    if (rep.misplaced.empty()) {
+        std::cout << "\nNo misplaced rows. Placement is consistent.\n";
+        return;
+    }
+
+    // Group for a readable summary rather than one line per row.
+    std::map<std::string, uint64_t> moves, quarantines;
+    for (const auto& m : rep.misplaced) {
+        const std::string key = m.physical_subdb + "  ->  " + m.home_subdb;
+        if (m.home_occupied) ++quarantines[key];
+        else ++moves[key];
+    }
+
+    std::cout << "\nMISPLACED ROWS: " << rep.misplaced.size() << "\n";
+    if (!moves.empty()) {
+        std::cout << "\n  relocate to declared home:\n";
+        for (const auto& [k, n] : moves) {
+            std::cout << "    " << n << "\t" << k << "\n";
+        }
+    }
+    if (!quarantines.empty()) {
+        std::cout << "\n  home already occupied - park in _orphans_<subdb>:\n";
+        for (const auto& [k, n] : quarantines) {
+            std::cout << "    " << n << "\t" << k << "\n";
+        }
+    }
+}
+
+}  // namespace smartbotic::db::storage

+ 80 - 0
service/src/storage/subdb_placement.hpp

@@ -0,0 +1,80 @@
+// v2.4.4 — offline sub-db placement audit and repair.
+//
+// Background
+// ----------
+// Before v2.4.3, LmdbDocumentStore cached an MDB_dbi opened inside a ReadTxn.
+// ReadTxn always aborts, so LMDB released the handle without bumping
+// `me_numdbs`, and the next mdb_dbi_open for an unrelated collection was
+// handed the same slot number. Writes through the stale cache entry then
+// succeeded into the WRONG sub-db.
+//
+// Each stored document records its own `collection`, so the damage is
+// self-describing: a row whose declared collection disagrees with the sub-db
+// it physically occupies was misdirected. That is the oracle used here — no
+// cross-referencing against MemoryStore or the WAL is required.
+//
+// Safety posture
+// --------------
+// - audit() is strictly read-only.
+// - repair() NEVER deletes a document outright. A row is moved to its declared
+//   home only when the home has no row under that id; when the home is already
+//   occupied, the stray copy is parked in `_orphans_<physical_subdb>` so an
+//   operator can compare the two by hand. Nothing is discarded.
+// - repair() requires the service to be stopped: it takes an LMDB write lock
+//   on the env and rewrites placement underneath any running reader.
+
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace smartbotic::db::storage {
+
+// One row sitting in a sub-db other than the one it declares.
+struct MisplacedRow {
+    std::string physical_subdb;      // where the row actually is
+    std::string declared_collection; // what the document says it is (may be qualified)
+    std::string home_subdb;          // resolved destination sub-db name
+    std::string id;                  // document key
+    bool home_occupied = false;      // home already holds this id -> quarantine
+};
+
+struct AuditReport {
+    std::string project;                    // project name used to de-qualify
+    std::vector<std::string> subdbs;        // non-system sub-dbs inspected
+    uint64_t rows_scanned = 0;
+    uint64_t rows_undecodable = 0;          // JSON parse failed / no collection field
+    std::vector<MisplacedRow> misplaced;
+    std::vector<std::string> unstamped;     // sub-dbs lacking an identity sentinel
+};
+
+// Inspect `env_path` read-only. `project` de-qualifies declared names of the
+// form "<project>:<collection>"; pass empty to infer it from the path
+// (.../projects/<name>/env).
+AuditReport audit(const std::string& env_path, const std::string& project);
+
+struct RepairResult {
+    uint64_t moved = 0;        // relocated to their declared home
+    uint64_t quarantined = 0;  // home occupied; parked in _orphans_<subdb>
+    uint64_t stamped = 0;      // identity sentinels written
+    std::vector<std::string> errors;
+};
+
+// Apply the repair described by `report` to `env_path`. The service MUST be
+// stopped.
+//
+// `stamp_identity` additionally writes an identity sentinel into every sub-db
+// so a future misbound handle fails loudly instead of silently corrupting
+// data. It is OPT-IN and REQUIRES the server binary to be >= v2.4.4:
+// earlier builds do not skip the sentinel when scanning, so they would try to
+// decode it as a document and throw, and their count() would be off by one per
+// collection. Repair placement first, upgrade, then stamp.
+RepairResult repair(const std::string& env_path,
+                    const AuditReport& report,
+                    bool stamp_identity);
+
+// Human-readable rendering of an audit, used by both the dry run and --apply.
+void print_audit(const AuditReport& report);
+
+}  // namespace smartbotic::db::storage

+ 33 - 0
tests/CMakeLists.txt

@@ -353,6 +353,7 @@ add_executable(test_document_store
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_txn.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_dbi.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/document_store_lmdb.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/subdb_identity.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/json_parse.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/doc_binary.cpp
 )
@@ -390,6 +391,7 @@ add_executable(test_migrate_v1_to_v2
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_txn.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_dbi.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/document_store_lmdb.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/subdb_identity.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/migrate_v1_to_v2.cpp
 )
 
@@ -444,6 +446,7 @@ add_executable(test_dual_write_mirror
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_txn.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_dbi.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/document_store_lmdb.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/subdb_identity.cpp
 )
 
 target_include_directories(test_dual_write_mirror PRIVATE
@@ -517,3 +520,33 @@ target_include_directories(test_project_crud PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
 )
 add_test(NAME project_crud COMMAND test_project_crud)
+
+# v2.4.4 — sub-db identity sentinel. Regression cover for the misbound-MDB_dbi
+# incident in which writes for one collection landed in another's sub-db.
+add_executable(test_subdb_identity
+    test_subdb_identity.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_env.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_txn.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_dbi.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/subdb_identity.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/document_store_lmdb.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/json_parse.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/doc_binary.cpp
+)
+
+target_include_directories(test_subdb_identity PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
+    ${LMDB_INCLUDE_DIR}
+    ${yyjson_INCLUDE_DIRS}
+)
+
+target_link_libraries(test_subdb_identity PRIVATE ${LMDB_LIBRARY})
+target_link_libraries(test_subdb_identity PRIVATE ${yyjson_LIBRARIES})
+
+if(TARGET nlohmann_json::nlohmann_json)
+    target_link_libraries(test_subdb_identity PRIVATE nlohmann_json::nlohmann_json)
+else()
+    target_include_directories(test_subdb_identity PRIVATE ${NLOHMANN_JSON_INCLUDE_DIRS})
+endif()
+
+add_test(NAME test_subdb_identity COMMAND test_subdb_identity)

+ 255 - 0
tests/test_subdb_identity.cpp

@@ -0,0 +1,255 @@
+// v2.4.4 — sub-db identity sentinel tests.
+//
+// Regression cover for the production incident in which an invalidated
+// MDB_dbi was reused after LMDB reassigned its slot, so writes aimed at
+// `image_hashes` landed in `executions` and succeeded silently. 31 documents
+// across smartbotic-automation ended up in a sub-db other than the one they
+// declared. See storage/subdb_identity.hpp for the full mechanism.
+//
+// The core test is `misbound_handle_is_refused`: it reproduces the misbinding
+// directly by handing the verifier a handle for a different sub-db, which is
+// what a stale cache entry amounts to.
+
+#include <cassert>
+#include <atomic>
+#include <filesystem>
+#include <iostream>
+#include <string>
+#include <unistd.h>
+
+#include <lmdb.h>
+#include <nlohmann/json.hpp>
+
+#include "document.hpp"
+#include "storage/document_store_lmdb.hpp"
+#include "storage/lmdb_env.hpp"
+#include "storage/lmdb_txn.hpp"
+#include "storage/subdb_identity.hpp"
+
+namespace fs = std::filesystem;
+
+using smartbotic::database::Document;
+using smartbotic::db::storage::is_identity_key;
+using smartbotic::db::storage::kSubdbIdentityKey;
+using smartbotic::db::storage::LmdbDocumentStore;
+using smartbotic::db::storage::LmdbEnv;
+using smartbotic::db::storage::LmdbEnvOpts;
+using smartbotic::db::storage::read_subdb_identity;
+using smartbotic::db::storage::ReadTxn;
+using smartbotic::db::storage::verify_subdb_identity;
+using smartbotic::db::storage::write_subdb_identity;
+using smartbotic::db::storage::WriteTxn;
+
+namespace {
+
+int g_pass = 0;
+int g_fail = 0;
+
+void check(bool cond, const char* msg) {
+    if (cond) {
+        ++g_pass;
+    } else {
+        ++g_fail;
+        std::cerr << "FAIL: " << msg << "\n";
+    }
+}
+
+std::string make_tmpdir(const char* tag) {
+    static std::atomic<int> counter{0};
+    std::string path = "/tmp/subdb-identity-test-" + std::to_string(::getpid()) +
+                       "-" + std::to_string(counter.fetch_add(1)) + "-" + tag;
+    std::error_code ec;
+    fs::remove_all(path, ec);
+    return path;
+}
+
+struct TmpEnv {
+    std::string path;
+    LmdbEnv env;
+    explicit TmpEnv(const char* tag)
+        : path(make_tmpdir(tag)),
+          env(LmdbEnvOpts{path, 64ULL << 20, 256, 126, false}) {}
+    ~TmpEnv() {
+        std::error_code ec;
+        fs::remove_all(path, ec);
+    }
+    TmpEnv(const TmpEnv&) = delete;
+    TmpEnv& operator=(const TmpEnv&) = delete;
+};
+
+// Open (creating) a named sub-db inside a write txn and return its handle.
+unsigned int open_subdb(WriteTxn& txn, const char* name) {
+    MDB_dbi dbi = 0;
+    int rc = mdb_dbi_open(txn.raw(), name, MDB_CREATE, &dbi);
+    assert(rc == MDB_SUCCESS);
+    (void)rc;
+    return dbi;
+}
+
+Document make_doc(const std::string& id, const std::string& collection) {
+    Document d;
+    d.id = id;
+    d.collection = collection;
+    d.set_data(nlohmann::json{{"seenCount", 1}, {"who", collection}});
+    return d;
+}
+
+// -------------------------------------------------------------------------
+
+void test_sentinel_roundtrip() {
+    TmpEnv t("roundtrip");
+    {
+        WriteTxn w(t.env);
+        unsigned int dbi = open_subdb(w, "image_hashes");
+        write_subdb_identity(w, dbi, "image_hashes");
+        w.commit();
+    }
+    {
+        WriteTxn w(t.env);
+        unsigned int dbi = open_subdb(w, "image_hashes");
+        bool threw = false;
+        try {
+            verify_subdb_identity(w, dbi, "image_hashes");
+        } catch (const std::exception&) {
+            threw = true;
+        }
+        check(!threw, "matching sentinel must verify without throwing");
+        w.commit();
+    }
+    {
+        ReadTxn r(t.env);
+        MDB_dbi dbi = 0;
+        mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
+        check(read_subdb_identity(r, dbi) == "image_hashes",
+              "read_subdb_identity returns the stamped name");
+    }
+}
+
+// THE regression test. A stale cache entry is, in effect, a handle that
+// addresses someone else's sub-db. Hand the verifier exactly that.
+void test_misbound_handle_is_refused() {
+    TmpEnv t("misbound");
+    unsigned int executions_dbi = 0;
+    {
+        WriteTxn w(t.env);
+        unsigned int ih = open_subdb(w, "image_hashes");
+        write_subdb_identity(w, ih, "image_hashes");
+        executions_dbi = open_subdb(w, "executions");
+        write_subdb_identity(w, executions_dbi, "executions");
+        w.commit();
+    }
+
+    WriteTxn w(t.env);
+    // Re-open so the handle is valid in this txn, then deliberately verify it
+    // under the WRONG name — the production misbinding, reproduced.
+    unsigned int exec = open_subdb(w, "executions");
+    bool threw = false;
+    std::string msg;
+    try {
+        verify_subdb_identity(w, exec, "image_hashes");
+    } catch (const std::exception& e) {
+        threw = true;
+        msg = e.what();
+    }
+    check(threw, "handle for 'executions' verified as 'image_hashes' must throw");
+    check(msg.find("image_hashes") != std::string::npos &&
+              msg.find("executions") != std::string::npos,
+          "misbinding error names both the requested and actual sub-db");
+    w.abort();
+}
+
+// Existing deployments have sub-dbs with no sentinel. Those must keep working.
+void test_unstamped_subdb_is_permitted() {
+    TmpEnv t("unstamped");
+    WriteTxn w(t.env);
+    unsigned int dbi = open_subdb(w, "legacy");
+    bool threw = false;
+    try {
+        verify_subdb_identity(w, dbi, "legacy");
+    } catch (const std::exception&) {
+        threw = true;
+    }
+    check(!threw, "sub-db without a sentinel must verify (absence is unknown, not wrong)");
+    w.commit();
+}
+
+void test_identity_key_predicate() {
+    check(is_identity_key(kSubdbIdentityKey), "sentinel key recognised");
+    check(!is_identity_key("__subdb_identity__"),
+          "same text without the leading NUL is NOT the sentinel");
+    check(!is_identity_key("e63c1b90"), "a document id is not the sentinel");
+    check(kSubdbIdentityKey[0] == '\0',
+          "sentinel must start with NUL so it cannot collide with a doc id");
+}
+
+// The sentinel is an implementation detail: it must never surface through the
+// DocumentStore API as a document, nor inflate a count.
+void test_sentinel_invisible_through_store() {
+    TmpEnv t("invisible");
+    LmdbDocumentStore store(t.env);
+
+    store.put("image_hashes", "aaa", make_doc("aaa", "image_hashes"));
+    store.put("image_hashes", "bbb", make_doc("bbb", "image_hashes"));
+
+    check(store.count("image_hashes") == 2,
+          "count() must exclude the identity sentinel");
+
+    smartbotic::database::Query q;
+    q.limit = 100;
+    auto res = store.scan("image_hashes", q);
+    check(res.documents.size() == 2, "scan() must exclude the identity sentinel");
+    check(res.total_matched == 2, "scan() total_matched must exclude the sentinel");
+
+    for (const auto& d : res.documents) {
+        check(d.id == "aaa" || d.id == "bbb",
+              "scan() must not surface the sentinel as a document");
+    }
+
+    // And it really is on disk.
+    ReadTxn r(t.env);
+    MDB_dbi dbi = 0;
+    int rc = mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
+    check(rc == MDB_SUCCESS, "sub-db exists");
+    check(read_subdb_identity(r, dbi) == "image_hashes",
+          "store.put() stamps the sentinel on first write");
+}
+
+// A vector sub-db gets stamped with its own (prefixed) name, and scan_vectors
+// must skip the sentinel rather than trying to read it as float32 bytes.
+void test_vector_subdb_sentinel() {
+    TmpEnv t("vectors");
+    LmdbDocumentStore store(t.env);
+    store.put_vector("emb", "v1", {1.0f, 2.0f, 3.0f});
+    store.put_vector("emb", "v2", {4.0f, 5.0f, 6.0f});
+
+    int seen = 0;
+    bool bad = false;
+    store.scan_vectors("emb", [&](std::string_view id, const float*, size_t n) {
+        ++seen;
+        if (n != 3) bad = true;
+        if (is_identity_key(id)) bad = true;
+    });
+    check(seen == 2, "scan_vectors must skip the sentinel");
+    check(!bad, "scan_vectors must not decode the sentinel as float data");
+
+    ReadTxn r(t.env);
+    MDB_dbi dbi = 0;
+    mdb_dbi_open(r.raw(), "_vectors_emb", 0, &dbi);
+    check(read_subdb_identity(r, dbi) == "_vectors_emb",
+          "vector sub-db is stamped with its prefixed name");
+}
+
+}  // namespace
+
+int main() {
+    std::cout << "=== test_subdb_identity ===\n";
+    test_sentinel_roundtrip();
+    test_misbound_handle_is_refused();
+    test_unstamped_subdb_is_permitted();
+    test_identity_key_predicate();
+    test_sentinel_invisible_through_store();
+    test_vector_subdb_sentinel();
+
+    std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
+    return g_fail == 0 ? 0 : 1;
+}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff