Jelajahi Sumber

feat(relations): T7 - bootstrap scan and relations check

Declaring a relation never indexed rows already present in the child
collection - the reverse index only saw writes made after declaration,
so a parent delete against pre-existing data was silently permitted.

- build_relation_index(relation, childCollection, childField) mirrors
  build_index: one read-txn walk of the child collection (yyjson-only
  field resolution, no Document materialisation), one write-txn backfill
  of the reverse index via MDB_NODUPDATA (idempotent by construction).
- CreateRelation now runs the bootstrap scan after arming the write path,
  before reporting success, and returns rows_indexed (mirrors
  CreateIndexResponse.rows_indexed).
- applyRelationDeclarations() self-heals a relation whose reverse index
  sub-db is missing at boot, mirroring applyIndexDeclarations()'s existing
  self-heal for secondary indexes - same reasoning: a declared-but-empty
  index is exactly the bug this task closes.
- New read-only CheckRelation RPC / Client::checkRelation() / CLI
  relation-check <name>: walks the reverse index and reports parent ids
  referenced by children that do not exist, without mutating anything.
  Walks the TOP LEVEL of the index and skips reserved keys with
  is_index_meta_key() (not is_identity_key()) - the exact spot Task 2's
  deferred finding flagged as where a real sentinel collision is possible.
  Capped entries, exact total.

Tests: 8 new cases in tests/test_relation_index.cpp (build_relation_index
idempotency/array-fields/absent-collection, check_relation_dangling
finds/ignores/caps). test_relation_index 43/43, test_relation_enforcement
45/45, test_subdb_identity 236/236.

Manually verified against a live server: write parent+child, THEN declare
the relation, confirm a parent delete (previously silently permitted) is
now refused; relation-check finds a genuine dangling reference produced by
disabling enforcement and deleting through it.
fszontagh 1 bulan lalu
induk
melakukan
01051b7877

+ 48 - 2
cli/main.cpp

@@ -92,6 +92,8 @@ void printUsage() {
               << "  " << C_CYAN << "relation-create" << C_RESET
               << " <name> <child> <child_field> <parent> [on_delete] [validate_on_write]\n"
               << "  " << C_CYAN << "relation-drop" << C_RESET << " <name>\n"
+              << "  " << C_CYAN << "relation-check" << C_RESET << " <name>"
+              << "                    Report dangling references (read-only)\n"
               << "  " << C_CYAN << "configure-relations" << C_RESET
               << " <collection> <on|off>   Enable/disable enforcement for a collection\n"
               << "  " << C_CYAN << "describe-delete" << C_RESET << " <collection> <id>"
@@ -720,13 +722,18 @@ bool execCommand(smartbotic::database::Client& client,
             const std::string onDelete = params.size() > 4 ? params[4] : "restrict";
             const bool validateOnWrite = params.size() > 5
                 && (params[5] == "true" || params[5] == "1" || params[5] == "yes");
+            // v2.11.0 T7 - the server backfills the reverse index over rows
+            // already in the child collection as part of this call, so
+            // rowsIndexed reports coverage the same way index-create does.
+            uint64_t rowsIndexed = 0;
             if (!client.createRelation(params[0], params[1], params[2], params[3],
-                                       onDelete, validateOnWrite)) {
+                                       onDelete, validateOnWrite, rowsIndexed)) {
                 printError("could not create the relation (see the service log)");
                 return false;
             }
             std::cout << "declared relation " << params[0] << " (" << params[1] << "."
-                      << params[2] << " -> " << params[3] << ", on_delete=" << onDelete << ")\n";
+                      << params[2] << " -> " << params[3] << ", on_delete=" << onDelete << ")\n"
+                      << "indexed " << rowsIndexed << " existing row(s) in " << params[1] << "\n";
             return true;
         }
 
@@ -740,6 +747,45 @@ bool execCommand(smartbotic::database::Client& client,
             return true;
         }
 
+        // v2.11.0 T7 - "does this relation's reverse index actually match
+        // live data?" Read-only, changes nothing. Walks the whole reverse
+        // index (cost is proportional to distinct parents referenced, not to
+        // the child collection's size) so this is a migration/operator tool,
+        // not something to run in a loop.
+        if (cmd == "relation-check") {
+            if (params.empty()) { printError("usage: relation-check <name>"); return false; }
+            auto result = client.checkRelation(params[0]);
+            if (!result.success) {
+                printError("could not check the relation: " + result.error);
+                return false;
+            }
+            if (result.totalDangling == 0) {
+                std::cout << C_GREEN << "clean" << C_RESET << " - no dangling references for "
+                          << params[0] << "\n";
+                return true;
+            }
+            std::cout << C_RED << result.totalDangling << " dangling reference(s)" << C_RESET
+                      << " for " << params[0] << ":\n";
+            for (const auto& d : result.dangling) {
+                std::cout << "  parent " << d.parentId << " does not exist, referenced by "
+                          << d.childCount << " child document(s)";
+                if (!d.sampleChildIds.empty()) {
+                    std::cout << " (e.g. ";
+                    for (size_t i = 0; i < d.sampleChildIds.size(); ++i) {
+                        if (i) std::cout << ", ";
+                        std::cout << d.sampleChildIds[i];
+                    }
+                    std::cout << ")";
+                }
+                std::cout << "\n";
+            }
+            if (result.dangling.size() < result.totalDangling) {
+                std::cout << "  ... " << (result.totalDangling - result.dangling.size())
+                          << " more not shown\n";
+            }
+            return true;
+        }
+
         // v2.11.0 T8 — the only reachable path to relations_enforced besides
         // grpcurl. A partial update: touches only this one knob.
         if (cmd == "configure-relations") {

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

@@ -762,6 +762,22 @@ public:
                         const std::string& onDelete = "restrict",
                         bool validateOnWrite = false);
 
