Преглед изворни кода

feat(relations): T5 - DescribeDelete RPC

Answers "what would happen if I deleted this?" without deleting anything.
Enumerates every relation whose parent is the target collection - not just
the blocking ones - so an operator sees restrict/cascade/set_null/no_action
side by side, with child counts and up to five sample child ids sourced
from the reverse index (mdb_cursor_count, no document reads). Gated as an
ordinary per-collection read, not admin.

- proto: DescribeDeleteRequest/RelationImpact/DescribeDeleteResponse + rpc
- relation_enforcement.{hpp,cpp}: describeDeleteImpacts(), structural
  (does not consult relations_enforced, same convention as
  findRelationBlocks) - the gRPC handler ANDs it with the live flag so
  would_be_blocked always agrees with what Delete() actually does
- database_grpc_impl: DescribeDelete handler
- client: Client::describeDelete() + brand-new RelationImpact/
  DescribeDeleteResult structs (no members added to existing public structs)
- cli: describe-delete <collection> <id>
- tests: mixed restrict+no_action case, zero-children case, no-relations
  case; 45/0 in test_relation_enforcement

Manually verified against a live server: would_be_blocked agreed with an
actual Delete() call in both the enforced and unenforced states.
fszontagh пре 1 месец
родитељ
комит
f572d0e837

+ 43 - 0
cli/main.cpp

