Преглед на файлове

feat(relations): T6a - wire RelationManager into the server and Delete

DatabaseService now owns a RelationManager alongside config_manager_ and
policy_manager_: constructed in setupComponents(), loaded at boot via
loadFromStore() (after persistence recovery, alongside the view/config/
policy caches), and threaded into DatabaseGrpcImpl's constructor.

DatabaseGrpcImpl::Delete calls RelationEnforcer::canDelete() after the
access gate and before store_.remove(), returning FAILED_PRECONDITION
with the enforcement layer's message on a block. The CollectionCfg is
held in a named local so RelationEnforcer's by-reference binding to
relationsEnforced does not dangle.

Also fixes two gaps that would have made the wiring inert:

- relation_enforcement.cpp was never added to service/CMakeLists.txt
  (only to the test target), so the service failed to link once Delete
  called into it.
- LmdbDocumentStore::set_relations() - the call that arms the write path
  to maintain the reverse index - was never invoked outside tests. Added
  DatabaseService::applyRelationDeclarations(), called once at boot right
  after relation_manager_->loadFromStore(), which groups all loaded
  relations by (project, child collection) and arms each project's LMDB
  store. Without this a relation declaration would load into the cache
  but the reverse index would stay empty forever, and canDelete() would
  never find a block. Task 6b's createRelation/dropRelation RPCs still
  need to call set_relations() again on live mutation.

Verified end-to-end against a real running server (see
.superpowers/sdd/2026-08-09-relations-v2.11.0/task-6a-report.md for the
full transcript): a parent delete blocked with the exact operator-facing
message, then permitted after relations_enforced was flipped off via
ConfigureCollection.
fszontagh преди 1 месец
родител
ревизия
cf4859698f
променени са 5 файла, в които са добавени 106 реда и са изтрити 3 реда
  1. 1 0
      service/CMakeLists.txt
  2. 32 1
      service/src/database_grpc_impl.cpp
  3. 4 1
      service/src/database_grpc_impl.hpp
  4. 55 1
      service/src/database_service.cpp
  5. 14 0
      service/src/database_service.hpp

+ 1 - 0
service/CMakeLists.txt

@@ -59,6 +59,7 @@ set(DATABASE_SERVICE_SOURCES
     src/views/view_manager.cpp
     src/relations/relation_manager.cpp
     src/relations/relation_index.cpp
+    src/relations/relation_enforcement.cpp
     src/config/collection_config_manager.cpp
     src/config/config_loader.cpp
     src/storage/lmdb_env.cpp

+ 32 - 1
service/src/database_grpc_impl.cpp

@@ -4,6 +4,7 @@
 #include "storage/cosine_simd.hpp"
 #include "storage/document_store.hpp"
 #include "storage/document_store_lmdb.hpp"
+#include "relations/relation_enforcement.hpp"
 #include "json_parse.hpp"
 #include "persistence/wal.hpp"
 #include "views/projection.hpp"
@@ -88,7 +89,8 @@ DatabaseGrpcImpl::DatabaseGrpcImpl(
     EncryptionManager& encryption,
     ViewManager& view_manager,
     CollectionConfigManager& config_manager,
-    PolicyManager& policy_manager
+    PolicyManager& policy_manager,
+    RelationManager& relation_manager
 ) : service_(service)
   , store_(store)
   , persistence_(persistence)
@@ -98,6 +100,7 @@ DatabaseGrpcImpl::DatabaseGrpcImpl(
   , view_manager_(view_manager)
   , config_manager_(config_manager)
   , policy_manager_(policy_manager)
+  , relation_manager_(relation_manager)
 {
 }
 
@@ -799,6 +802,34 @@ grpc::Status DatabaseGrpcImpl::Delete(
         return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
             "cannot write to view '" + request->collection() + "': views are read-only");
     }
+
+    // v2.11.0 T6a — referential integrity. After the access gate (authorisation
+    // before integrity - a caller must not learn about child counts on a
+    // collection they cannot read), before store_.remove() so a blocked delete
+    // never mutates anything. Only meaningful on the LMDB substrate, where the
+    // reverse index lives; if this project has no LMDB store for some reason,
+    // there is nothing to enforce against and the delete proceeds as before.
+    if (!request->collection().empty() && request->collection()[0] != '_') {
+        try {
+            const auto rc = smartbotic::database::resolveCollection(request->collection());
+            if (auto* ds = service_.docStore(rc.project)) {
+                if (auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds)) {
+                    // Named local: CollectionCfg is returned by value, and
+                    // RelationEnforcer binds relationsEnforced by reference —
+                    // binding straight to the temporary's subobject would dangle.
+                    const CollectionCfg cfg = config_manager_.configFor(request->collection());
+                    RelationEnforcer enforcer(relation_manager_, *lmdb, cfg.relationsEnforced);
+                    std::string err;
+                    if (!enforcer.canDelete(request->collection(), request->id(), err)) {
+                        return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, err);
+                    }
+                }
+            }
+        } catch (const std::invalid_argument& e) {
+            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
+        }
+    }
+
     // Check if document has a vector before deleting (remove() erases it from memory)
     bool hadVector = false;
     auto* vectors = store_.getCollectionVectors(request->collection());

