فهرست منبع

feat(relations): T6b - relation CRUD RPCs, client methods, CLI

Adds the management surface for referential-integrity relations that
Task 6a's enforcement already consults: relations could previously only
be declared by writing raw documents into _relations.

- proto: CreateRelation/DropRelation/ListRelations/GetRelationInfo,
  mirroring the CreateView/DropView/ListViews/GetViewInfo shape.
- server: admin-only gate (same bar gate() applies to any _-prefixed
  system collection), cross-project rejection surfaced from
  RelationManager rather than duplicated, and armRelationsForChild() so
  a relation declared at runtime arms the reverse index immediately
  instead of waiting for the next restart's applyRelationDeclarations().
  Fixed a bug caught by the new e2e test: arming used a non-creating
  docStore() lookup, so a relation declared for a project with no prior
  writes was silently never armed once that project's env was lazily
  created later. Now uses ProjectStoreRegistry::getOrCreate(), the same
  call the write-mirror path already makes on first write.
- client: RelationDefinition (new struct) + createRelation/dropRelation/
  listRelations/getRelationInfo, qualifying name AND child AND parent
  and unqualifying on the way back - the v2.4.2 createView bug qualified
  collection but not name and broke view lookups for two releases.
  setRelationsEnforced/getRelationsEnforced as methods rather than new
  CollectionConfig members (ABI: a struct size change under an unchanged
  soname crashed an installed consumer once already). remove() gained an
  errorOut overload so a caller can tell "already gone" apart from
  "blocked by a relation".
- cli: --project flag (needed to address a non-default project at all),
  relations/relation/relation-create/relation-drop/configure-relations
  commands, and remove now reports the actual outcome and surfaces the
  server's refusal message instead of printing "ok removed"
  unconditionally.
- tests/load_test/test_relations_client_e2e.{cpp,sh}: new client/server
  boundary test with a non-default project - unit tests cannot see a
  qualify/unqualify mismatch, which is exactly where the v2.4.2 bug
  lived. 22 assertions, all passing.

ctest: test_relation_manager 9/9, test_relation_enforcement 27/27,
test_subdb_identity 236/236.
fszontagh 1 ماه پیش
والد
کامیت
6f3eeb6382

+ 1 - 0
.gitignore

@@ -28,3 +28,4 @@ core.*
 
 
 .claude/worktrees/
 .claude/worktrees/
 .playwright-mcp/
 .playwright-mcp/
+.superpowers/

+ 129 - 3
cli/main.cpp