@@ -94,6 +94,8 @@ void printUsage() {
               << "  " << 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 << "describe-delete" << C_RESET << " <collection> <id>"
+              << "        What would happen if this document were deleted\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 << "policy" << C_RESET << " <project> <principal>       Show one policy\n"
@@ -759,6 +761,47 @@ bool execCommand(smartbotic::database::Client& client,
             return true;
         }
 
+        // v2.11.0 T5 — DescribeDelete: "what would happen if I deleted this?"
+        // without deleting anything. A per-collection READ, not admin - any
+        // caller who can read the collection can ask this. Cheap enough to
+        // run before every delete: counts come from the reverse index.
+        if (cmd == "describe-delete") {
+            if (params.size() < 2) {
+                printError("usage: describe-delete <collection> <id>");
+                return false;
+            }
+            auto d = client.describeDelete(params[0], params[1]);
+            if (!d.success) {
+                printError("could not describe the delete: " + d.error);
+                return false;
+            }
+            if (d.impacts.empty()) {
+                std::cout << "no relations reference " << params[0] << "/" << params[1]
+                          << " - safe to delete\n";
+                return true;
+            }
+            std::cout << (d.wouldBeBlocked
+                              ? (C_RED + std::string("would be BLOCKED") + C_RESET)
+                              : (C_GREEN + std::string("would proceed") + C_RESET))
+                      << " deleting " << params[0] << "/" << params[1] << ":\n";
+            for (const auto& imp : d.impacts) {
+                std::cout << "  " << (imp.blocks ? C_RED : C_DIM) << "[" << imp.onDelete << "]"
+                          << C_RESET << " " << imp.relation << ": " << imp.childCount
+                          << " child document(s) in " << imp.childCollection
+                          << " via " << imp.childField;
+                if (!imp.sampleChildIds.empty()) {
+                    std::cout << " (e.g. ";
+                    for (size_t i = 0; i < imp.sampleChildIds.size(); ++i) {
+                        if (i) std::cout << ", ";
+                        std::cout << imp.sampleChildIds[i];
+                    }
+                    std::cout << ")";
+                }
+                std::cout << (imp.blocks ? "  BLOCKS" : "") << "\n";
+            }
+            return true;
+        }
+
         // ===== v2.7.0 access policy =====
         //
         // Policy lives in the `_policies` collection and is managed through the

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

@@ -797,6 +797,50 @@ public:
      */
     [[nodiscard]] bool getRelationsEnforced(const std::string& collection);
 
+    /**
+     * One relation's impact on deleting a specific document, as reported by
+     * describeDelete(). A brand-new struct, not a member added to an
+     * existing one - safe under the unchanged soname.
+     */
+    struct RelationImpact {
+        std::string relation;
+        std::string childCollection;
+        std::string childField;
+        std::string onDelete;              // restrict | cascade | set_null | no_action
+        uint64_t childCount = 0;
+        std::vector<std::string> sampleChildIds;   // at most five
+        bool blocks = false;
+    };
+
+    /**
+     * The result of describeDelete(): whether deleting collection/id would
+     * be blocked right now, and why. Read-only - describeDelete() never
+     * deletes anything.
+     */
+    struct DescribeDeleteResult {
+        bool success = false;
+        std::string error;
+        bool wouldBeBlocked = false;
+        std::vector<RelationImpact> impacts;
+    };
+
+    /**
+     * "What would happen if I deleted this?" - answered without deleting
+     * anything. Reports EVERY relation whose parent is `collection`,
+     * including cascade/no_action/set_null (with blocks=false), not just
+     * the ones that would block - the whole point is showing an operator
+     * what each declared policy will do. `impacts[i].blocks` is ANDed
+     * with the collection's live relations_enforced flag, so it and
+     * wouldBeBlocked agree with what an actual delete() would do right now.
+     *
+     * Gated as an ordinary per-collection READ, not admin - it changes
+     * nothing, but child counts and sample ids are facts about data.
+     * Cheap: counts come from the reverse index (mdb_cursor_count), not a
+     * collection scan, so this is meant to be called before every delete.
+     */
+    [[nodiscard]] DescribeDeleteResult describeDelete(const std::string& collection,
+                                                       const std::string& id);
+
     // ===== Event Subscription =====
 
     using EventCallback = std::function<void(const std::string& collection,

+ 47 - 0
client/src/client.cpp

@@ -1520,6 +1520,48 @@ public:
         return relationFromProto(response.relation());
     }
 
+    // v2.11.0 T5 — "what would happen if I deleted this?" Gated as an
+    // ordinary read server-side, so `collection` is qualified the same way
+    // get()/find() qualify theirs - not the admin-only qualify() used by
+    // the relation-management calls above (which is the same call, just a
+    // reminder this one follows the read family, not the admin family).
+    Client::DescribeDeleteResult describeDelete(const std::string& collection,
+                                                const std::string& id) {
+        smartbotic::databasepb::DescribeDeleteRequest request;
+        request.set_collection(qualify(collection));
+        request.set_id(id);
+
+        smartbotic::databasepb::DescribeDeleteResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        Client::DescribeDeleteResult out;
+        auto status = stub_->DescribeDelete(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::describeDelete failed: {}", status.error_message());
+            out.success = false;
+            out.error = status.error_message();
+            return out;
+        }
+        out.success = response.success();
+        out.error = response.error();
+        out.wouldBeBlocked = response.would_be_blocked();
+        out.impacts.reserve(response.impacts_size());
+        for (const auto& pbImpact : response.impacts()) {
+            Client::RelationImpact impact;
+            impact.relation = unqualify(pbImpact.relation());
+            impact.childCollection = unqualify(pbImpact.child_collection());
+            impact.childField = pbImpact.child_field();
+            impact.onDelete = pbImpact.on_delete();
+            impact.childCount = pbImpact.child_count();
+            impact.sampleChildIds.assign(pbImpact.sample_child_ids().begin(),
+                                         pbImpact.sample_child_ids().end());
+            impact.blocks = pbImpact.blocks();
+            out.impacts.push_back(std::move(impact));
+        }
+        return out;
+    }
+
     // ===== Event Subscription =====
 
     class SubscriptionHandle {
@@ -2400,6 +2442,11 @@ bool Client::getRelationsEnforced(const std::string& collection) {
     return impl_->getRelationsEnforced(collection);
 }
 
+Client::DescribeDeleteResult Client::describeDelete(const std::string& collection,
+                                                     const std::string& id) {
+    return impl_->describeDelete(collection, id);
+}
+
 std::shared_ptr<void> Client::subscribe(const std::vector<std::string>& collections, EventCallback callback) {
     return impl_->subscribe(collections, std::move(callback));
 }

+ 31 - 0
proto/database.proto

@@ -79,6 +79,10 @@ service DatabaseService {
     rpc ListRelations(ListRelationsRequest) returns (ListRelationsResponse);
     rpc GetRelationInfo(GetRelationInfoRequest) returns (GetRelationInfoResponse);
 
+    // v2.11.0 T5 — "what would happen if I deleted this?" without deleting
+    // it. Read-only and gated as an ordinary per-collection read, not admin.
+    rpc DescribeDelete(DescribeDeleteRequest) returns (DescribeDeleteResponse);
+
     // Collection configuration
     rpc ConfigureCollection(ConfigureCollectionRequest) returns (ConfigureCollectionResponse);
 
@@ -961,6 +965,33 @@ message GetRelationInfoResponse {
     bool found = 2;
 }
 
+// v2.11.0 T5 — "what would happen if I deleted this?", answered without
+// deleting anything. A relational database exposes its constraints through
+// DDL you can read; a document store has none, so this has to be a query.
+// Gated as an ordinary READ on `collection` (it changes nothing), unlike
+// the relation-management RPCs above which are admin-only.
+message DescribeDeleteRequest {
+    string collection = 1;
+    string id = 2;
+}
+
+message RelationImpact {
+    string relation = 1;
+    string child_collection = 2;
+    string child_field = 3;
+    string on_delete = 4;        // restrict | cascade | set_null | no_action
+    uint64 child_count = 5;
+    repeated string sample_child_ids = 6;   // at most five
+    bool blocks = 7;
+}
+
+message DescribeDeleteResponse {
+    bool success = 1;
+    string error = 2;
+    bool would_be_blocked = 3;
+    repeated RelationImpact impacts = 4;
+}
+
 // ===== Collection Configuration =====
 
 // Per-collection configuration for timestamp precision and other runtime knobs.

+ 74 - 0
service/src/database_grpc_impl.cpp

@@ -2825,6 +2825,80 @@ grpc::Status DatabaseGrpcImpl::GetRelationInfo(
     return grpc::Status::OK;
 }
 
+// v2.11.0 T5 — DescribeDelete: "what would happen if I deleted this?"
+// answered as a query, without deleting anything. Unlike CreateRelation and
+// its siblings, this names an ordinary document (collection + id), not a
+// relation declaration, so it gates like Get/Find/Exists - a per-collection
+// READ - not admin. It must not mutate anything: no index writes, no
+// document writes, no set_relations, read-only throughout.
+grpc::Status DatabaseGrpcImpl::DescribeDelete(
+    grpc::ServerContext* context,
+    const pb::DescribeDeleteRequest* request,
+    pb::DescribeDeleteResponse* response
+) {
+    smartbotic::database::Decision dec;
+    if (auto st = gate(context, request->collection(), smartbotic::database::Access::Read, dec);
+        !st.ok()) {
+        return st;
+    }
+
+    try {
+        const auto rc = smartbotic::database::resolveCollection(request->collection());
+        auto* ds = service_.docStore(rc.project);
+        if (ds == nullptr) {
+            response->set_success(false);
+            response->set_error("no storage for project '" + rc.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;
+        }
+
+        // Named local: CollectionCfg is returned by value, and this reads
+        // relationsEnforced directly (no RelationEnforcer needed here -
+        // this handler never calls canDelete(), it computes the fuller
+        // per-relation enumeration itself).
+        const CollectionCfg cfg = config_manager_.configFor(request->collection());
+
+        const auto impacts = describeDeleteImpacts(
+            relation_manager_, *lmdb, request->collection(), request->id());
+
+        bool wouldBeBlocked = false;
+        for (const auto& imp : impacts) {
+            auto* pbImpact = response->add_impacts();
+            pbImpact->set_relation(imp.relation);
+            pbImpact->set_child_collection(imp.childCollection);
+            pbImpact->set_child_field(imp.childField);
+            pbImpact->set_on_delete(relationOnDeleteToString(imp.onDelete));
+            pbImpact->set_child_count(imp.childCount);
+            for (const auto& id : imp.sampleChildIds) {
+                pbImpact->add_sample_child_ids(id);
+            }
+            // Structural blocking (imp.blocks) ANDed with the live
+            // relations_enforced flag: an operator with enforcement OFF
+            // still sees which relations WOULD block if it were on, but
+            // both this field and would_be_blocked agree with what an
+            // actual Delete() would do right now.
+            const bool blocks = cfg.relationsEnforced && imp.blocks;
+            pbImpact->set_blocks(blocks);
+            if (blocks) wouldBeBlocked = true;
+        }
+
+        response->set_would_be_blocked(wouldBeBlocked);
+        response->set_success(true);
+        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

@@ -290,6 +290,15 @@ public:
         pb::GetRelationInfoResponse* response
     ) override;
 
+    // v2.11.0 T5 — "what would happen if I deleted this?" Read-only, gated
+    // as an ordinary per-collection read (not admin) - unlike the relation
+    // management RPCs above.
+    grpc::Status DescribeDelete(
+        grpc::ServerContext* context,
+        const pb::DescribeDeleteRequest* request,
+        pb::DescribeDeleteResponse* response
+    ) override;
+
     // ===== Collection Config Operations =====
 
     // v2.9.0 — secondary index management.

+ 37 - 0
service/src/relations/relation_enforcement.cpp

@@ -48,6 +48,43 @@ std::vector<RelationBlock> findRelationBlocks(
     return out;
 }
 
+std::vector<RelationImpact> describeDeleteImpacts(
+    RelationManager& relations,
+    smartbotic::db::storage::LmdbDocumentStore& store,
+    const std::string& qualifiedParentCollection,
+    const std::string& parentId) {
+    std::vector<RelationImpact> out;
+
+    for (const auto& r : relations.relationsWithParent(qualifiedParentCollection)) {
+        // relation_index_* take the BARE relation name; RelationInfo::name
+        // is project-qualified. Same guard as findRelationBlocks: a
+        // malformed name should never happen (createRelation validates),
+        // but a describe query is not the place to throw over a data
+        // problem in an unrelated declaration - skip it.
+        std::string bareRelation;
+        try {
+            bareRelation = resolveCollection(r.name).collection;
+        } catch (const std::exception&) {
+            continue;
+        }
+
+        RelationImpact impact;
+        impact.relation = r.name;
+        impact.childCollection = r.child;
+        impact.childField = r.childField;
+        impact.onDelete = r.onDelete;
+        impact.childCount = store.relation_index_child_count(bareRelation, parentId);
+        if (impact.childCount > 0) {
+            impact.sampleChildIds = store.relation_index_children(bareRelation, parentId, 5);
+        }
+        impact.blocks = (r.onDelete == OnDelete::Restrict) && (impact.childCount > 0);
+
+        out.push_back(std::move(impact));
+    }
+
+    return out;
+}
+
 std::string formatRelationBlockError(
     const std::string& qualifiedParentCollection,
     const std::string& parentId,

+ 41 - 0
service/src/relations/relation_enforcement.hpp

@@ -76,6 +76,47 @@ std::string formatRelationBlockError(
     const std::string& parentId,
     const std::vector<RelationBlock>& blocks);
 
+// One relation's impact on deleting qualifiedParentCollection/parentId, for
+// DescribeDelete (Task 5).
+//
+// Unlike RelationBlock/findRelationBlocks, this enumerates EVERY relation
+// whose parent is this collection - restrict, cascade, set_null AND
+// no_action - because DescribeDelete exists to show an operator what each
+// declared policy will do, not just which ones currently block. `blocks`
+// is the structural answer ("this relation's policy is restrict and it has
+// live children right now"): true only for Restrict with childCount > 0,
+// mirroring findRelationBlocks' own filter. It deliberately does NOT fold
+// in CollectionCfg::relationsEnforced - same reasoning as
+// findRelationBlocks: whether the flag is consulted is the caller's
+// decision, not this query's. DescribeDelete's handler ANDs `blocks` with
+// the live relationsEnforced value to produce would_be_blocked, so a
+// disabled collection still shows an operator which relations WOULD block
+// if it were turned back on.
+struct RelationImpact {
+    std::string relation;          // qualified name, e.g. "default:exec_wf"
+    std::string childCollection;   // qualified, e.g. "default:executions"
+    std::string childField;        // dot-path on the child
+    OnDelete onDelete = OnDelete::Restrict;
+    uint64_t childCount = 0;
+    std::vector<std::string> sampleChildIds;   // at most five
+    bool blocks = false;
+};
+
+// Every relation whose parent is qualifiedParentCollection, each annotated
+// with its live child count and blocking status against parentId. Empty
+// means this collection has no declared relations at all (not the same as
+// "the delete is safe" - see `blocks` on each entry, and would_be_blocked
+// in the DescribeDelete response).
+//
+// Same cost profile as findRelationBlocks: relation_index_child_count is
+// backed by mdb_cursor_count, so this reads no documents and is cheap
+// enough for a UI to call before every delete.
+std::vector<RelationImpact> describeDeleteImpacts(
+    RelationManager& relations,
+    smartbotic::db::storage::LmdbDocumentStore& store,
+    const std::string& qualifiedParentCollection,
+    const std::string& parentId);
+
 // Thin, stateful wrapper around findRelationBlocks()/formatRelationBlockError()
 // that also applies the relationsEnforced switch (Task 8). Exists so the
 // decision is directly unit-testable (see tests/test_relation_enforcement.cpp)

+ 129 - 0
tests/test_relation_enforcement.cpp

@@ -37,6 +37,8 @@ using smartbotic::database::RelationBlock;
 using smartbotic::database::RelationEnforcer;
 using smartbotic::database::RelationInfo;
 using smartbotic::database::RelationManager;
+using smartbotic::database::RelationImpact;
+using smartbotic::database::describeDeleteImpacts;
 using smartbotic::database::findRelationBlocks;
 using smartbotic::db::storage::LmdbDocumentStore;
 using smartbotic::db::storage::LmdbEnv;
@@ -303,6 +305,130 @@ void test_relations_enforced_false_skips_the_check() {
     mstore.stop();
 }
 
+// v2.11.0 T5 — DescribeDelete's enumeration. The case that matters most:
+// a parent with children under a restrict relation AND children under a
+// no_action relation. Unlike findRelationBlocks (which only reports
+// blockers), describeDeleteImpacts must report BOTH relations, and mark
+// only the restrict one as blocking.
+void test_describe_delete_reports_restrict_and_no_action() {
+    MemoryStore mstore(MemoryStore::Config{});
+    mstore.start();
+    RelationManager rm(mstore);
+    rm.loadFromStore();
+
+    TmpEnv t("rel-describe-mixed");
+    LmdbDocumentStore store(t.env);
+    store.set_relations("executions", {{"exec_wf", "workflowId"}});
+    store.set_relations("comments", {{"comment_wf", "workflowId"}});
+
+    auto put = [&](const std::string& coll, const std::string& id, const nlohmann::json& data) {
+        Document d; d.id = id; d.collection = coll; d.set_data(data);
+        store.put(coll, id, d);
+    };
+    put("executions", "e1", {{"workflowId", "wf-1"}});
+    put("executions", "e2", {{"workflowId", "wf-1"}});
+    put("comments", "c1", {{"workflowId", "wf-1"}});
+
+    std::string mgrErr;
+    RelationInfo restrictRel;
+    restrictRel.name = "default:exec_wf";
+    restrictRel.child = "default:executions";
+    restrictRel.childField = "workflowId";
+    restrictRel.parent = "default:workflows";
+    restrictRel.onDelete = OnDelete::Restrict;
+    check(rm.createRelation(restrictRel, mgrErr), "declared the restrict relation");
+
+    RelationInfo noActionRel;
+    noActionRel.name = "default:comment_wf";
+    noActionRel.child = "default:comments";
+    noActionRel.childField = "workflowId";
+    noActionRel.parent = "default:workflows";
+    noActionRel.onDelete = OnDelete::NoAction;
+    check(rm.createRelation(noActionRel, mgrErr), "declared the no_action relation");
+
+    auto impacts = describeDeleteImpacts(rm, store, "default:workflows", "wf-1");
+    check(impacts.size() == 2, "both relations are reported, not just the blocker");
+
+    const RelationImpact* restrictImpact = nullptr;
+    const RelationImpact* noActionImpact = nullptr;
+    for (const auto& imp : impacts) {
+        if (imp.relation == "default:exec_wf") restrictImpact = &imp;
+        if (imp.relation == "default:comment_wf") noActionImpact = &imp;
+    }
+    check(restrictImpact != nullptr, "restrict relation present");
+    check(noActionImpact != nullptr, "no_action relation present");
+    if (restrictImpact) {
+        check(restrictImpact->childCount == 2, "restrict relation counts both children");
+        check(restrictImpact->blocks, "restrict relation with live children blocks");
+        check(restrictImpact->sampleChildIds.size() == 2, "samples both blocking ids");
+    }
+    if (noActionImpact) {
+        check(noActionImpact->childCount == 1, "no_action relation counts its child");
+        check(!noActionImpact->blocks, "no_action never blocks, even with live children");
+        check(noActionImpact->sampleChildIds.size() == 1, "still samples the id for visibility");
+    }
+
+    // Consistency with the actual enforcement decision: RelationEnforcer
+    // (what Delete() really calls) agrees that this delete is blocked, by
+    // exactly the relation describeDeleteImpacts flagged.
+    bool enforced = true;
+    RelationEnforcer enforcer(rm, store, enforced);
+    std::string err;
+    check(!enforcer.canDelete("default:workflows", "wf-1", err),
+          "a real delete right now would in fact be blocked, agreeing with the impact");
+
+    mstore.stop();
+}
+
+// A relation with no live children is reported (so an operator can see the
+// declaration exists) but never blocks - zero postings is not a reference,
+// matching findRelationBlocks' "zero count is not a block" rule.
+void test_describe_delete_zero_children_does_not_block() {
+    MemoryStore mstore(MemoryStore::Config{});
+    mstore.start();
+    RelationManager rm(mstore);
+    rm.loadFromStore();
+
+    TmpEnv t("rel-describe-empty");
+    LmdbDocumentStore store(t.env);
+    store.set_relations("executions", {{"exec_wf", "workflowId"}});
+
+    RelationInfo rel;
+    rel.name = "default:exec_wf";
+    rel.child = "default:executions";
+    rel.childField = "workflowId";
+    rel.parent = "default:workflows";
+    rel.onDelete = OnDelete::Restrict;
+    std::string mgrErr;
+    check(rm.createRelation(rel, mgrErr), "declared the relation");
+
+    auto impacts = describeDeleteImpacts(rm, store, "default:workflows", "wf-nonexistent");
+    check(impacts.size() == 1, "the declared relation is reported even with no children");
+    check(impacts[0].childCount == 0, "no children referencing this parent id");
+    check(!impacts[0].blocks, "zero children never blocks");
+    check(impacts[0].sampleChildIds.empty(), "no sample ids when there are no children");
+
+    mstore.stop();
+}
+
+// A collection with no declared relations at all reports an empty impacts
+// list - describeDeleteImpacts, like findRelationBlocks, has nothing to say
+// about a parent nothing points at.
+void test_describe_delete_no_relations_declared() {
+    MemoryStore mstore(MemoryStore::Config{});
+    mstore.start();
+    RelationManager rm(mstore);
+    rm.loadFromStore();
+
+    TmpEnv t("rel-describe-none");
+    LmdbDocumentStore store(t.env);
+
+    auto impacts = describeDeleteImpacts(rm, store, "default:orphan_collection", "anything");
+    check(impacts.empty(), "no relations declared means no impacts reported");
+
+    mstore.stop();
+}
+
 }  // namespace
 
 int main() {
@@ -314,6 +440,9 @@ int main() {
     test_restrict_blocks_and_names_the_blockers();
     test_cascade_and_set_null_permit_for_now();
     test_relations_enforced_false_skips_the_check();
+    test_describe_delete_reports_restrict_and_no_action();
+    test_describe_delete_zero_children_does_not_block();
+    test_describe_delete_no_relations_declared();
 
     std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
     return g_fail == 0 ? 0 : 1;