+    /**
+     * Same as createRelation() above, but also reports how many existing
+     * rows in `childCollection` the server's bootstrap scan indexed (v2.11.0
+     * T7). Declaring a relation over a collection that already has data
+     * backfills the reverse index immediately - this is how a caller learns
+     * that happened and how much it covered, the same shape as
+     * createIndex(...,rowsIndexed).
+     *
+     * An OVERLOAD, not a default out-parameter added to the call above and
+     * not a struct member - same ABI reasoning as createIndex.
+     */
+    bool createRelation(const std::string& name, const std::string& childCollection,
+                        const std::string& childField, const std::string& parentCollection,
+                        const std::string& onDelete, bool validateOnWrite,
+                        uint64_t& rowsIndexed);
+
     /**
      * Drop a relation by name. Admin-only.
      */
@@ -841,6 +857,45 @@ public:
     [[nodiscard]] DescribeDeleteResult describeDelete(const std::string& collection,
                                                        const std::string& id);
 
+    /**
+     * One dangling reference found by checkRelation(): a child row points at
+     * a parent id that does not exist. A brand-new struct, not a member
+     * added to an existing one - safe under the unchanged soname.
+     */
+    struct DanglingReference {
+        std::string parentId;
+        uint64_t childCount = 0;                   // children of this parent, from the index
+        std::vector<std::string> sampleChildIds;    // at most five
+    };
+
+    /**
+     * Result of checkRelation(): every dangling parent id it found, capped
+     * (see the server implementation) with `totalDangling` giving the exact
+     * count so the cap never understates the problem silently.
+     */
+    struct CheckRelationResult {
+        bool success = false;
+        std::string error;
+        uint64_t totalDangling = 0;
+        std::vector<DanglingReference> dangling;    // capped
+    };
+
+    /**
+     * "Does this relation's reverse index actually match live data?" v2.11.0
+     * T7 - the bootstrap-scan gap this task closes. A relation declared over
+     * a collection that already had rows is backfilled by createRelation()
+     * itself now, but a relation restored from a snapshot predating that fix,
+     * or one whose index sub-db was otherwise lost, can still carry stale
+     * postings for parent ids that no longer exist. This walks the reverse
+     * index and reports them. Read-only - changes nothing.
+     *
+     * ⚠ Cost is proportional to the number of DISTINCT parents referenced,
+     * not `childCollection`'s row count, but that is still a full index
+     * walk. Treat this as an operator/migration tool, not something to call
+     * on a hot path or in a request-serving loop.
+     */
+    [[nodiscard]] CheckRelationResult checkRelation(const std::string& name);
+
     // ===== Event Subscription =====
 
     using EventCallback = std::function<void(const std::string& collection,

+ 52 - 2
client/src/client.cpp

@@ -1437,7 +1437,8 @@ public:
 
     bool createRelation(const std::string& name, const std::string& childCollection,
                         const std::string& childField, const std::string& parentCollection,
-                        const std::string& onDelete, bool validateOnWrite) {
+                        const std::string& onDelete, bool validateOnWrite,
+                        uint64_t* rowsIndexed) {
         smartbotic::databasepb::CreateRelationRequest request;
         // name, child AND parent all need qualifying - qualifying only
         // `child`/`parent` but not `name` is exactly the v2.4.2 createView
@@ -1463,6 +1464,9 @@ public:
             spdlog::error("Client::createRelation rejected: {}", response.error());
             return false;
         }
+        // v2.11.0 T7 - the server's bootstrap scan already backfilled the
+        // reverse index over any rows present in childCollection.
+        if (rowsIndexed != nullptr) *rowsIndexed = response.rows_indexed();
         return true;
     }
 
@@ -1562,6 +1566,40 @@ public:
         return out;
     }
 