@@ -33,6 +33,10 @@ namespace {
 
 
 struct Args {
 struct Args {
     std::string address = "localhost:9004";
     std::string address = "localhost:9004";
+    // v2.11.0 T6b — needed to exercise relation management against a
+    // non-default project from the CLI. Empty means the client's own
+    // "default" (unchanged behaviour for every existing command).
+    std::string project;
     std::string command;
     std::string command;
     std::vector<std::string> params;
     std::vector<std::string> params;
 };
 };
@@ -80,6 +84,16 @@ void printUsage() {
               << "          remove an index\n"
               << "          remove an index\n"
               << "  " << C_CYAN << "index-values" << C_RESET << " <coll> <field> [n] [asc|desc]"
               << "  " << C_CYAN << "index-values" << C_RESET << " <coll> <field> [n] [asc|desc]"
               << "  distinct values + counts\n"
               << "  distinct values + counts\n"
+              << C_BOLD << "  Relations / referential integrity (v2.11.0+)" << C_RESET << "\n"
+              << "  " << C_CYAN << "relations" << C_RESET
+              << "                             List declared relations\n"
+              << "  " << C_CYAN << "relation" << C_RESET << " <name>"
+              << "                    Show one relation's declaration\n"
+              << "  " << 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 << "configure-relations" << C_RESET
+              << " <collection> <on|off>   Enable/disable enforcement for a collection\n"
               << "  " << C_CYAN << "security-set" << C_RESET << " <project> <on|off> [enforce|audit]\n"
               << "  " << C_CYAN << "security-set" << C_RESET << " <project> <on|off> [enforce|audit]\n"
               << "  " << C_CYAN << "policies" << C_RESET << " [project]                   List principals with a policy\n"
               << "  " << C_CYAN << "policies" << C_RESET << " [project]                   List principals with a policy\n"
               << "  " << C_CYAN << "policy" << C_RESET << " <project> <principal>       Show one policy\n"
               << "  " << C_CYAN << "policy" << C_RESET << " <project> <principal>       Show one policy\n"
@@ -95,7 +109,8 @@ void printUsage() {
               << "  " << C_CYAN << "reconcile-subdbs" << C_RESET << " --env <path>       Repair misfiled documents (dry run by default)\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_DIM << "[--project NAME] [--apply]   STOP THE SERVICE and back up before --apply" << C_RESET << "\n\n"
               << C_BOLD << "Options:" << C_RESET << "\n"
               << C_BOLD << "Options:" << C_RESET << "\n"
-              << "  --address HOST:PORT    Database address (default: localhost:9004)\n";
+              << "  --address HOST:PORT    Database address (default: localhost:9004)\n"
+              << "  --project NAME         Operate as this project namespace (default: default)\n";
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -545,7 +560,25 @@ bool execCommand(smartbotic::database::Client& client,
 
 
         if (cmd == "remove" || cmd == "delete") {
         if (cmd == "remove" || cmd == "delete") {
             if (params.size() < 2) { printError("usage: remove <collection> <id>"); return false; }
             if (params.size() < 2) { printError("usage: remove <collection> <id>"); return false; }
-            client.remove(params[0], params[1]);
+            // v2.11.0 T6b — the return value used to be discarded and "ok
+            // removed" printed unconditionally. That was merely sloppy
+            // before referential integrity: now a delete can be REFUSED (a
+            // relation with on_delete=restrict/no_action still has children
+            // referencing it), and the server reports that as a real error,
+            // not deleted=false. Report the actual outcome, and surface the
+            // server's message on refusal so an operator learns what
+            // blocked them rather than being told it worked.
+            std::string err;
+            bool deleted = client.remove(params[0], params[1], err);
+            if (!err.empty()) {
+                printError("remove " + params[0] + "/" + params[1] + ": " + err);
+                return false;
+            }
+            if (!deleted) {
+                std::cout << C_YELLOW << "not found" << C_RESET << " "
+                          << params[0] << "/" << params[1] << "\n";
+                return true;
+            }
             std::cout << C_GREEN << "ok" << C_RESET << " removed " << params[0] << "/" << params[1] << "\n";
             std::cout << C_GREEN << "ok" << C_RESET << " removed " << params[0] << "/" << params[1] << "\n";
             return true;
             return true;
         }
         }
@@ -637,6 +670,95 @@ bool execCommand(smartbotic::database::Client& client,
             return true;
             return true;
         }
         }
 
 
+        // ===== v2.11.0 T6b — relations (referential integrity) =====
+        //
+        // Declaration is admin-only: `_relations` is a system collection and a
+        // relation names another collection's schema, which is not ordinary
+        // per-collection write access. Follows the `indexes` output shape.
+        if (cmd == "relations") {
+            auto list = client.listRelations();
+            if (list.empty()) {
+                std::cout << "no relations declared\n";
+                return true;
+            }
+            std::cout << C_BOLD << "name                 child.field -> parent                    on_delete    enforced-at-write"
+                      << C_RESET << "\n";
+            for (const auto& r : list) {
+                std::cout << "  " << C_CYAN << r.name << C_RESET
+                          << std::string(r.name.size() < 19 ? 19 - r.name.size() : 1, ' ')
+                          << r.child << "." << r.childField << " -> " << r.parent
+                          << "  " << r.onDelete
+                          << (r.validateOnWrite ? "  (validate_on_write)" : "") << "\n";
+            }
+            return true;
+        }
+
+        if (cmd == "relation") {
+            if (params.empty()) { printError("usage: relation <name>"); return false; }
+            auto r = client.getRelationInfo(params[0]);
+            if (!r) { printError("relation not found: " + params[0]); return false; }
+            std::cout << C_BOLD << r->name << C_RESET << ":\n"
+                      << "  child:              " << r->child << "\n"
+                      << "  child_field:        " << r->childField << "\n"
+                      << "  parent:             " << r->parent << "\n"
+                      << "  on_delete:          " << r->onDelete << "\n"
+                      << "  validate_on_write:  " << (r->validateOnWrite ? "yes" : "no") << "\n";
+            return true;
+        }
+
+        if (cmd == "relation-create") {
+            if (params.size() < 4) {
+                printError("usage: relation-create <name> <child> <child_field> <parent> "
+                           "[on_delete] [validate_on_write]\n"
+                           "  on_delete: restrict (default) | cascade | set_null | no_action\n"
+                           "  note: cascade/set_null are accepted and persisted but currently "
+                           "behave as permit");
+                return false;
+            }
+            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");
+            if (!client.createRelation(params[0], params[1], params[2], params[3],
+                                       onDelete, validateOnWrite)) {
+                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";
+            return true;
+        }
+
+        if (cmd == "relation-drop") {
+            if (params.empty()) { printError("usage: relation-drop <name>"); return false; }
+            if (!client.dropRelation(params[0])) {
+                printError("could not drop the relation (see the service log)");
+                return false;
+            }
+            std::cout << "dropped relation " << params[0] << "\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") {
+            if (params.size() < 2) {
+                printError("usage: configure-relations <collection> <on|off>");
+                return false;
+            }
+            if (params[1] != "on" && params[1] != "off") {
+                printError("expected 'on' or 'off', got: " + params[1]);
+                return false;
+            }
+            const bool enforced = params[1] == "on";
+            if (!client.setRelationsEnforced(params[0], enforced)) {
+                printError("could not update relations_enforced (see the service log)");
+                return false;
+            }
+            std::cout << C_GREEN << "ok" << C_RESET << " " << params[0]
+                      << " relations_enforced=" << (enforced ? "on" : "off") << "\n";
+            return true;
+        }
+
         // ===== v2.7.0 access policy =====
         // ===== v2.7.0 access policy =====
         //
         //
         // Policy lives in the `_policies` collection and is managed through the
         // Policy lives in the `_policies` collection and is managed through the
@@ -957,6 +1079,8 @@ int main(int argc, char* argv[]) {
         std::string arg = argv[i];
         std::string arg = argv[i];
         if (arg == "--address" && i + 1 < argc) {
         if (arg == "--address" && i + 1 < argc) {
             args.address = argv[++i];
             args.address = argv[++i];
+        } else if (arg == "--project" && i + 1 < argc) {
+            args.project = argv[++i];
         } else if (arg == "--help" || arg == "-h") {
         } else if (arg == "--help" || arg == "-h") {
             printUsage();
             printUsage();
             return 0;
             return 0;
@@ -983,7 +1107,9 @@ int main(int argc, char* argv[]) {
     }
     }
 
 
     // Connect
     // Connect
-    smartbotic::database::Client client({.address = args.address});
+    smartbotic::database::Client::Config clientCfg{.address = args.address};
+    if (!args.project.empty()) clientCfg.project = args.project;
+    smartbotic::database::Client client(clientCfg);
     client.connect();
     client.connect();
 
 
     // Scriptable mode: single command
     // Scriptable mode: single command

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

@@ -211,6 +211,24 @@ public:
      */
      */
     bool remove(const std::string& collection, const std::string& id);
     bool remove(const std::string& collection, const std::string& id);
 
 
+    /**
+     * Delete a document, reporting WHY on refusal.
+     *
+     * A relation with on_delete="restrict" (or the enforced default,
+     * "no_action") can refuse a delete that still has children referencing
+     * it - the server returns a real gRPC FAILED_PRECONDITION, not
+     * `deleted=false`. The single-return-value remove() above discarded
+     * that message (logged it at spdlog::error and nothing else), which
+     * left an operator unable to tell "already gone" apart from "blocked".
+     * This is an OVERLOAD, not a new parameter on the existing signature,
+     * so the exported symbol callers already link against is untouched.
+     *
+     * @param errorOut populated with the server's message when the call
+     *   fails for any reason (transport error, or a refusal). Empty when
+     *   the document simply didn't exist (deleted=false, no error).
+     */
+    bool remove(const std::string& collection, const std::string& id, std::string& errorOut);
+
     /**
     /**
      * Check if a document exists.
      * Check if a document exists.
      */
      */
@@ -701,6 +719,84 @@ public:
      */
      */
     [[nodiscard]] std::optional<ViewDefinition> getViewInfo(const std::string& name);
     [[nodiscard]] std::optional<ViewDefinition> getViewInfo(const std::string& name);
 
 
+    // ===== Relation (Referential Integrity) Management — v2.11.0 T6b =====
+
+    /**
+     * A declared relation, as reported by listRelations() / getRelationInfo().
+     * Mirrors the server-side RelationDefinition proto message. This is a
+     * brand-new struct, not a member added to an existing one - safe under
+     * the unchanged soname (see the ABI note on createIndex above).
+     */
+    struct RelationDefinition {
+        std::string name;
+        std::string child;
+        std::string childField;
+        std::string parent;
+        std::string onDelete = "restrict";  // restrict | cascade | set_null | no_action
+        bool validateOnWrite = false;
+        uint64_t createdAt = 0;
+        uint64_t updatedAt = 0;
+    };
+
+    /**
+     * Declare a relation: documents in `childCollection` reference documents
+     * in `parentCollection` through `childField` (a dot-path that may
+     * resolve to a single id or an array of ids).
+     *
+     * Admin-only: `_relations` is a system collection and a relation names
+     * another collection's schema, so this is not ordinary per-collection
+     * write access.
+     *
+     * `onDelete` is one of "restrict" (default), "cascade", "set_null",
+     * "no_action". Only restrict/no_action are enforced today - cascade and
+     * set_null are accepted and persisted but currently behave as permit.
+     *
+     * A cross-project relation (name/child/parent resolving to different
+     * projects) is refused server-side: no LMDB transaction spans two
+     * project envs, so it could never be enforced atomically.
+     *
+     * @return true on success.
+     */
+    bool createRelation(const std::string& name, const std::string& childCollection,
+                        const std::string& childField, const std::string& parentCollection,
+                        const std::string& onDelete = "restrict",
+                        bool validateOnWrite = false);
+
+    /**
+     * Drop a relation by name. Admin-only.
+     */
+    bool dropRelation(const std::string& name);
+
+    /**
+     * List all relations declared in this client's project. Admin-only.
+     */
+    [[nodiscard]] std::vector<RelationDefinition> listRelations();
+
+    /**
+     * Look up a single relation by name. Admin-only.
+     */
+    [[nodiscard]] std::optional<RelationDefinition> getRelationInfo(const std::string& name);
+
+    /**
+     * Set whether declared relations are enforced for `collection`.
+     * Defaults to true (enforced) - declaring a relation names its child
+     * and parent explicitly, so the declaration IS the opt-in.
+     *
+     * A METHOD rather than a member on CollectionConfig: adding a field to
+     * that public struct would change its size under an unchanged soname,
+     * which is exactly what crashed an installed consumer in v2.7.1 (see
+     * the ABI note above createIndex). This sends a ConfigureCollection
+     * partial update touching ONLY relations_enforced - timestampPrecision
+     * and versioningEnabled are left as they are.
+     */
+    bool setRelationsEnforced(const std::string& collection, bool enforced);
+
+    /**
+     * Read whether relations are currently enforced for `collection`.
+     * Defaults to true when the collection has no explicit config.
+     */
+    [[nodiscard]] bool getRelationsEnforced(const std::string& collection);
+
     // ===== Event Subscription =====
     // ===== Event Subscription =====
 
 
     using EventCallback = std::function<void(const std::string& collection,
     using EventCallback = std::function<void(const std::string& collection,

+ 196 - 1
client/src/client.cpp

@@ -480,7 +480,14 @@ public:
         return {response.id(), response.inserted()};
         return {response.id(), response.inserted()};
     }
     }
 
 
-    bool remove(const std::string& collection, const std::string& id) {
+    // errorOut is optional: nullptr keeps the old single-value remove()'s
+    // behaviour (log at spdlog::error and swallow the message). Non-null is
+    // what lets a caller distinguish "already gone" from "blocked" - a
+    // relation with on_delete=restrict/no_action refuses via a real gRPC
+    // FAILED_PRECONDITION, not deleted=false, and that message is the only
+    // way an operator learns WHAT blocked the delete.
+    bool removeImpl(const std::string& collection, const std::string& id,
+                    std::string* errorOut) {
         smartbotic::databasepb::DeleteRequest request;
         smartbotic::databasepb::DeleteRequest request;
         request.set_collection(qualify(collection));
         request.set_collection(qualify(collection));
         request.set_id(id);
         request.set_id(id);
@@ -507,12 +514,22 @@ public:
         }
         }
         if (!status.ok()) {
         if (!status.ok()) {
             spdlog::error("Client::remove failed after retries: {}", status.error_message());
             spdlog::error("Client::remove failed after retries: {}", status.error_message());
+            if (errorOut != nullptr) *errorOut = status.error_message();
             return false;
             return false;
         }
         }
 
 
         return response.deleted();
         return response.deleted();
     }
     }
 
 
+    bool remove(const std::string& collection, const std::string& id) {
+        return removeImpl(collection, id, nullptr);
+    }
+
+    bool remove(const std::string& collection, const std::string& id, std::string& errorOut) {
+        errorOut.clear();
+        return removeImpl(collection, id, &errorOut);
+    }
+
     bool exists(const std::string& collection, const std::string& id) {
     bool exists(const std::string& collection, const std::string& id) {
         smartbotic::databasepb::ExistsRequest request;
         smartbotic::databasepb::ExistsRequest request;
         request.set_collection(qualify(collection));
         request.set_collection(qualify(collection));
@@ -1091,6 +1108,50 @@ public:
         return response.found();
         return response.found();
     }
     }
 
 
+    // v2.11.0 T6b — a METHOD, not a CollectionConfig member (see the header
+    // doc comment for why). Sends a partial update touching ONLY
+    // relations_enforced: timestamp_precision is left unset (empty string,
+    // the server's "leave unchanged" sentinel) and versioning_enabled is
+    // left absent (proto3 field presence), so neither is disturbed.
+    bool setRelationsEnforced(const std::string& collection, bool enforced) {
+        smartbotic::databasepb::ConfigureCollectionRequest request;
+        request.set_collection(qualify(collection));
+        request.mutable_config()->set_relations_enforced(enforced);
+
+        smartbotic::databasepb::ConfigureCollectionResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->ConfigureCollection(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::setRelationsEnforced failed: {}", status.error_message());
+            return false;
+        }
+        if (!response.success()) {
+            spdlog::error("Client::setRelationsEnforced rejected: {}", response.error());
+            return false;
+        }
+        return true;
+    }
+
+    bool getRelationsEnforced(const std::string& collection) {
+        smartbotic::databasepb::GetCollectionConfigRequest request;
+        request.set_collection(qualify(collection));
+        smartbotic::databasepb::GetCollectionConfigResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->GetCollectionConfig(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::getRelationsEnforced failed: {}", status.error_message());
+            return true;  // fail toward the safe (enforced) default
+        }
+        // Defaults to true (enforced) when never explicitly configured -
+        // mirrors the server's CollectionCfg default.
+        return !response.config().has_relations_enforced()
+                   || response.config().relations_enforced();
+    }
+
     Client::TimestampMigrationResult migrateCollectionTimestamps(
     Client::TimestampMigrationResult migrateCollectionTimestamps(
         const std::string& collection,
         const std::string& collection,
         const std::string& fromPrecision,
         const std::string& fromPrecision,
@@ -1356,6 +1417,109 @@ public:
         return v;
         return v;
     }
     }
 
 
+    // ===== v2.11.0 T6b — relation (referential integrity) management =====
+
+    Client::RelationDefinition relationFromProto(
+        const smartbotic::databasepb::RelationDefinition& pb) const {
+        Client::RelationDefinition r;
+        // Round-trip like listViews(): the caller declared "posts_by_user",
+        // it should list back as "posts_by_user", not "myproject:posts_by_user".
+        r.name = unqualify(pb.name());
+        r.child = unqualify(pb.child());
+        r.childField = pb.child_field();
+        r.parent = unqualify(pb.parent());
+        r.onDelete = pb.on_delete().empty() ? "restrict" : pb.on_delete();
+        r.validateOnWrite = pb.validate_on_write();
+        r.createdAt = pb.created_at();
+        r.updatedAt = pb.updated_at();
+        return r;
+    }
+
+    bool createRelation(const std::string& name, const std::string& childCollection,
+                        const std::string& childField, const std::string& parentCollection,
+                        const std::string& onDelete, bool validateOnWrite) {
+        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
+        // bug (registry keyed on the bare name, every lookup sent the
+        // qualified one, the keys never met).
+        request.set_name(qualify(name));
+        request.set_child(qualify(childCollection));
+        request.set_child_field(childField);
+        request.set_parent(qualify(parentCollection));
+        request.set_on_delete(onDelete);
+        request.set_validate_on_write(validateOnWrite);
+
+        smartbotic::databasepb::CreateRelationResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->CreateRelation(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::createRelation failed: {}", status.error_message());
+            return false;
+        }
+        if (!response.success()) {
+            spdlog::error("Client::createRelation rejected: {}", response.error());
+            return false;
+        }
+        return true;
+    }
+
+    bool dropRelation(const std::string& name) {
+        smartbotic::databasepb::DropRelationRequest request;
+        request.set_name(qualify(name));
+        smartbotic::databasepb::DropRelationResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->DropRelation(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::dropRelation failed: {}", status.error_message());
+            return false;
+        }
+        if (!response.success()) {
+            spdlog::error("Client::dropRelation rejected: {}", response.error());
+            return false;
+        }
+        return true;
+    }
+
+    std::vector<Client::RelationDefinition> listRelations() {
+        smartbotic::databasepb::ListRelationsRequest request;
+        // Scope the listing to this client's workspace, like listViews().
+        request.set_project(config_.project);
+        smartbotic::databasepb::ListRelationsResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        std::vector<Client::RelationDefinition> out;
+        auto status = stub_->ListRelations(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::listRelations failed: {}", status.error_message());
+            return out;
+        }
+        out.reserve(response.relations_size());
+        for (const auto& pb : response.relations()) {
+            out.push_back(relationFromProto(pb));
+        }
+        return out;
+    }
+
+    std::optional<Client::RelationDefinition> getRelationInfo(const std::string& name) {
+        smartbotic::databasepb::GetRelationInfoRequest request;
+        request.set_name(qualify(name));
+        smartbotic::databasepb::GetRelationInfoResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->GetRelationInfo(&context, request, &response);
+        if (!status.ok() || !response.found()) {
+            return std::nullopt;
+        }
+        return relationFromProto(response.relation());
+    }
+
     // ===== Event Subscription =====
     // ===== Event Subscription =====
 
 
     class SubscriptionHandle {
     class SubscriptionHandle {
@@ -2039,6 +2203,10 @@ bool Client::remove(const std::string& collection, const std::string& id) {
     return impl_->remove(collection, id);
     return impl_->remove(collection, id);
 }
 }
 
 
+bool Client::remove(const std::string& collection, const std::string& id, std::string& errorOut) {
+    return impl_->remove(collection, id, errorOut);
+}
+
 bool Client::exists(const std::string& collection, const std::string& id) {
 bool Client::exists(const std::string& collection, const std::string& id) {
     return impl_->exists(collection, id);
     return impl_->exists(collection, id);
 }
 }
@@ -2205,6 +2373,33 @@ std::optional<Client::ViewDefinition> Client::getViewInfo(const std::string& nam
     return impl_->getViewInfo(name);
     return impl_->getViewInfo(name);
 }
 }
 
 
+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) {
+    return impl_->createRelation(name, childCollection, childField, parentCollection,
+                                 onDelete, validateOnWrite);
+}
+
+bool Client::dropRelation(const std::string& name) {
+    return impl_->dropRelation(name);
+}
+
+std::vector<Client::RelationDefinition> Client::listRelations() {
+    return impl_->listRelations();
+}
+
+std::optional<Client::RelationDefinition> Client::getRelationInfo(const std::string& name) {
+    return impl_->getRelationInfo(name);
+}
+
+bool Client::setRelationsEnforced(const std::string& collection, bool enforced) {
+    return impl_->setRelationsEnforced(collection, enforced);
+}
+
+bool Client::getRelationsEnforced(const std::string& collection) {
+    return impl_->getRelationsEnforced(collection);
+}
+
 std::shared_ptr<void> Client::subscribe(const std::vector<std::string>& collections, EventCallback callback) {
 std::shared_ptr<void> Client::subscribe(const std::vector<std::string>& collections, EventCallback callback) {
     return impl_->subscribe(collections, std::move(callback));
     return impl_->subscribe(collections, std::move(callback));
 }
 }

+ 76 - 0
proto/database.proto

@@ -70,6 +70,15 @@ service DatabaseService {
     rpc ListViews(ListViewsRequest) returns (ListViewsResponse);
     rpc ListViews(ListViewsRequest) returns (ListViewsResponse);
     rpc GetViewInfo(GetViewInfoRequest) returns (GetViewInfoResponse);
     rpc GetViewInfo(GetViewInfoRequest) returns (GetViewInfoResponse);
 
 
+    // v2.11.0 T6b — relation (referential integrity) management. Admin-only:
+    // `_relations` is a system collection and a relation names another
+    // collection's schema, so declaring one is not ordinary per-collection
+    // write access.
+    rpc CreateRelation(CreateRelationRequest) returns (CreateRelationResponse);
+    rpc DropRelation(DropRelationRequest) returns (DropRelationResponse);
+    rpc ListRelations(ListRelationsRequest) returns (ListRelationsResponse);
+    rpc GetRelationInfo(GetRelationInfoRequest) returns (GetRelationInfoResponse);
+
     // Collection configuration
     // Collection configuration
     rpc ConfigureCollection(ConfigureCollectionRequest) returns (ConfigureCollectionResponse);
     rpc ConfigureCollection(ConfigureCollectionRequest) returns (ConfigureCollectionResponse);
 
 
@@ -885,6 +894,73 @@ message GetViewInfoResponse {
     bool found = 2;
     bool found = 2;
 }
 }
 
 
+// ===== Relation (Referential Integrity) Operations — v2.11.0 T6b =====
+//
+// A relation says: documents in `child` reference documents in `parent`
+// through `child_field` (a dot-path that may resolve to a single id or an
+// array of ids). Declarations are persisted in the global `_relations`
+// system collection (mirrors `_views`) and are project-qualified like any
+// other collection-carrying name. Management is admin-only.
+
+message RelationDefinition {
+    string name = 1;               // relation name, project-qualified
+    string child = 2;               // collection holding the reference, project-qualified
+    string child_field = 3;         // dot-path on the child; may resolve to an array of ids
+    string parent = 4;              // collection being referenced, project-qualified
+    // "restrict" (default) | "cascade" | "set_null" | "no_action".
+    // Only restrict/no_action are enforced today (v2.11.0 T4/T6a); cascade
+    // and set_null are accepted and persisted but currently behave as
+    // permit (Task 12 makes them destructive).
+    string on_delete = 5;
+    // Not yet enforced (Task 13). Accepted and persisted.
+    bool validate_on_write = 6;
+    uint64 created_at = 7;
+    uint64 updated_at = 8;
+}
+
+message CreateRelationRequest {
+    string name = 1;
+    string child = 2;
+    string child_field = 3;
+    string parent = 4;
+    string on_delete = 5;           // empty defaults to "restrict"
+    bool validate_on_write = 6;
+}
+
+message CreateRelationResponse {
+    bool success = 1;
+    string error = 2;
+}
+
+message DropRelationRequest {
+    string name = 1;
+}
+
+message DropRelationResponse {
+    bool success = 1;
+    string error = 2;
+}
+
+message ListRelationsRequest {
+    // Restrict the listing to one project namespace. Empty lists every
+    // project (operator/CLI use). Clients always send their own project so
+    // one workspace never enumerates another's relations.
+    string project = 1;
+}
+
+message ListRelationsResponse {
+    repeated RelationDefinition relations = 1;
+}
+
+message GetRelationInfoRequest {
+    string name = 1;
+}
+
+message GetRelationInfoResponse {
+    RelationDefinition relation = 1;
+    bool found = 2;
+}
+
 // ===== Collection Configuration =====
 // ===== Collection Configuration =====
 
 
 // Per-collection configuration for timestamp precision and other runtime knobs.
 // Per-collection configuration for timestamp precision and other runtime knobs.

+ 205 - 0
service/src/database_grpc_impl.cpp

@@ -4,6 +4,7 @@
 #include "storage/cosine_simd.hpp"
 #include "storage/cosine_simd.hpp"
 #include "storage/document_store.hpp"
 #include "storage/document_store.hpp"
 #include "storage/document_store_lmdb.hpp"
 #include "storage/document_store_lmdb.hpp"
+#include "storage/project_store.hpp"
 #include "relations/relation_enforcement.hpp"
 #include "relations/relation_enforcement.hpp"
 #include "json_parse.hpp"
 #include "json_parse.hpp"
 #include "persistence/wal.hpp"
 #include "persistence/wal.hpp"
@@ -2620,6 +2621,210 @@ grpc::Status DatabaseGrpcImpl::GetViewInfo(
     return grpc::Status::OK;
     return grpc::Status::OK;
 }
 }
 
 
+// ===== Relation Operations (v2.11.0 T6b) =====
+//
+// `_relations` is a global system collection like `_views`, and unlike a
+// view, a relation names another collection's SCHEMA (which field
+// references which parent). Declaring or dropping one is not ordinary
+// per-collection write access, so these gate the same way `gate()` already
+// treats any `_`-prefixed system collection: admin of some secured project
+// (or open, when nothing anywhere is secured — the pre-2.7.0 world).
+
+namespace {
+std::string relationOnDeleteToString(OnDelete v) {
+    switch (v) {
+        case OnDelete::Restrict: return "restrict";
+        case OnDelete::Cascade: return "cascade";
+        case OnDelete::SetNull: return "set_null";
+        case OnDelete::NoAction: return "no_action";
+    }
+    return "restrict";
+}
+
+OnDelete relationOnDeleteFromString(const std::string& s) {
+    if (s == "cascade") return OnDelete::Cascade;
+    if (s == "set_null") return OnDelete::SetNull;
+    if (s == "no_action") return OnDelete::NoAction;
+    return OnDelete::Restrict;
+}
+
+pb::RelationDefinition relationToProto(const RelationInfo& r) {
+    pb::RelationDefinition out;
+    out.set_name(r.name);
+    out.set_child(r.child);
+    out.set_child_field(r.childField);
+    out.set_parent(r.parent);
+    out.set_on_delete(relationOnDeleteToString(r.onDelete));
+    out.set_validate_on_write(r.validateOnWrite);
+    out.set_created_at(r.createdAt);
+    out.set_updated_at(r.updatedAt);
+    return out;
+}
+} // namespace
+
+void DatabaseGrpcImpl::armRelationsForChild(const std::string& childQualified) {
+    try {
+        const auto rc = smartbotic::database::resolveCollection(childQualified);
+        // getOrCreate(), NOT docStore()/get(): a relation declared for a
+        // project that has never been written to yet (a schema-first admin
+        // workflow - declare relations, then start writing) would otherwise
+        // find no store to arm, and NOTHING re-triggers arming once the
+        // project's env is lazily created on that first write. The reverse
+        // index would then stay unarmed for the life of the process,
+        // silently permitting every delete on that child - exactly the
+        // failure mode this method exists to prevent. getOrCreate() is the
+        // same call the write-mirror path already makes on first write, so
+        // this has no effect beyond ensuring the (possibly still-empty) env
+        // exists.
+        auto* registry = service_.projects();
+        auto* ds = registry != nullptr ? registry->getOrCreate(rc.project) : nullptr;
+        auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
+        if (lmdb == nullptr) return;  // no LMDB substrate for this project; nothing to arm
+
+        std::vector<smartbotic::db::storage::RelationRef> refs;
+        for (const auto& rel : relation_manager_.relationsWithChild(childQualified)) {
+            const auto rn = smartbotic::database::resolveCollection(rel.name);
+            refs.push_back(smartbotic::db::storage::RelationRef{rn.collection, rel.childField});
+        }
+        lmdb->set_relations(rc.collection, refs);
+        spdlog::info("v2.11 relations: re-armed {} relation(s) for child '{}'",
+                     refs.size(), childQualified);
+    } catch (const std::exception& e) {
+        spdlog::error("v2.11 relations: could not re-arm child '{}': {}",
+                      childQualified, e.what());
+    }
+}
+
+grpc::Status DatabaseGrpcImpl::CreateRelation(
+    grpc::ServerContext* context,
+    const pb::CreateRelationRequest* request,
+    pb::CreateRelationResponse* response
+) {
+    if (auto st = requireAnyAdmin(context, "CreateRelation"); !st.ok()) {
+        return st;
+    }
+
+    if (service_.isReadOnly()) {
+        response->set_success(false);
+        response->set_error("database is in read-only mode: " + service_.readOnlyReason());
+        return grpc::Status::OK;
+    }
+    if (store_.pressure() == MemoryPressure::Emergency) {
+        response->set_success(false);
+        response->set_error("memory pressure emergency (" +
+            std::to_string(store_.pressurePercent()) + "%); retry after backoff");
+        return grpc::Status::OK;
+    }
+
+    try {
+        RelationInfo r;
+        r.name = request->name();
+        r.child = request->child();
+        r.childField = request->child_field();
+        r.parent = request->parent();
+        r.onDelete = relationOnDeleteFromString(request->on_delete());
+        r.validateOnWrite = request->validate_on_write();
+
+        // Cross-project / malformed-name validation (no LMDB transaction
+        // spans two project envs, so a cross-project relation could never be
+        // enforced atomically) already lives in RelationManager — surfaced
+        // here, not duplicated.
+        std::string err;
+        bool ok = relation_manager_.createRelation(r, err);
+        response->set_success(ok);
+        if (!ok) {
+            response->set_error(err);
+            return grpc::Status::OK;
+        }
+
+        // Arm the write path immediately, or the reverse index stays empty
+        // (and every delete on this child silently succeeds) until the next
+        // restart's DatabaseService::applyRelationDeclarations().
+        armRelationsForChild(r.child);
+        return grpc::Status::OK;
+    } catch (const std::exception& e) {
+        return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
+    }
+}
+
+grpc::Status DatabaseGrpcImpl::DropRelation(
+    grpc::ServerContext* context,
+    const pb::DropRelationRequest* request,
+    pb::DropRelationResponse* response
+) {
+    if (auto st = requireAnyAdmin(context, "DropRelation"); !st.ok()) {
+        return st;
+    }
+
+    if (service_.isReadOnly()) {
+        response->set_success(false);
+        response->set_error("database is in read-only mode: " + service_.readOnlyReason());
+        return grpc::Status::OK;
+    }
+    if (store_.pressure() == MemoryPressure::Emergency) {
+        response->set_success(false);
+        response->set_error("memory pressure emergency (" +
+            std::to_string(store_.pressurePercent()) + "%); retry after backoff");
+        return grpc::Status::OK;
+    }
+
+    try {
+        // Capture the child BEFORE dropping - once gone, dropRelation() no
+        // longer knows what to re-arm.
+        auto existing = relation_manager_.getRelation(request->name());
+
+        std::string err;
+        bool ok = relation_manager_.dropRelation(request->name(), err);
+        response->set_success(ok);
+        if (!ok) {
+            response->set_error(err);
+            return grpc::Status::OK;
+        }
+
+        if (existing) armRelationsForChild(existing->child);
+        return grpc::Status::OK;
+    } catch (const std::exception& e) {
+        return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
+    }
+}
+
+grpc::Status DatabaseGrpcImpl::ListRelations(
+    grpc::ServerContext* context,
+    const pb::ListRelationsRequest* request,
+    pb::ListRelationsResponse* response
+) {
+    if (auto st = requireAnyAdmin(context, "ListRelations"); !st.ok()) {
+        return st;
+    }
+
+    // Empty project = list everything (operator / CLI). Clients always send
+    // their own project so one workspace never enumerates another's
+    // relations - same contract as ListViews.
+    for (const auto& r : relation_manager_.listRelations(request->project())) {
+        *response->add_relations() = relationToProto(r);
+    }
+    return grpc::Status::OK;
+}
+
+grpc::Status DatabaseGrpcImpl::GetRelationInfo(
+    grpc::ServerContext* context,
+    const pb::GetRelationInfoRequest* request,
+    pb::GetRelationInfoResponse* response
+) {
+    if (auto st = requireAnyAdmin(context, "GetRelationInfo"); !st.ok()) {
+        return st;
+    }
+
+    auto r = relation_manager_.getRelation(request->name());
+    if (!r) {
+        response->set_found(false);
+        return grpc::Status::OK;
+    }
+    *response->mutable_relation() = relationToProto(*r);
+    response->set_found(true);
+    return grpc::Status::OK;
+}
+
 // ===== DatabaseReplicationGrpcImpl =====
 // ===== DatabaseReplicationGrpcImpl =====
 
 
 DatabaseReplicationGrpcImpl::DatabaseReplicationGrpcImpl(
 DatabaseReplicationGrpcImpl::DatabaseReplicationGrpcImpl(

+ 36 - 0
service/src/database_grpc_impl.hpp

@@ -264,6 +264,32 @@ public:
         pb::GetViewInfoResponse* response
         pb::GetViewInfoResponse* response
     ) override;
     ) override;
 
 
+    // ===== Relation Operations (v2.11.0 T6b) =====
+
+    grpc::Status CreateRelation(
+        grpc::ServerContext* context,
+        const pb::CreateRelationRequest* request,
+        pb::CreateRelationResponse* response
+    ) override;
+
+    grpc::Status DropRelation(
+        grpc::ServerContext* context,
+        const pb::DropRelationRequest* request,
+        pb::DropRelationResponse* response
+    ) override;
+
+    grpc::Status ListRelations(
+        grpc::ServerContext* context,
+        const pb::ListRelationsRequest* request,
+        pb::ListRelationsResponse* response
+    ) override;
+
+    grpc::Status GetRelationInfo(
+        grpc::ServerContext* context,
+        const pb::GetRelationInfoRequest* request,
+        pb::GetRelationInfoResponse* response
+    ) override;
+
     // ===== Collection Config Operations =====
     // ===== Collection Config Operations =====
 
 
     // v2.9.0 — secondary index management.
     // v2.9.0 — secondary index management.
@@ -405,6 +431,16 @@ private:
     static std::vector<Filter> fromProtoFilters(const google::protobuf::RepeatedPtrField<pb::Filter>& filters);
     static std::vector<Filter> fromProtoFilters(const google::protobuf::RepeatedPtrField<pb::Filter>& filters);
     static Query fromProtoQuery(const pb::FindRequest& request);
     static Query fromProtoQuery(const pb::FindRequest& request);
 
 
+    // v2.11.0 T6b — re-arm a child collection's reverse index with its
+    // CURRENT set of relations (from relation_manager_), right after a
+    // runtime create/drop. Without this, a relation declared at runtime has
+    // an empty reverse index until the next restart's
+    // DatabaseService::applyRelationDeclarations(), and enforcement silently
+    // permits every delete in the meantime. No-op on a project with no LMDB
+    // substrate for `childQualified`. Advisory: logs and returns on failure,
+    // since the declaration itself already succeeded and persisted.
+    void armRelationsForChild(const std::string& childQualified);
+
     DatabaseService& service_;
     DatabaseService& service_;
     MemoryStore& store_;
     MemoryStore& store_;
     PersistenceManager& persistence_;
     PersistenceManager& persistence_;

+ 97 - 0
tests/load_test/test_relations_client_e2e.cpp

@@ -0,0 +1,97 @@
+// v2.11.0 T6b client/server boundary test.
+//
+// Covers the exact gap that let the v2.4.2 createView bug ship for two
+// releases: qualifying `collection`/`child`/`parent` but not `name` leaves
+// the manager's registry keyed on the bare name while every lookup sends
+// the qualified one, so the keys never meet and the declaration silently
+// behaves as if it does not exist. Relations have the same registry shape
+// (name -> RelationInfo, keyed by the project-qualified name) and the same
+// exposure, so this is the first thing to prove, with a NON-DEFAULT project.
+//
+// tests/test_relation_manager.cpp and tests/test_relation_enforcement.cpp
+// exercise RelationManager and RelationEnforcer in-process; neither drives a
+// real Client against a real server, which is the only place a
+// qualify/unqualify mismatch is observable.
+
+#include <smartbotic/database/client.hpp>
+#include <iostream>
+#include <string>
+
+using namespace smartbotic::database;
+static int pass = 0, fail = 0;
+static void ck(bool c, const char* m) {
+    if (c) { ++pass; } else { ++fail; std::cerr << "FAIL: " << m << "\n"; }
+}
+
+int main() {
+    // ---- Part 1: create/list/get under a NON-DEFAULT project, over gRPC.
+    Client c({.address = "127.0.0.1:9012", .project = "acme"});
+    c.connect();
+
+    ck(c.createRelation("exec_wf", "executions", "workflowId", "workflows"),
+       "createRelation succeeds with a non-default project");
+
+    auto listed = c.listRelations();
+    bool found = false;
+    for (const auto& r : listed) {
+        if (r.name == "exec_wf") {
+            found = true;
+            ck(r.child == "executions", "listRelations reports the BARE child name (round-trip)");
+            ck(r.parent == "workflows", "listRelations reports the BARE parent name (round-trip)");
+            ck(r.childField == "workflowId", "listRelations reports child_field verbatim");
+            ck(r.onDelete == "restrict", "listRelations reports the default on_delete");
+        }
+    }
+    ck(found, "listRelations sees the relation created under its BARE name - "
+              "this is the exact key-mismatch v2.4.2 shipped for createView");
+
+    auto info = c.getRelationInfo("exec_wf");
+    ck(info.has_value(), "getRelationInfo finds the relation by its bare name");
+    ck(info && info->child == "executions", "getRelationInfo round-trips child");
+    ck(info && info->parent == "workflows", "getRelationInfo round-trips parent");
+
+    // Another project must not see this one - same isolation contract as
+    // listViews()/listCollections().
+    Client other({.address = "127.0.0.1:9012", .project = "other_proj"});
+    other.connect();
+    auto theirRelations = other.listRelations();
+    bool leaked = false;
+    for (const auto& r : theirRelations) if (r.name == "exec_wf") leaked = true;
+    ck(!leaked, "listRelations does not leak another project's relation");
+
+    // ---- Part 2: cross-project relations are refused (RelationManager
+    // validates this; the RPC must surface the reason, not swallow it).
+    ck(!c.createRelation("bad_cross", "executions", "x", "other_proj:workflows"),
+       "cross-project relation is refused");
+
+    // ---- Part 3: enforcement blocks a delete with children, and drop
+    // requires the relation to exist first.
+    c.upsert("workflows", nlohmann::json{{"name", "wf-1"}}, "wf-1");
+    c.upsert("executions", nlohmann::json{{"workflowId", "wf-1"}}, "ex-1");
+
+    std::string err;
+    bool deleted = c.remove("workflows", "wf-1", err);
+    ck(!deleted, "delete of a referenced parent is refused");
+    ck(!err.empty(), "remove(..., errorOut) surfaces the refusal message");
+
+    ck(c.getRelationsEnforced("workflows"), "relations_enforced defaults to true");
+    ck(c.setRelationsEnforced("workflows", false), "setRelationsEnforced(false) accepted");
+    ck(!c.getRelationsEnforced("workflows"), "setRelationsEnforced round-trips");
+
+    err.clear();
+    deleted = c.remove("workflows", "wf-1", err);
+    ck(deleted, "delete succeeds once enforcement is disabled for the collection");
+    ck(err.empty(), "no error on a successful delete");
+
+    ck(c.setRelationsEnforced("workflows", true), "re-enable relations_enforced");
+
+    ck(c.dropRelation("exec_wf"), "dropRelation succeeds");
+    ck(!c.getRelationInfo("exec_wf").has_value(), "relation is gone after drop");
+    ck(!c.dropRelation("exec_wf"), "dropping a gone relation fails (not idempotent-success)");
+
+    // c.remove leaves ex-1 behind; harmless, the process exits.
+    (void)c.remove("executions", "ex-1");
+
+    std::cout << "\npassed=" << pass << " failed=" << fail << "\n";
+    return fail == 0 ? 0 : 1;
+}

+ 66 - 0
tests/load_test/test_relations_client_e2e.sh

@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# v2.11.0 T6b client/server boundary e2e — relation management over gRPC
+# with a non-default project. See test_relations_client_e2e.cpp for what
+# each assertion covers and why the unit suite could not catch it.
+
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+DIR=/tmp/sbdb-relations-client-e2e
+PORT=9012
+
+rm -rf "$DIR"
+mkdir -p "$DIR/data"
+
+cat > "$DIR/config.json" <<EOF
+{
+  "storage": {
+    "data_directory": "$DIR/data",
+    "bind_address": "127.0.0.1",
+    "rpc_port": $PORT,
+    "encryption": { "enabled": false },
+    "migrations": { "enabled": false },
+    "replication": { "enabled": false }
+  }
+}
+EOF
+
+echo "=== building test binary ==="
+g++ -std=c++20 -O1 -o "$DIR/test_relations_client_e2e" test_relations_client_e2e.cpp \
+    -I"$ROOT/client/include" -I"$ROOT/build/client" \
+    -L"$ROOT/build/client" -lsmartbotic-db-client -lspdlog -lfmt
+
+echo "=== booting server on $PORT ==="
+"$ROOT/build/service/smartbotic-database" --config "$DIR/config.json" \
+    > "$DIR/server.log" 2>&1 &
+SERVER_PID=$!
+cleanup() { kill "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true; }
+trap cleanup EXIT
+
+for _ in $(seq 1 60); do
+    grep -q "Notified systemd: READY" "$DIR/server.log" && break
+    sleep 0.5
+done
+if ! grep -q "Notified systemd: READY" "$DIR/server.log"; then
+    echo "server failed to become ready; log:"
+    tail -30 "$DIR/server.log"
+    exit 1
+fi
+
+echo "=== running assertions ==="
+set +e
+LD_LIBRARY_PATH="$ROOT/build/client" "$DIR/test_relations_client_e2e"
+RC=$?
+set -e
+
+if [[ $RC -ne 0 ]]; then
+    echo ""
+    echo "FAILED — server log tail:"
+    tail -30 "$DIR/server.log"
+    exit $RC
+fi
+
+echo ""
+echo "OK — relations client/server e2e passed"