+ 4 - 1
service/src/database_grpc_impl.hpp

@@ -9,6 +9,7 @@
 #include "views/view_manager.hpp"
 #include "config/collection_config_manager.hpp"
 #include "security/policy_manager.hpp"
+#include "relations/relation_manager.hpp"
 #include "auth/principal.hpp"
 
 #include <database.grpc.pb.h>
@@ -42,7 +43,8 @@ public:
         EncryptionManager& encryption,
         ViewManager& view_manager,
         CollectionConfigManager& config_manager,
-        PolicyManager& policy_manager
+        PolicyManager& policy_manager,
+        RelationManager& relation_manager
     );
 
     ~DatabaseGrpcImpl() override = default;
@@ -412,6 +414,7 @@ private:
     ViewManager& view_manager_;
     CollectionConfigManager& config_manager_;
     PolicyManager& policy_manager_;
+    RelationManager& relation_manager_;
 
     // v2.7.0 — token -> principal, resolved per call. Populated by
     // DatabaseService from the union of all listeners' keys.

+ 55 - 1
service/src/database_service.cpp

@@ -195,6 +195,8 @@ bool DatabaseService::initialize() {
         // migration-created documents are stamped with the correct precision.
         config_manager_->loadFromStore();
         policy_manager_->loadFromStore();
+        relation_manager_->loadFromStore();
+        applyRelationDeclarations();
 
         // v2.9.0 — re-apply persisted index declarations to each project's LMDB
         // store. This is load-bearing, not bookkeeping: the declaration is what
@@ -379,6 +381,55 @@ void DatabaseService::applyIndexDeclarations() {
     }
 }
 