+    // v2.11.0 T7 - "does this relation's reverse index actually match live
+    // data?" Read-only; qualified like the other relation-management calls
+    // (getRelationInfo etc.), since a relation name is admin-scoped the same
+    // way regardless of which call reads it.
+    Client::CheckRelationResult checkRelation(const std::string& name) {
+        smartbotic::databasepb::CheckRelationRequest request;
+        request.set_name(qualify(name));
+
+        smartbotic::databasepb::CheckRelationResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        Client::CheckRelationResult out;
+        auto status = stub_->CheckRelation(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::checkRelation failed: {}", status.error_message());
+            out.success = false;
+            out.error = status.error_message();
+            return out;
+        }
+        out.success = response.success();
+        out.error = response.error();
+        out.totalDangling = response.total_dangling();
+        out.dangling.reserve(response.dangling_size());
+        for (const auto& pbd : response.dangling()) {
+            Client::DanglingReference d;
+            d.parentId = pbd.parent_id();
+            d.childCount = pbd.child_count();
+            d.sampleChildIds.assign(pbd.sample_child_ids().begin(), pbd.sample_child_ids().end());
+            out.dangling.push_back(std::move(d));
+        }
+        return out;
+    }
+
     // ===== Event Subscription =====
 
     class SubscriptionHandle {
@@ -2419,7 +2457,15 @@ bool Client::createRelation(const std::string& name, const std::string& childCol
                             const std::string& childField, const std::string& parentCollection,
                             const std::string& onDelete, bool validateOnWrite) {
     return impl_->createRelation(name, childCollection, childField, parentCollection,
-                                 onDelete, validateOnWrite);
+                                 onDelete, validateOnWrite, nullptr);
+}
+
+bool Client::createRelation(const std::string& name, const std::string& childCollection,
+                            const std::string& childField, const std::string& parentCollection,
+                            const std::string& onDelete, bool validateOnWrite,
+                            uint64_t& rowsIndexed) {
+    return impl_->createRelation(name, childCollection, childField, parentCollection,
+                                 onDelete, validateOnWrite, &rowsIndexed);
 }
 
 bool Client::dropRelation(const std::string& name) {
@@ -2447,6 +2493,10 @@ Client::DescribeDeleteResult Client::describeDelete(const std::string& collectio
     return impl_->describeDelete(collection, id);
 }
 
+Client::CheckRelationResult Client::checkRelation(const std::string& name) {
+    return impl_->checkRelation(name);
+}
+
 std::shared_ptr<void> Client::subscribe(const std::vector<std::string>& collections, EventCallback callback) {
     return impl_->subscribe(collections, std::move(callback));
 }

+ 48 - 0
proto/database.proto

@@ -83,6 +83,20 @@ service DatabaseService {
     // it. Read-only and gated as an ordinary per-collection read, not admin.
     rpc DescribeDelete(DescribeDeleteRequest) returns (DescribeDeleteResponse);
 
+    // v2.11.0 T7 — "does this relation's reverse index actually match live
+    // data?" A relation declared over a collection that already had rows
+    // before v2.11.0 T7 shipped (or one whose sub-db was lost/restored from
+    // an older snapshot) can carry postings for parent ids that no longer
+    // exist. This walks the reverse index and probes each parent id;
+    // mutates nothing. ⚠ Cost is proportional to the number of DISTINCT
+    // parents referenced, not collection size, but on a very large child
+    // collection that is still a full index walk - treat this as an
+    // operator/migration tool, not something to call on a hot path.
+    // Gated as an ordinary per-collection read on the relation's CHILD
+    // collection (same reasoning as DescribeDelete: it changes nothing, but
+    // reports facts about data in that collection).
+    rpc CheckRelation(CheckRelationRequest) returns (CheckRelationResponse);
+
     // Collection configuration
     rpc ConfigureCollection(ConfigureCollectionRequest) returns (ConfigureCollectionResponse);
 
@@ -934,6 +948,12 @@ message CreateRelationRequest {
 message CreateRelationResponse {
     bool success = 1;
     string error = 2;
+    // v2.11.0 T7 — rows indexed by the bootstrap scan. Declaring a relation
+    // over a collection that already has rows backfills the reverse index
+    // immediately (mirrors CreateIndexResponse.rows_indexed), so enforcement
+    // covers pre-existing children from the moment the relation is declared,
+    // not only writes made afterwards.
+    uint64 rows_indexed = 3;
 }
 
 message DropRelationRequest {
@@ -992,6 +1012,34 @@ message DescribeDeleteResponse {
     repeated RelationImpact impacts = 4;
 }
 
+// v2.11.0 T7 — the bootstrap-scan gap. Declaring a relation with T3 alone
+// only maintains the reverse index going FORWARD from writes made after
+// declaration; rows already in the child collection were never indexed, so
+// enforcement silently missed them. This RPC answers "is that still true
+// right now, for this relation" without changing anything.
+message CheckRelationRequest {
+    string name = 1;              // relation name, project-qualified like the others
+}
+
+// One parent id referenced by the child collection's data, whose parent
+// document does not exist.
+message DanglingReference {
+    string parent_id = 1;
+    uint64 child_count = 2;             // children of this parent, from the index
+    repeated string sample_child_ids = 3;   // at most five
+}
+
+message CheckRelationResponse {
+    bool success = 1;
+    string error = 2;
+    // Every dangling parent id found, not just the reported sample - so a
+    // capped `dangling` list below does not silently understate the problem.
+    uint64 total_dangling = 3;
+    // Capped (see the server implementation) so a badly out-of-sync relation
+    // cannot blow up the response size; total_dangling is the true count.
+    repeated DanglingReference dangling = 4;
+}
+
 // ===== Collection Configuration =====
 
 // Per-collection configuration for timestamp precision and other runtime knobs.

+ 106 - 0
service/src/database_grpc_impl.cpp

@@ -2741,6 +2741,45 @@ grpc::Status DatabaseGrpcImpl::CreateRelation(
         // (and every delete on this child silently succeeds) until the next
         // restart's DatabaseService::applyRelationDeclarations().
         armRelationsForChild(r.child);
+
+        // v2.11.0 T7 — bootstrap scan. Arming above only maintains the index
+        // going FORWARD from writes made after this point; rows already in
+        // `child` were never indexed, so without this a parent delete would
+        // silently find zero children for every one of them. Backfill BEFORE
+        // reporting success, for the same reason CreateIndex backfills
+        // before declaring: between "declared" and "populated" the index
+        // would answer queries/deletes with an incomplete view, and there is
+        // no state where that is a safe thing to expose.
+        //
+        // ⚠ Blocks for the duration of a full scan over `child` - see the
+        // RPC docstring. Fine for admin/CreateRelation (already synchronous
+        // and admin-gated); a large pre-existing collection belongs behind a
+        // migration window, not a request an interactive caller is waiting
+        // on.
+        try {
+            const auto rn = smartbotic::database::resolveCollection(r.name);
+            const auto rc2 = smartbotic::database::resolveCollection(r.child);
+            auto* ds = service_.docStore(rc2.project);
+            auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
+            if (lmdb != nullptr) {
+                const uint64_t rows =
+                    lmdb->build_relation_index(rn.collection, rc2.collection, r.childField);
+                response->set_rows_indexed(rows);
+                spdlog::info("v2.11 relations: bootstrap-indexed {} existing row(s) for '{}' "
+                             "({}.{} -> {})",
+                             rows, r.name, r.child, r.childField, r.parent);
+            }
+        } catch (const std::exception& e) {
+            // The declaration is already persisted and armed for future
+            // writes; failing to backfill existing rows must not roll that
+            // back (the relation is still an improvement over nothing, and
+            // `relations check` can find what the backfill missed). Loud,
+            // because a skipped backfill means pre-existing children stay
+            // invisible to enforcement until re-run.
+            spdlog::error("v2.11 relations: bootstrap scan failed for '{}': {}",
+                          r.name, e.what());
+        }
+
         return grpc::Status::OK;
     } catch (const std::exception& e) {
         return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
@@ -2899,6 +2938,73 @@ grpc::Status DatabaseGrpcImpl::DescribeDelete(
     }
 }
 
+// v2.11.0 T7 — "does this relation's reverse index actually match live
+// data?" See the RPC docstring in the proto: read-only, walks the whole
+// index (cost proportional to distinct parents referenced, not to
+// `child`'s size), and reports parent ids the child collection references
+// that do not exist in the parent collection.
+grpc::Status DatabaseGrpcImpl::CheckRelation(
+    grpc::ServerContext* context,
+    const pb::CheckRelationRequest* request,
+    pb::CheckRelationResponse* response
+) {
+    auto r = relation_manager_.getRelation(request->name());
+    if (!r) {
+        response->set_success(false);
+        response->set_error("relation '" + request->name() + "' does not exist");
+        return grpc::Status::OK;
+    }
+
+    // Gated as an ordinary per-collection READ on the CHILD collection, not
+    // admin - same reasoning as DescribeDelete: this changes nothing, but
+    // the dangling parent ids and sample child ids it reports are facts
+    // about data in `child`.
+    smartbotic::database::Decision dec;
+    if (auto st = gate(context, r->child, smartbotic::database::Access::Read, dec); !st.ok()) {
+        return st;
+    }
+
+    try {
+        const auto rn = smartbotic::database::resolveCollection(r->name);
+        const auto rp = smartbotic::database::resolveCollection(r->parent);
+        auto* ds = service_.docStore(rp.project);
+        if (ds == nullptr) {
+            response->set_success(false);
+            response->set_error("no storage for project '" + rp.project + "'");
+            return grpc::Status::OK;
+        }
+        auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
+        if (lmdb == nullptr) {
+            response->set_success(false);
+            response->set_error("relations require the LMDB substrate");
+            return grpc::Status::OK;
+        }
+
+        // Capped so a badly out-of-sync relation cannot blow up the response;
+        // total_dangling (computed over every parent id, not just the
+        // reported sample) is what tells the caller the cap was hit.
+        constexpr size_t kMaxReported = 100;
+        const auto result =
+            lmdb->check_relation_dangling(rn.collection, rp.collection, kMaxReported);
+
+        response->set_success(true);
+        response->set_total_dangling(result.total);
+        for (const auto& d : result.entries) {
+            auto* pbd = response->add_dangling();
+            pbd->set_parent_id(d.parentId);
+            pbd->set_child_count(d.childCount);
+            for (const auto& c : d.sampleChildIds) pbd->add_sample_child_ids(c);
+        }
+        return grpc::Status::OK;
+    } catch (const std::invalid_argument& e) {
+        response->set_success(false);
+        response->set_error(e.what());
+        return grpc::Status::OK;
+    } catch (const std::exception& e) {
+        return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
+    }
+}
+
 // ===== DatabaseReplicationGrpcImpl =====
 
 DatabaseReplicationGrpcImpl::DatabaseReplicationGrpcImpl(

+ 9 - 0
service/src/database_grpc_impl.hpp

@@ -299,6 +299,15 @@ public:
         pb::DescribeDeleteResponse* response
     ) override;
 
+    // v2.11.0 T7 — dangling-reference report for one relation. Read-only,
+    // mutates nothing; gated as an ordinary per-collection read on the
+    // relation's CHILD collection, not admin.
+    grpc::Status CheckRelation(
+        grpc::ServerContext* context,
+        const pb::CheckRelationRequest* request,
+        pb::CheckRelationResponse* response
+    ) override;
+
     // ===== Collection Config Operations =====
 
     // v2.9.0 — secondary index management.

+ 20 - 0
service/src/database_service.cpp

@@ -420,6 +420,26 @@ void DatabaseService::applyRelationDeclarations() {
             ++applied;
             spdlog::info("v2.11 relations: {} relation(s) active with child '{}:{}'",
                          refs.size(), project, collection);
+
+            // v2.11.0 T7 self-heal — mirrors applyIndexDeclarations()'s
+            // rebuild of a declared-but-absent index sub-db, for the same
+            // reason: a relation whose reverse index sub-db is missing is
+            // exactly the "declared but not enforcing" bug this task exists
+            // to close. Reachable paths that leave a relation in this state:
+            // a snapshot/backup restored from before the sub-db existed, or
+            // an operator manually dropping the `_relidx1_*` sub-db (there
+            // is no dropRelation-only-the-index tool). CreateRelation's own
+            // bootstrap scan (see database_grpc_impl.cpp) covers the normal
+            // declare-time path; this covers everything else.
+            for (const auto& ref : refs) {
+                if (lmdb->relation_index_exists(ref.name)) continue;
+                const uint64_t rows =
+                    lmdb->build_relation_index(ref.name, collection, ref.childField);
+                spdlog::warn("v2.11 relations: rebuilt '{}' over {} row(s) in '{}:{}' - the "
+                             "relation was declared but its reverse index sub-db was absent "
+                             "(restored snapshot predating it, or a manual removal)",
+                             ref.name, rows, project, collection);
+            }
         } catch (const std::exception& e) {
             spdlog::error("v2.11 relations: could not apply declarations for '{}:{}': {}",
                           project, collection, e.what());

+ 160 - 0
service/src/storage/document_store_lmdb.cpp

@@ -1243,6 +1243,166 @@ LmdbDocumentStore::relation_index_children(std::string_view relation,
     return out;
 }
 
+// -------------------------------------------------------------------------
+// v2.11.0 T7 — the bootstrap-scan gap.
+//
+// T3 wired relation_index_add/remove into put()/del(), but that only
+// maintains the index going FORWARD from the moment set_relations() arms a
+// collection. Declaring a relation over a collection that already had rows
+// (the common case - a relation is usually declared once the schema is
+// known, not on day one of an empty collection) never indexed those rows:
+// the reverse index started empty and stayed empty for every pre-existing
+// child, so a parent delete against it silently found zero children and
+// went through as if nothing referenced it. Worse than no feature, because
+// declaring the relation looked like protection.
+//
+// build_relation_index closes that gap the same way build_index closes it
+// for secondary indexes: walk the child collection once, resolve
+// childField with yyjson only (never materialise a Document - the same
+// reasoning as maintainRelations), and post every reference found. Idempotent
+// via MDB_NODUPDATA, so calling this twice over an already-indexed relation
+// (e.g. CreateRelation re-run, or an operator re-running a migration) posts
+// no duplicates.
+// -------------------------------------------------------------------------
+
+uint64_t LmdbDocumentStore::build_relation_index(std::string_view relation,
+                                                  std::string_view childCollection,
+                                                  const std::string& childField) {
+    std::vector<std::pair<std::string, std::string>> rows;   // id -> payload
+    {
+        ReadTxn rtxn(env_);
+        auto dbi_opt = try_open_for_read(rtxn, childCollection);
+        if (!dbi_opt) return 0;
+        MDB_cursor* cur = nullptr;
+        mdb_check(mdb_cursor_open(rtxn.raw(), *dbi_opt, &cur), "cursor_open (relbuild)");
+        struct G { MDB_cursor* c; ~G() { if (c) mdb_cursor_close(c); } } g{cur};
+        MDB_val k{0, nullptr};
+        MDB_val v{0, nullptr};
+        int rc = mdb_cursor_get(cur, &k, &v, MDB_FIRST);
+        while (rc == MDB_SUCCESS) {
+            if (!is_identity_key(to_sv(k))) {
+                rows.emplace_back(std::string(to_sv(k)), std::string(to_sv(v)));
+            }
+            rc = mdb_cursor_get(cur, &k, &v, MDB_NEXT);
+        }
+    }
+
+    const std::string sub = relation_index_subdb(relation);
+    uint64_t indexed = 0;
+    {
+        WriteTxn wtxn(env_);
+        const unsigned int dbi = open_for_write(wtxn, sub, MDB_DUPSORT);
+        for (const auto& [id, payload] : rows) {
+            yyjson_doc* d = yyjson_read(payload.data(), payload.size(), 0);
+            if (!d) continue;
+            auto val = resolve_from_yyjson(yyjson_doc_get_root(d), childField);
+            yyjson_doc_free(d);
+            const auto parentIds = extract_relation_ids(val);
+            if (parentIds.empty()) continue;   // absent/null/unresolvable - not a reference
+            for (const auto& parentId : parentIds) {
+                MDB_val pk = to_val(parentId);
+                MDB_val cv = to_val(id);
+                const int rc = mdb_put(wtxn.raw(), dbi, &pk, &cv, MDB_NODUPDATA);
+                if (rc != MDB_SUCCESS && rc != MDB_KEYEXIST) {
+                    throw_mdb(rc, "relation index build put");
+                }
+            }
+            ++indexed;
+        }
+        wtxn.commit();
+        // MANDATORY, same as relation_index_add - the sub-db may be brand
+        // new (a relation declared on a collection with no prior postings
+        // still gets an empty sub-db created here), and until this runs it
+        // is invisible to try_open_for_read for the rest of the process.
+        cacheCommittedDbi(sub, dbi);
+    }
+    return indexed;
+}
+
+bool LmdbDocumentStore::relation_index_exists(std::string_view relation) {
+    const std::string sub = relation_index_subdb(relation);
+    ReadTxn rtxn(env_);
+    return try_open_for_read(rtxn, sub).has_value();
+}
+
+LmdbDocumentStore::DanglingCheckResult
+LmdbDocumentStore::check_relation_dangling(std::string_view relation,
+                                            std::string_view parentCollection,
+                                            size_t maxResults) {
+    DanglingCheckResult out;
+
+    const std::string sub = relation_index_subdb(relation);
+    ReadTxn rtxn(env_);
+    auto idx_dbi = try_open_for_read(rtxn, sub);
+    if (!idx_dbi) return out;   // never built - nothing to report (see relation_index_exists)
+
+    // The parent collection may legitimately not exist yet (nothing has been
+    // written to it) - every referenced parent id is then dangling by
+    // definition, which the mdb_get loop below already produces correctly
+    // when parent_dbi is nullopt (mdb_get is only called when it is set).
+    auto parent_dbi = try_open_for_read(rtxn, parentCollection);
+
+    MDB_cursor* cur = nullptr;
+    mdb_check(mdb_cursor_open(rtxn.raw(), *idx_dbi, &cur), "cursor_open (relation check)");
+    struct G { MDB_cursor* c; ~G() { if (c) mdb_cursor_close(c); } } g{cur};
+
+    MDB_val k{0, nullptr};
+    MDB_val v{0, nullptr};
+    int rc = mdb_cursor_get(cur, &k, &v, MDB_FIRST);
+    while (rc == MDB_SUCCESS) {
+        const auto key = to_sv(k);
+        // ⚠ Top-level walk: is_index_meta_key(), NOT is_identity_key(). This
+        // sub-db carries the v2.4.4 identity sentinel like any other, and a
+        // key here IS a real posting-list key (a parent id) - exactly the
+        // position where a real sentinel collision must be skipped, unlike
+        // the dead-code check inside one parent's dup set that Task 2 left
+        // as a Minor finding.
+        if (is_index_meta_key(key)) {
+            rc = mdb_cursor_get(cur, &k, &v, MDB_NEXT_NODUP);
+            continue;
+        }
+
+        bool parentExists = false;
+        if (parent_dbi) {
+            MDB_val pk = to_val(key);
+            MDB_val pv{0, nullptr};
+            const int grc = mdb_get(rtxn.raw(), *parent_dbi, &pk, &pv);
+            if (grc == MDB_SUCCESS) parentExists = true;
+            else if (grc != MDB_NOTFOUND) throw_mdb(grc, "get (relation check parent probe)");
+        }
+
+        if (!parentExists) {
+            ++out.total;
+            if (out.entries.size() < maxResults) {
+                DanglingReference ref;
+                ref.parentId = std::string(key);
+                // Cursor is still positioned on this key's first dup entry;
+                // cursor_count and a bounded walk of MDB_NEXT_DUP cost
+                // nothing extra beyond what a normal walk already touches.
+                size_t n = 0;
+                mdb_check(mdb_cursor_count(cur, &n), "cursor_count (relation check)");
+                ref.childCount = static_cast<uint64_t>(n);
+                MDB_val sk = k;
+                MDB_val sv{0, nullptr};
+                int src = mdb_cursor_get(cur, &sk, &sv, MDB_FIRST_DUP);
+                while (src == MDB_SUCCESS && ref.sampleChildIds.size() < 5) {
+                    const std::string_view child = to_sv(sv);
+                    if (!is_index_meta_key(child)) ref.sampleChildIds.emplace_back(child);
+                    src = mdb_cursor_get(cur, &sk, &sv, MDB_NEXT_DUP);
+                }
+                if (src != MDB_SUCCESS && src != MDB_NOTFOUND) {
+                    throw_mdb(src, "cursor next_dup (relation check sample)");
+                }
+                out.entries.push_back(std::move(ref));
+            }
+        }
+
+        rc = mdb_cursor_get(cur, &k, &v, MDB_NEXT_NODUP);
+    }
+    if (rc != MDB_SUCCESS && rc != MDB_NOTFOUND) throw_mdb(rc, "cursor next (relation check)");
+    return out;
+}
+
 std::optional<smartbotic::database::Document>
 LmdbDocumentStore::get(std::string_view collection, std::string_view id) {
     // An empty id is a zero-length LMDB key, which mdb_get rejects with

+ 53 - 0
service/src/storage/document_store_lmdb.hpp

@@ -224,6 +224,59 @@ public:
                                                       std::string_view parentId,
                                                       size_t limit);
 
+    // v2.11.0 T7 — populate a relation's reverse index over rows already
+    // present in `childCollection`, mirroring build_index(). Idempotent via
+    // MDB_NODUPDATA: re-running over an already-indexed relation posts no
+    // duplicates. Returns rows indexed (rows that had a resolvable, non-null
+    // reference under `childField`; absent/null contribute nothing, same as
+    // the write-path maintainRelations()).
+    //
+    // ⚠ Cost is proportional to `childCollection`'s size - a full collection
+    // walk, same as build_index. On a large collection this call blocks for
+    // the duration; it belongs in a migration/maintenance window, not a
+    // request path a caller is waiting on synchronously in a tight loop.
+    uint64_t build_relation_index(std::string_view relation,
+                                  std::string_view childCollection,
+                                  const std::string& childField);
+
+    // True if this relation's reverse index sub-db has been created at least
+    // once (via relation_index_add or build_relation_index) - independent of
+    // whether it currently holds any postings, since build_relation_index
+    // creates the (possibly empty) sub-db even when nothing matched. Used to
+    // tell "declared but never populated" (missing sub-db - the T7 bug) apart
+    // from "populated, currently empty" (sub-db exists with zero postings).
+    bool relation_index_exists(std::string_view relation);
+
+    // v2.11.0 T7 — dangling references: child rows pointing at a parent id
+    // that does not exist in `parentCollection`. Read-only; mutates nothing.
+    //
+    // Walks the TOP LEVEL of the reverse index (every distinct parent id),
+    // skipping reserved meta keys via is_index_meta_key() - not
+    // is_identity_key(), since a top-level walk is exactly where a real
+    // sentinel collision is possible (see the T2 review note this task
+    // closes). For each parent id absent from `parentCollection`, records
+    // its child count (mdb_cursor_count - free, already positioned) and up
+    // to 5 sample child ids.
+    //
+    // `maxResults` caps the ENTRIES returned, not the work done: every
+    // parent id in the index is still probed, so `total` is always exact
+    // even when `entries` is capped. Cost is proportional to the number of
+    // DISTINCT parents referenced, not to `childCollection`'s row count -
+    // still a full index walk, so treat this as a migration/operator tool on
+    // a large relation, not a hot-path call.
+    struct DanglingReference {
+        std::string parentId;
+        uint64_t childCount = 0;
+        std::vector<std::string> sampleChildIds;   // at most 5
+    };
+    struct DanglingCheckResult {
+        uint64_t total = 0;
+        std::vector<DanglingReference> entries;    // capped at maxResults
+    };
+    DanglingCheckResult check_relation_dangling(std::string_view relation,
+                                                std::string_view parentCollection,
+                                                size_t maxResults);
+
     // v2.11.0 T3 — declare which relations have `collection` as their CHILD
     // side, so put()/del() know to maintain the reverse index. Empty removes
     // the declaration. Mirrors set_indexed_fields: maintenance runs inside

+ 199 - 0
tests/test_relation_index.cpp

@@ -19,11 +19,15 @@
 #include <unistd.h>
 #include <vector>
 
+#include <nlohmann/json.hpp>
+
+#include "document.hpp"
 #include "storage/document_store_lmdb.hpp"
 #include "storage/lmdb_env.hpp"
 
 namespace fs = std::filesystem;
 
+using smartbotic::database::Document;
 using smartbotic::db::storage::LmdbDocumentStore;
 using smartbotic::db::storage::LmdbEnv;
 using smartbotic::db::storage::LmdbEnvOpts;
@@ -130,12 +134,207 @@ void test_index_created_at_runtime_is_visible_after_reopen() {
     fs::remove_all(path, ec);
 }
 
+// v2.11.0 T7 — declaring a relation over a collection that ALREADY has rows
+// must index those pre-existing rows, not just ones written afterwards.
+// This is the exact bug the task brief calls out: without a bootstrap scan,
+// declaring a relation over populated data leaves every existing child
+// invisible to enforcement while looking like it worked.
+void test_build_relation_index_covers_pre_existing_rows() {
+    TmpEnv t("relbuild-basic");
+    LmdbDocumentStore store(t.env);
+
+    // Rows exist BEFORE the relation is ever declared - no set_relations()
+    // call has happened, so put() below does not touch the reverse index at
+    // all. This is exactly "declare a relation on a populated collection".
+    auto put = [&](const std::string& id, const nlohmann::json& data) {
+        Document d; d.id = id; d.collection = "executions"; d.set_data(data);
+        store.put("executions", id, d);
+    };
+    put("e1", {{"workflowId", "wf-1"}});
+    put("e2", {{"workflowId", "wf-1"}});
+    put("e3", {{"workflowId", "wf-2"}});
+    put("e4", {{"other", 1}});                 // no reference - must not count
+    put("e5", {{"workflowId", nullptr}});      // null - must not count
+
+    check(store.relation_index_child_count("exec_wf", "wf-1") == 0,
+          "before the bootstrap scan, the reverse index knows nothing - this "
+          "is the bug: a declared relation would silently protect nothing");
+
+    const uint64_t indexed = store.build_relation_index("exec_wf", "executions", "workflowId");
+    check(indexed == 3, "3 rows had a resolvable reference (e4/e5 excluded)");
+    check(store.relation_index_child_count("exec_wf", "wf-1") == 2,
+          "both pre-existing children of wf-1 are now indexed");
+    check(store.relation_index_child_count("exec_wf", "wf-2") == 1,
+          "and wf-2's child");
+
+    auto kids = store.relation_index_children("exec_wf", "wf-1", 10);
+    std::sort(kids.begin(), kids.end());
+    check(kids == std::vector<std::string>{"e1", "e2"}, "correct children listed");
+
+    // Idempotent: re-running the scan (e.g. re-declaring the relation) must
+    // not double the postings, the same MDB_NODUPDATA guarantee build_index
+    // already relies on.
+    const uint64_t reindexed = store.build_relation_index("exec_wf", "executions", "workflowId");
+    check(reindexed == 3, "the re-scan still visits the same 3 rows");
+    check(store.relation_index_child_count("exec_wf", "wf-1") == 2,
+          "no duplicate postings from re-running the scan");
+    check(store.relation_index_child_count("exec_wf", "wf-2") == 1,
+          "no duplicate postings on wf-2 either");
+
+    check(store.relation_index_exists("exec_wf"),
+          "the sub-db now exists - relation_index_exists distinguishes this "
+          "from 'declared but never built'");
+    check(!store.relation_index_exists("never_declared"),
+          "an unrelated relation's sub-db was never created");
+}
+
+// An array-valued child field must contribute one posting per element, same
+// as the write-path maintainRelations() - the bootstrap scan must not
+// diverge from ongoing maintenance.
+void test_build_relation_index_handles_array_valued_field() {
+    TmpEnv t("relbuild-array");
+    LmdbDocumentStore store(t.env);
+
+    Document n; n.id = "n1"; n.collection = "nodes";
+    n.set_data({{"config", {{"credentialIds", {"c1", "c2"}}}}});
+    store.put("nodes", "n1", n);
+
+    const uint64_t indexed =
+        store.build_relation_index("node_creds", "nodes", "config.credentialIds");
+    check(indexed == 1, "one row contributed (it has two postings, one row)");
+    check(store.relation_index_child_count("node_creds", "c1") == 1, "array element 1");
+    check(store.relation_index_child_count("node_creds", "c2") == 1, "array element 2");
+}
+
+// A relation over a collection that does not exist yet (or is empty) must
+// not throw, and must not fabricate a sub-db that then confuses
+// relation_index_exists.
+void test_build_relation_index_over_absent_collection() {
+    TmpEnv t("relbuild-absent");
+    LmdbDocumentStore store(t.env);
+
+    const uint64_t indexed = store.build_relation_index("exec_wf", "executions", "workflowId");
+    check(indexed == 0, "nothing to index in a collection that was never written");
+}
+
+// v2.11.0 T7 — check_relation_dangling: a genuinely dangling reference (a
+// child row referencing a parent id that does not exist) must be reported,
+// with the correct count and sample child ids; a live reference must not be
+// flagged.
+void test_check_relation_dangling_finds_missing_parents() {
+    TmpEnv t("relcheck-basic");
+    LmdbDocumentStore store(t.env);
+
+    auto putParent = [&](const std::string& id) {
+        Document d; d.id = id; d.collection = "workflows"; d.set_data({{"name", id}});
+        store.put("workflows", id, d);
+    };
+    auto putChild = [&](const std::string& id, const std::string& wf) {
+        Document d; d.id = id; d.collection = "executions";
+        d.set_data({{"workflowId", wf}});
+        store.put("executions", id, d);
+    };
+
+    // wf-1 exists and is referenced - not dangling.
+    putParent("wf-1");
+    putChild("e1", "wf-1");
+    putChild("e2", "wf-1");
+
+    // wf-missing is referenced but was NEVER written as a parent - dangling.
+    putChild("e3", "wf-missing");
+    putChild("e4", "wf-missing");
+    putChild("e5", "wf-missing");
+
+    store.build_relation_index("exec_wf", "executions", "workflowId");
+
+    auto result = store.check_relation_dangling("exec_wf", "workflows", 100);
+    check(result.total == 1, "exactly one dangling parent id");
+    check(result.entries.size() == 1, "and it is reported (well under the cap)");
+    if (!result.entries.empty()) {
+        const auto& d = result.entries[0];
+        check(d.parentId == "wf-missing", "names the missing parent");
+        check(d.childCount == 3, "counts all three referencing children");
+        check(d.sampleChildIds.size() == 3, "samples all three (under the 5-sample cap)");
+        std::vector<std::string> sorted = d.sampleChildIds;
+        std::sort(sorted.begin(), sorted.end());
+        check(sorted == std::vector<std::string>{"e3", "e4", "e5"}, "correct sample ids");
+    }
+
+    // check_relation_dangling must mutate nothing - the index and parent
+    // collection are exactly as they were.
+    check(store.relation_index_child_count("exec_wf", "wf-missing") == 3,
+          "the check did not remove or alter the dangling postings");
+    check(store.count("workflows") == 1, "the check did not create a phantom parent row");
+}
+
+// A relation with no dangling references reports zero, and never invents
+// one for a value that legitimately exists.
+void test_check_relation_dangling_clean_relation() {
+    TmpEnv t("relcheck-clean");
+    LmdbDocumentStore store(t.env);
+
+    Document p; p.id = "wf-1"; p.collection = "workflows"; p.set_data({{"name", "wf-1"}});
+    store.put("workflows", "wf-1", p);
+    Document c; c.id = "e1"; c.collection = "executions";
+    c.set_data({{"workflowId", "wf-1"}});
+    store.put("executions", "e1", c);
+
+    store.build_relation_index("exec_wf", "executions", "workflowId");
+
+    auto result = store.check_relation_dangling("exec_wf", "workflows", 100);
+    check(result.total == 0, "no dangling references");
+    check(result.entries.empty(), "nothing reported");
+}
+
+// A relation that was declared but never built (no build_relation_index /
+// relation_index_add call at all) has no sub-db - check_relation_dangling
+// must report "nothing to say" rather than treating an absent index as
+// "everything is dangling".
+void test_check_relation_dangling_never_built_reports_nothing() {
+    TmpEnv t("relcheck-unbuilt");
+    LmdbDocumentStore store(t.env);
+
+    Document c; c.id = "e1"; c.collection = "executions";
+    c.set_data({{"workflowId", "wf-1"}});
+    store.put("executions", "e1", c);   // no relation declared/built at all
+
+    auto result = store.check_relation_dangling("exec_wf", "workflows", 100);
+    check(result.total == 0, "an unbuilt index has nothing to report - not a false positive");
+    check(result.entries.empty(), "nothing reported");
+}
+
+// The `maxResults` cap bounds the returned samples but `total` must still be
+// exact - a caller must never be able to mistake a capped list for the
+// complete one.
+void test_check_relation_dangling_respects_cap_but_total_is_exact() {
+    TmpEnv t("relcheck-capped");
+    LmdbDocumentStore store(t.env);
+
+    for (int i = 0; i < 5; ++i) {
+        Document c; c.id = "e" + std::to_string(i); c.collection = "executions";
+        c.set_data({{"workflowId", "wf-missing-" + std::to_string(i)}});
+        store.put("executions", "e" + std::to_string(i), c);
+    }
+    store.build_relation_index("exec_wf", "executions", "workflowId");
+
+    auto result = store.check_relation_dangling("exec_wf", "workflows", 2);
+    check(result.total == 5, "total counts every dangling parent, not just the capped sample");
+    check(result.entries.size() == 2, "entries is capped at maxResults");
+}
+
 }  // namespace
 
 int main() {
     std::cout << "=== test_relation_index ===\n";
     test_children_of_a_parent_are_a_dup_set();
     test_index_created_at_runtime_is_visible_after_reopen();
+    test_build_relation_index_covers_pre_existing_rows();
+    test_build_relation_index_handles_array_valued_field();
+    test_build_relation_index_over_absent_collection();
+    test_check_relation_dangling_finds_missing_parents();
+    test_check_relation_dangling_clean_relation();
+    test_check_relation_dangling_never_built_reports_nothing();
+    test_check_relation_dangling_respects_cap_but_total_is_exact();
 
     std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
     return g_fail == 0 ? 0 : 1;