+void DatabaseService::applyRelationDeclarations() {
+    // Group by (project, bare child collection) — set_relations() is a
+    // per-collection call on that project's LmdbDocumentStore and replaces
+    // whatever was declared before, so every relation sharing a child
+    // collection must land in one call.
+    std::unordered_map<std::string,
+                        std::vector<smartbotic::db::storage::RelationRef>> byChild;
+    // Track which project each grouping key belongs to alongside the bare
+    // collection name, since the map key alone doesn't carry it.
+    std::unordered_map<std::string, std::pair<std::string, std::string>> keyToProjectCollection;
+
+    for (const auto& r : relation_manager_->listRelations()) {
+        try {
+            const auto rn = resolveCollection(r.name);
+            const auto rc = resolveCollection(r.child);
+            const std::string key = rc.project + ":" + rc.collection;
+            byChild[key].push_back(
+                smartbotic::db::storage::RelationRef{rn.collection, r.childField});
+            keyToProjectCollection[key] = {rc.project, rc.collection};
+        } catch (const std::exception& e) {
+            // Advisory per relation: one unparseable declaration must not
+            // stop the rest from being armed. Loud, because a skipped
+            // relation silently maintains no reverse index for its child.
+            spdlog::error("v2.11 relations: could not apply declaration '{}': {}",
+                          r.name, e.what());
+        }
+    }
+
+    size_t applied = 0;
+    for (const auto& [key, refs] : byChild) {
+        const auto& [project, collection] = keyToProjectCollection[key];
+        try {
+            auto* ds = docStore(project);
+            auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
+            if (lmdb == nullptr) continue;
+            lmdb->set_relations(collection, refs);
+            ++applied;
+            spdlog::info("v2.11 relations: {} relation(s) active with child '{}:{}'",
+                         refs.size(), project, collection);
+        } catch (const std::exception& e) {
+            spdlog::error("v2.11 relations: could not apply declarations for '{}:{}': {}",
+                          project, collection, e.what());
+        }
+    }
+    if (applied > 0) {
+        spdlog::info("v2.11 relations: applied declarations for {} child collection(s)", applied);
+    }
+}
+
 void DatabaseService::auditSubdbPlacement() {
     if (!projects_) return;
 
@@ -999,6 +1050,9 @@ void DatabaseService::setupComponents() {
     // v2.8.0 — access policy. Constructed here; its cache is loaded after
     // recovery alongside the view and collection-config caches.
     policy_manager_ = std::make_unique<PolicyManager>(*store_);
+    // v2.11.0 T6a — relation declarations. Constructed here; its cache is
+    // loaded after recovery alongside the view/config/policy caches.
+    relation_manager_ = std::make_unique<RelationManager>(*store_);
     store_->setConfigManager(config_manager_.get());
 
     // v1.9.0 — disk-resident version history. Replaces the in-heap
@@ -1177,7 +1231,7 @@ void DatabaseService::setupComponents() {
     // Create gRPC implementations
     storageImpl_ = std::make_unique<DatabaseGrpcImpl>(
         *this, *store_, *persistence_, *events_, *files_, *encryption_, *view_manager_, *config_manager_,
-        *policy_manager_
+        *policy_manager_, *relation_manager_
     );
     // v2.7.0 — hand the impl the union of every listener's keys so it can
     // resolve a principal per call. A token is only accepted if some listener's

+ 14 - 0
service/src/database_service.hpp

@@ -13,6 +13,7 @@
 #include "views/view_manager.hpp"
 #include "config/collection_config_manager.hpp"
 #include "security/policy_manager.hpp"
+#include "relations/relation_manager.hpp"
 
 // LMDB storage substrate (v2.0+). DocumentStore + LmdbEnv live alongside the
 // v1.x MemoryStore, which is still the write entry point and a bounded read
@@ -322,6 +323,16 @@ private:
     // persisted in _collection_meta. Must run at boot, after config load.
     void applyIndexDeclarations();
 
+    // v2.11.0 T6a — arm each project's LmdbDocumentStore with the relation
+    // declarations loaded by relation_manager_->loadFromStore(), grouped by
+    // CHILD collection. LmdbDocumentStore has no knowledge of RelationManager
+    // and maintains the reverse index for nothing until told to via
+    // set_relations() (see Task 3's report) — this is what tells it, once at
+    // boot. Task 6b's createRelation/dropRelation RPCs must call
+    // set_relations() again on live mutation; this only covers what was
+    // already persisted at startup.
+    void applyRelationDeclarations();
+
     // 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
@@ -364,6 +375,9 @@ private:
     // v2.8.0 — per-project access policy. Owned here so its cache is loaded
     // once, after recovery, alongside ViewManager and CollectionConfigManager.
     std::unique_ptr<PolicyManager> policy_manager_;
+    // v2.11.0 T6a — relation declarations. Owned here so its cache is loaded
+    // once, after recovery, alongside ViewManager/CollectionConfigManager/PolicyManager.
+    std::unique_ptr<RelationManager> relation_manager_;
 
     // gRPC
     std::unique_ptr<DatabaseGrpcImpl> storageImpl_;