diff --git a/README.md b/README.md index 0f89825..b91613b 100644 --- a/README.md +++ b/README.md @@ -16,20 +16,20 @@ The project compiles clean with zero warnings (OTP 27 / rebar3 3.24). The architecture is fully designed (see [`docs/Architecture.md`](docs/Architecture.md)). Implementation is underway: -| Component | Status | -| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `nref` subsystem | Fully implemented (DETS — Disk-based Erlang Term Storage — backed ID allocator with `set_floor/1`) | -| `dictionary` subsystem | `dictionary_imp` implemented; `dictionary_server` / `term_server` wired to it | -| `graphdb_bootstrap` | Fully implemented — Mnesia (Erlang's built-in distributed database) schema/table creation, bootstrap scaffold loader (38 nodes, 38 relationship pairs) | -| `graphdb_mgr` | Implemented — bootstrap init, public read API (`get_node`, `get_relationships`), category immutability guard, cache audit/repair (`verify_caches/0`, `rebuild_caches/0`); write operations delegate to workers | -| `graphdb_attr` | Fully implemented — attribute library (name, literal, relationship attributes, relationship types) | -| `graphdb_class` | Fully implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph), qualifying characteristics, class-level inheritance | -| `graphdb_instance` | Fully implemented — compositional hierarchy, multi-class membership, four-level inheritance with class-resolver ambiguity detection; fires composition rules on `create_instance/3`, surfaces `proposed` outcomes for propose-mode rules, fires connection rules via a caller-supplied resolver on `create_instance/4`, and applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/5` | -| `graphdb_rules` | Implemented — rule meta-ontology + create/retrieve; `effective_rules_for_class/2` + `effective_connection_rules/2` (taxonomy walk); composition firing engine; propose mode; connection firing; horizontal conflict precedence; the later firing-engine phases outstanding (see `TASKS.md`) | -| `graphdb_language` | Fully implemented — multilingual overlay (language registration, dialect chains, per-language overlay tables, label resolution, translation hooks) | -| `graphdb_query` | Implemented — query language (parse/execute, snapshot-semantics sessions, path finding) | - -**523 tests** (105 EUnit + 418 Common Test) — all passing. See +| Component | Status | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nref` subsystem | Fully implemented (DETS — Disk-based Erlang Term Storage — backed ID allocator with `set_floor/1`) | +| `dictionary` subsystem | `dictionary_imp` implemented; `dictionary_server` / `term_server` wired to it | +| `graphdb_bootstrap` | Fully implemented — Mnesia (Erlang's built-in distributed database) schema/table creation, bootstrap scaffold loader (38 nodes, 38 relationship pairs) | +| `graphdb_mgr` | Implemented — bootstrap init, public read API (`get_node`, `get_relationships`), category immutability guard, cache audit/repair (`verify_caches/0`, `rebuild_caches/0`); `retire_node/1` / `unretire_node/1` soft-retire; `delete_node/1` reserved for future hard delete | +| `graphdb_attr` | Fully implemented — attribute library (name, literal, relationship attributes, relationship types); seeds `retired` lifecycle marker | +| `graphdb_class` | Fully implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph), qualifying characteristics, class-level inheritance | +| `graphdb_instance` | Fully implemented — compositional hierarchy, multi-class membership, four-level inheritance with class-resolver ambiguity detection; refuses retired nodes as new targets/parents/endpoints; fires composition rules on `create_instance/3`, surfaces `proposed` outcomes for propose-mode rules, fires connection rules via a caller-supplied resolver on `create_instance/4`, and applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/5` | +| `graphdb_rules` | Implemented — rule meta-ontology + create/retrieve; `effective_rules_for_class/2` + `effective_connection_rules/2` (taxonomy walk); composition firing engine; propose mode; connection firing; horizontal conflict precedence; the later firing-engine phases outstanding (see `TASKS.md`) | +| `graphdb_language` | Fully implemented — multilingual overlay (language registration, dialect chains, per-language overlay tables, label resolution, translation hooks) | +| `graphdb_query` | Implemented — query language (parse/execute, snapshot-semantics sessions, path finding) | + +**537 tests** (105 EUnit + 432 Common Test) — all passing. See `TASKS.md` for remaining work. --- @@ -200,7 +200,7 @@ Priority order — each step applies only to attributes not yet resolved by a hi | `graphdb_rules` | Graph rules — rule meta-ontology + create/retrieve; taxonomy-walk effective-rules reads; composition firing engine; propose mode; connection firing; horizontal conflict precedence; later phases outstanding | | `graphdb_language` | Multilingual overlay — language registration, dialect chains, per-language overlay tables, label resolution | | `graphdb_query` | Query language — parses and executes graph queries; snapshot-semantics sessions | -| `graphdb_mgr` | Primary coordinator — routes operations across the other specialized workers | +| `graphdb_mgr` | Primary coordinator — routes operations across the other specialized workers; soft-retire (`retire_node/1` / `unretire_node/1`) | --- @@ -232,26 +232,26 @@ Priority order — each step applies only to attributes not yet resolved by a hi ./rebar3 eunit --app=graphdb && ./rebar3 ct ``` -| Suite | Type | Tests | Coverage | -| ------------------------- | ----- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `graphdb_bootstrap_tests` | EUnit | 61 | Term parsing, validation, record conversion, nref macro consistency | -| `graphdb_class_tests` | EUnit | 13 | `is_valid_parent_kind/1`, `collect_qc_nrefs/2` | -| `graphdb_instance_tests` | EUnit | 13 | `find_avp_value/2`, composition-firing helpers (`summarize/1` etc.) | -| `graphdb_language_tests` | EUnit | 9 | Dialect-chain building, label-resolution helpers | -| `graphdb_mgr_tests` | EUnit | 9 | Direction validation, client-side arg checks | -| `graphdb_bootstrap_SUITE` | CT | 19 | Full bootstrap load, Mnesia tables, idempotency, error handling, Language subcategory nodes | -| `graphdb_mgr_SUITE` | CT | 28 | Bootstrap init, read ops, category guard, write stubs, cache audit/repair | -| `graphdb_attr_SUITE` | CT | 37 | Attribute create/lookup, seeding, relationship types, atomic reciprocal pair, literal sub-groups, `attribute_type`/`instantiable` markers | -| `graphdb_class_SUITE` | CT | 49 | Class create, QC (qualifying characteristics), lookups, hierarchy, multi-inheritance, inheritance, templates, abstract classes | -| `graphdb_instance_SUITE` | CT | 106 | Instance create (incl. composition rule firing, propose-mode outcomes, `{Min,Max}` multiplicity, connection firing — resolver-driven mandatory/auto/propose, target validation — and horizontal conflict precedence via `create_instance/5`), relationships (incl. arc validation, per-arc AVPs — attribute-value pairs), lookups, hierarchy, four-level inheritance, multi-class membership | -| `graphdb_language_SUITE` | CT | 27 | Multilingual overlay: language/dialect registration, per-language overlay tables, label resolution, translation hooks | -| `graphdb_query_SUITE` | CT | 43 | Query language: parse/execute, snapshot-semantics sessions, `#cont_path{}` resume, path finding | -| `graphdb_rules_SUITE` | CT | 80 | Rule meta-ontology seeding (incl. `reciprocal_nref` literal), composition/connection rule create/retrieve (incl. reciprocal param), validation catalog (incl. `{Min,Max}` multiplicity range), `effective_rules_for_class/2` taxonomy walk, `effective_connection_rules/2`, composition firing engine, propose mode, connection firing, horizontal conflict resolution | -| `graphdb_nref_SUITE` | CT | 6 | Switchable node-nref allocation facade; permanent/runtime phase flip | -| `graphdb_nrefs_SUITE` | CT | 2 | `graphdb_nrefs:verify/0` bootstrap nref-macro consistency check | -| `rel_id_server_SUITE` | CT | 7 | Relationship-row ID allocator (`get_id/0`, `get_id_pair/0`) | -| `dictionary_server_SUITE` | CT | 7 | `dictionary_server` gen_server behaviour | -| `term_server_SUITE` | CT | 7 | `term_server` gen_server behaviour | +| Suite | Type | Tests | Coverage | +| ------------------------- | ----- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphdb_bootstrap_tests` | EUnit | 61 | Term parsing, validation, record conversion, nref macro consistency | +| `graphdb_class_tests` | EUnit | 13 | `is_valid_parent_kind/1`, `collect_qc_nrefs/2` | +| `graphdb_instance_tests` | EUnit | 13 | `find_avp_value/2`, composition-firing helpers (`summarize/1` etc.) | +| `graphdb_language_tests` | EUnit | 9 | Dialect-chain building, label-resolution helpers | +| `graphdb_mgr_tests` | EUnit | 9 | Direction validation, client-side arg checks | +| `graphdb_bootstrap_SUITE` | CT | 19 | Full bootstrap load, Mnesia tables, idempotency, error handling, Language subcategory nodes | +| `graphdb_mgr_SUITE` | CT | 37 | Bootstrap init, read ops, category guard, write stubs, cache audit/repair, soft-retire (`retire_node/1`, `unretire_node/1`), `get_node` retired-filter | +| `graphdb_attr_SUITE` | CT | 38 | Attribute create/lookup, seeding, relationship types, atomic reciprocal pair, literal sub-groups, `attribute_type`/`instantiable`/`retired` markers | +| `graphdb_class_SUITE` | CT | 49 | Class create, QC (qualifying characteristics), lookups, hierarchy, multi-inheritance, inheritance, templates, abstract classes | +| `graphdb_instance_SUITE` | CT | 110 | Instance create (incl. composition rule firing, propose-mode outcomes, `{Min,Max}` multiplicity, connection firing — resolver-driven mandatory/auto/propose, target validation — and horizontal conflict precedence via `create_instance/5`; retired-node guards for target/parent/endpoint), relationships (incl. arc validation, per-arc AVPs — attribute-value pairs), lookups, hierarchy, four-level inheritance, multi-class membership | +| `graphdb_language_SUITE` | CT | 27 | Multilingual overlay: language/dialect registration, per-language overlay tables, label resolution, translation hooks | +| `graphdb_query_SUITE` | CT | 43 | Query language: parse/execute, snapshot-semantics sessions, `#cont_path{}` resume, path finding | +| `graphdb_rules_SUITE` | CT | 80 | Rule meta-ontology seeding (incl. `reciprocal_nref` literal), composition/connection rule create/retrieve (incl. reciprocal param), validation catalog (incl. `{Min,Max}` multiplicity range), `effective_rules_for_class/2` taxonomy walk, `effective_connection_rules/2`, composition firing engine, propose mode, connection firing, horizontal conflict resolution | +| `graphdb_nref_SUITE` | CT | 6 | Switchable node-nref allocation facade; permanent/runtime phase flip | +| `graphdb_nrefs_SUITE` | CT | 2 | `graphdb_nrefs:verify/0` bootstrap nref-macro consistency check | +| `rel_id_server_SUITE` | CT | 7 | Relationship-row ID allocator (`get_id/0`, `get_id_pair/0`) | +| `dictionary_server_SUITE` | CT | 7 | `dictionary_server` gen_server behaviour | +| `term_server_SUITE` | CT | 7 | `term_server` gen_server behaviour | Each CT test case runs in an isolated Mnesia database with a fresh nref allocator in a private temp directory. diff --git a/TASKS.md b/TASKS.md index b68ee0c..873e03a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -131,25 +131,87 @@ Tracked follow-ups (not in the seam spec): convention, no behaviour change. - **Batch `mutate([Mutation])`** — the tier-3 entry point. -### Node deletion (slice A, after the seam) - -`graphdb_mgr:delete_node/1` still returns `{error, not_implemented}`. -Decided policy: **refuse-if-referenced**. The node's own upward attachment -(its parent↔child arc pair, its class-membership arc pair, its own AVPs) -is removed as part of the delete; deletion is refused when other things -depend on the node: - -- it is a compositional/taxonomic parent with children; -- it is a class with live instances; -- it is targeted by an incoming connection arc from a peer. - -Only runtime nodes (`nref >= ?NREF_START`) are deletable — the entire -permanent tier (categories, bootstrap attributes, arc labels, init seeds) -is refused, extending the current category-only guard. **Known non-goal:** -detecting a node referenced as an AVP *value* on another node or arc (no -index exists; not treated as a blocker). The blocker reads must run in the -same transaction as the deletes (TOCTOU). `delete_node` is the first -tier-1 primitive + tier-2 wrapper built on the seam. +### Node deletion (slice A) — IMPLEMENTED + +Design: `docs/designs/delete-node-soft-retire-design.md`. Delivered: +`graphdb_mgr:retire_node/1` / `unretire_node/1` (idempotent, permanent-tier +guard), `graphdb_attr` seeds the `retired` boolean marker, `graphdb_instance` +refuses retired nodes as new targets/parents/endpoints. + +Decided policy: **soft-retire, applied uniformly to all runtime nodes.** Two +operations, `graphdb_mgr:retire_node/1` and its inverse +`graphdb_mgr:unretire_node/1`, mark a node retired (a boolean `retired` +lifecycle AVP on the node row); the node and its arcs stay in Mnesia, and +the public `get_node/1` returns `{error, retired}` for a retired node. +Because nothing is removed, no arc or cache is ever orphaned — so the +operation needs **no environment-vs-project discriminator**, and +refuse-if-referenced is not required for integrity. Retire additionally +blocks a retired node from taking on **new** participation (new instance +target/parent, new arc endpoint); existing structural participation is left +intact. + +`delete_node/1` is **left untouched** (still `{error, not_implemented}`) and +reserved for a future *real* (hard) delete; `retire_node`/`unretire_node` +refuse the whole permanent tier (`nref < ?NREF_START`) with a new +`permanent_node_immutable` atom. Built on the seam (`transaction/1`, merged +in PR #41). + +This is forward-compatible with the planned history / versioning / +bounded-lifetime feature: retirement is a degenerate lifetime bound, and a +later purge pass under that feature reclaims retired nodes once it defines +what is safe to forget — so mistakes are hidden now without being +destroyed. + +**Superseded:** the earlier refuse-if-referenced *hard-delete* policy. A +hard-delete fast-path for project instances — where dependencies are +local and knowable — is deferred behind the project-boundary work below +(it has no distinguishable node population until projects are physically +realized) and is where the reserved `delete_node` eventually lands. + +Follow-ups this design adds: + +- **Retired rules must not fire.** A retired `graphdb_rules` rule node is + still reached through existing structure, so retiring it does not stop it + firing. Exclude retired rule nodes at the firing read chokepoint + (`effective_rules_for_class` / `effective_connection_rules`). Deferred + from slice A to keep that slice scoped to the retire mechanism. +- **Unify permanent-tier immutability.** `delete_node`'s category-only + guard (`category_nodes_are_immutable`) is too narrow — categories are not + the only permanent nodes. When the real `delete_node` lands, its guard + (and `update_node_avps`') should refuse the whole permanent tier, + consistent with `retire_node`'s `permanent_node_immutable`. + +### Project boundary (architectural; prerequisite for the delete hard-delete fast-path) + +The environment/project split described in the knowledge model is not +physically realized. Today there is a single shared `nodes` / +`relationships` pair; instances draw nrefs from the environment runtime +allocator (`graphdb_nref`); and the Projects category (`nref` 5) is a bare +scaffold with nothing attached. Consequently a project instance is not +reliably distinguishable from an environment instance-kind node (e.g. a +rule), and there is no project-local identity space. + +Until this exists, several things stay blocked or degraded: + +- the delete hard-delete fast-path for project instances (slice A above); +- project-scoped rules (`graphdb_rules` returns + `project_rules_not_yet_supported`); +- any per-project isolation, addressing, or lifecycle. + +How projects are separated, identified, and addressed is an **open +architectural question to be brainstormed** — this entry records the need +and what it unblocks, not the solution. + +### Retired-node purge (deferred; depends on the history/versioning feature) + +Soft-retire (node deletion, slice A above) hides nodes without removing +them, so retired rows accumulate. Reclaiming them is a separate +**asynchronous background operation** — scheduled or explicitly triggered, +never part of the synchronous delete path. It can run safely only once the +planned history / versioning / bounded-lifetime feature defines what is +safe to forget (e.g. a retired node past its lifetime bound with no live +references). Scheduling, triggering, batching, and traversal are an open +design — recorded here as a need, not a solution. ### Node AVP update (slice B) diff --git a/apps/graphdb/src/graphdb_attr.erl b/apps/graphdb/src/graphdb_attr.erl index b741641..c8f0b42 100644 --- a/apps/graphdb/src/graphdb_attr.erl +++ b/apps/graphdb/src/graphdb_attr.erl @@ -106,7 +106,8 @@ target_kind_nref, %% integer() -- seeded literal attribute relationship_avp_nref, %% integer() -- seeded literal attribute attribute_type_nref, %% integer() -- seeded literal attribute - instantiable_nref %% integer() -- seeded marker literal attribute + instantiable_nref, %% integer() -- seeded marker literal attribute + retired_nref %% integer() -- seeded `retired` lifecycle marker }). @@ -346,17 +347,19 @@ init([]) -> target_kind_nref = ensure_seed("target_kind", AttrLitNref), relationship_avp_nref = ensure_seed("relationship_avp", AttrLitNref), attribute_type_nref = ensure_seed("attribute_type", AttrLitNref), - instantiable_nref = ensure_seed("instantiable", AttrLitNref) + instantiable_nref = ensure_seed("instantiable", AttrLitNref), + retired_nref = ensure_seed("retired", AttrLitNref) }, ok = ensure_template_avp_marker(State#state.relationship_avp_nref), ok = retro_stamp_bootstrap_attribute_types( State#state.attribute_type_nref), logger:info("graphdb_attr: started (attribute_literals_group=~p, " "literal_type=~p, target_kind=~p, relationship_avp=~p, " - "attribute_type=~p, instantiable=~p)", + "attribute_type=~p, instantiable=~p, retired=~p)", [AttrLitNref, State#state.literal_type_nref, State#state.target_kind_nref, State#state.relationship_avp_nref, - State#state.attribute_type_nref, State#state.instantiable_nref]), + State#state.attribute_type_nref, State#state.instantiable_nref, + State#state.retired_nref]), {ok, State} catch throw:{error, Reason} -> @@ -416,7 +419,8 @@ handle_call(seeded_nrefs, _From, State) -> target_kind => State#state.target_kind_nref, relationship_avp => State#state.relationship_avp_nref, attribute_type => State#state.attribute_type_nref, - instantiable => State#state.instantiable_nref + instantiable => State#state.instantiable_nref, + retired => State#state.retired_nref }}, {reply, Reply, State}; diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index 94231bc..d7ce795 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -105,7 +105,8 @@ %% literal-attribute, cached from graphdb_attr %% at init time and used by add_relationship %% validation. - instantiable_nref %% integer() -- seeded `instantiable` marker + instantiable_nref, %% integer() -- seeded `instantiable` marker + retired_nref %% integer() -- seeded `retired` marker }). @@ -379,10 +380,11 @@ init([]) -> %% create_instance time that the class is not marked non-instantiable. %% graphdb_attr is started before graphdb_instance by graphdb_sup, %% so this call is safe at init time. - {ok, #{target_kind := TkAttr, instantiable := InstAttr}} = - graphdb_attr:seeded_nrefs(), + {ok, #{target_kind := TkAttr, instantiable := InstAttr, + retired := RetAttr}} = graphdb_attr:seeded_nrefs(), {ok, #state{target_kind_avp_nref = TkAttr, - instantiable_nref = InstAttr}}. + instantiable_nref = InstAttr, + retired_nref = RetAttr}}. %%----------------------------------------------------------------------------- @@ -390,9 +392,9 @@ init([]) -> %%----------------------------------------------------------------------------- handle_call({create_instance, Name, ClassNref, ParentNref, Resolver, ConflictResolver}, _From, - #state{instantiable_nref = InstAttr} = State) -> - Ctx = #{inst_attr => InstAttr, on_path => [], resolver => Resolver, - conflict_resolver => ConflictResolver, + #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> + Ctx = #{inst_attr => InstAttr, ret_attr => RetAttr, on_path => [], + resolver => Resolver, conflict_resolver => ConflictResolver, root_parent => ParentNref, root_source => undefined}, {reply, do_create_instance(Name, ClassNref, ParentNref, Ctx), State}; @@ -403,8 +405,8 @@ handle_call({add_relationship, S, C, T, R, TemplateSpec, AVPSpec}, State}; handle_call({add_class_membership, InstanceNref, ClassNref}, _From, - #state{instantiable_nref = InstAttr} = State) -> - {reply, do_add_class_membership(InstanceNref, ClassNref, InstAttr), + #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> + {reply, do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr), State}; %%----------------------------------------------------------------------------- @@ -492,9 +494,10 @@ find_avp_value([_ | Rest], AttrNref) -> %%----------------------------------------------------------------------------- do_create_instance(Name, ClassNref, ParentNref, Ctx) -> InstAttr = maps:get(inst_attr, Ctx), - case do_validate_class(ClassNref, InstAttr) of + RetAttr = maps:get(ret_attr, Ctx), + case do_validate_class(ClassNref, InstAttr, RetAttr) of ok -> - case do_validate_parent(ParentNref) of + case do_validate_parent(ParentNref, RetAttr) of ok -> fire_create(Name, ClassNref, ParentNref, Ctx); {error, _} = Err -> @@ -1103,19 +1106,26 @@ propose_children(RuleNode, Deploy, ChildClass, Count, Max, I, OwnerNref, Acc) -> %%----------------------------------------------------------------------------- -%% do_validate_class(ClassNref, InstAttr) -> ok | {error, term()} +%% do_validate_class(ClassNref, InstAttr, RetAttr) -> ok | {error, term()} %% -%% Validates that ClassNref is an existing kind=class node and is not -%% marked non-instantiable (instantiable => false AVP under InstAttr). -%% Absence of the marker is permissive — only classes explicitly stamped -%% with `instantiable => false` are blocked. -%%----------------------------------------------------------------------------- -do_validate_class(ClassNref, InstAttr) -> +%% Validates that ClassNref is an existing kind=class node, is not +%% retired (retired => true AVP under RetAttr), and is not marked +%% non-instantiable (instantiable => false AVP under InstAttr). +%% Retired check runs first; absence of either marker is permissive. +%% The duplication of is_retired/2 in graphdb_mgr is intentional — the +%% workers do not share a module, and this does NOT introduce a shared util +%% module for one small predicate (YAGNI). +%%----------------------------------------------------------------------------- +do_validate_class(ClassNref, InstAttr, RetAttr) -> case mnesia:dirty_read(nodes, ClassNref) of [#node{kind = class, attribute_value_pairs = AVPs}] -> - case is_marked_non_instantiable(AVPs, InstAttr) of - true -> {error, {class_not_instantiable, ClassNref}}; - false -> ok + case is_retired(AVPs, RetAttr) of + true -> {error, {class_retired, ClassNref}}; + false -> + case is_marked_non_instantiable(AVPs, InstAttr) of + true -> {error, {class_not_instantiable, ClassNref}}; + false -> ok + end end; [#node{kind = Kind}] -> {error, {not_a_class, Kind}}; [] -> {error, class_not_found} @@ -1134,15 +1144,32 @@ is_marked_non_instantiable(AVPs, InstAttr) -> (_) -> false end, AVPs). +%% is_retired(AVPs, RetAttr) -> boolean() +%% +%% Returns true only when AVPs contains #{attribute => RetAttr, value => true}. +%% The duplication of this helper across workers is intentional — workers do +%% not share a module, and this does NOT introduce a shared util module for +%% one small predicate (YAGNI). +is_retired(AVPs, RetAttr) -> + lists:any(fun + (#{attribute := A, value := true}) when A =:= RetAttr -> true; + (_) -> false + end, AVPs). + %%----------------------------------------------------------------------------- -%% do_validate_parent(ParentNref) -> ok | {error, term()} +%% do_validate_parent(ParentNref, RetAttr) -> ok | {error, term()} %% -%% Validates that ParentNref references an existing node. +%% Validates that ParentNref references an existing node and is not +%% retired (retired => true AVP under RetAttr). %%----------------------------------------------------------------------------- -do_validate_parent(ParentNref) -> +do_validate_parent(ParentNref, RetAttr) -> case mnesia:dirty_read(nodes, ParentNref) of - [_Node] -> ok; + [#node{attribute_value_pairs = AVPs}] -> + case is_retired(AVPs, RetAttr) of + true -> {error, {parent_retired, ParentNref}}; + false -> ok + end; [] -> {error, parent_not_found} end. @@ -1162,7 +1189,7 @@ do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, State) -> TkAttr = State#state.target_kind_avp_nref, case validate_arc_endpoints(SourceNref, CharNref, TargetNref, - ReciprocalNref, TkAttr) of + ReciprocalNref, TkAttr, State#state.retired_nref) of ok -> case resolve_arc_classes(SourceNref, TargetNref) of {ok, SourceClass, TargetClass} -> @@ -1187,18 +1214,20 @@ do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, %%----------------------------------------------------------------------------- -%% validate_arc_endpoints(Source, Char, Target, Reciprocal, TkAttr) -> +%% validate_arc_endpoints(Source, Char, Target, Reciprocal, TkAttr, RetAttr) -> %% ok | {error, term()} %% %% Arc validation. Reads all four nodes inside one mnesia transaction %% and rejects: %% - missing source / target / characterization / reciprocal +%% - any endpoint that is retired (retired => true AVP under RetAttr); +%% reports the first retired nref as {endpoint_retired, Nref} %% - characterization or reciprocal that is not kind=attribute %% - target whose kind disagrees with the characterization's %% `target_kind` AVP (the value stored under attribute=TkAttr) %%----------------------------------------------------------------------------- validate_arc_endpoints(SourceNref, CharNref, TargetNref, ReciprocalNref, - TkAttr) -> + TkAttr, RetAttr) -> F = fun() -> Source = mnesia:read(nodes, SourceNref), Target = mnesia:read(nodes, TargetNref), @@ -1215,22 +1244,41 @@ validate_arc_endpoints(SourceNref, CharNref, TargetNref, ReciprocalNref, {error, {characterization_not_found, CharNref}}; {atomic, {_, _, _, []}} -> {error, {reciprocal_not_found, ReciprocalNref}}; - {atomic, {[_], [#node{kind = TKind}], [#node{kind = CKind} = CharNode], - [#node{kind = RKind}]}} -> - case {CKind, RKind} of - {attribute, attribute} -> - check_target_kind(CharNode, TKind, TkAttr); - {attribute, _} -> - {error, {reciprocal_not_an_attribute, ReciprocalNref, - RKind}}; - {_, _} -> - {error, {characterization_not_an_attribute, CharNref, - CKind}} + {atomic, {[#node{attribute_value_pairs = SAVPs}], + [#node{kind = TKind, attribute_value_pairs = TAVPs}], + [#node{kind = CKind, attribute_value_pairs = CAVPs} = CharNode], + [#node{kind = RKind, attribute_value_pairs = RAVPs}]}} -> + case first_retired([{SourceNref, SAVPs}, {TargetNref, TAVPs}, + {CharNref, CAVPs}, {ReciprocalNref, RAVPs}], + RetAttr) of + {retired, RNref} -> + {error, {endpoint_retired, RNref}}; + none -> + case {CKind, RKind} of + {attribute, attribute} -> + check_target_kind(CharNode, TKind, TkAttr); + {attribute, _} -> + {error, {reciprocal_not_an_attribute, ReciprocalNref, + RKind}}; + {_, _} -> + {error, {characterization_not_an_attribute, CharNref, + CKind}} + end end; {aborted, Reason} -> {error, Reason} end. +%% first_retired([{Nref, AVPs}], RetAttr) -> {retired, Nref} | none +%% +%% Returns the first entry whose AVP list carries the retired marker, or none. +first_retired([], _RetAttr) -> none; +first_retired([{Nref, AVPs} | Rest], RetAttr) -> + case is_retired(AVPs, RetAttr) of + true -> {retired, Nref}; + false -> first_retired(Rest, RetAttr) + end. + check_target_kind(#node{attribute_value_pairs = AVPs}, ActualKind, TkAttr) -> case find_avp_value(AVPs, TkAttr) of not_found -> @@ -1350,17 +1398,18 @@ write_connection_arcs(SourceNref, CharNref, TargetNref, ReciprocalNref, %%----------------------------------------------------------------------------- -%% do_add_class_membership(InstanceNref, ClassNref, InstAttr) -> +%% do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr) -> %% ok | {error, term()} %% %% Validates the subject (must be an instance) and the target (must be -%% a class and instantiable), then atomically writes the 29/30 arc pair -%% and appends ClassNref to the instance's classes cache. Idempotent. +%% a class, not retired, and instantiable), then atomically writes the +%% 29/30 arc pair and appends ClassNref to the instance's classes cache. +%% Idempotent. %%----------------------------------------------------------------------------- -do_add_class_membership(InstanceNref, ClassNref, InstAttr) -> +do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr) -> case do_get_instance(InstanceNref) of {ok, _} -> - case do_validate_class(ClassNref, InstAttr) of + case do_validate_class(ClassNref, InstAttr, RetAttr) of ok -> do_write_class_membership(InstanceNref, ClassNref); {error, _} = Err -> Err diff --git a/apps/graphdb/src/graphdb_mgr.erl b/apps/graphdb/src/graphdb_mgr.erl index 60be8ee..8d12ff2 100644 --- a/apps/graphdb/src/graphdb_mgr.erl +++ b/apps/graphdb/src/graphdb_mgr.erl @@ -91,7 +91,11 @@ avps %% [#{attribute => Nref, value => term()}] }). --record(state, {}). +-record(state, { + retired_nref %% integer() | undefined -- seeded `retired` + %% marker nref; lazily fetched from graphdb_attr + %% on first use (graphdb_attr starts after mgr) +}). %%--------------------------------------------------------------------- @@ -112,6 +116,8 @@ create_instance/3, add_relationship/4, delete_node/1, + retire_node/1, + unretire_node/1, update_node_avps/2, %% Transaction helper (write-path seam) transaction/1, @@ -241,6 +247,20 @@ delete_node(Nref) -> gen_server:call(?MODULE, {delete_node, Nref}). +%%----------------------------------------------------------------------------- +%% retire_node(Nref) -> ok | {error, Reason} +%% Soft-retires a runtime node (sets the boolean `retired` marker AVP). +%% Idempotent. Refuses the permanent tier (Nref < ?NREF_START). +%%----------------------------------------------------------------------------- +retire_node(Nref) -> + gen_server:call(?MODULE, {retire_node, Nref}). + +%% unretire_node(Nref) -> ok | {error, Reason} +%% Clears the `retired` marker. Idempotent. +unretire_node(Nref) -> + gen_server:call(?MODULE, {unretire_node, Nref}). + + %%----------------------------------------------------------------------------- %% update_node_avps(Nref, AVPs) -> ok | {error, term()} %% @@ -349,8 +369,24 @@ init([]) -> %%----------------------------------------------------------------------------- %% handle_call/3 -- Read operations %%----------------------------------------------------------------------------- -handle_call({get_node, Nref}, _From, State) -> - {reply, do_get_node(Nref), State}; +handle_call({get_node, Nref}, _From, State0) -> + case do_get_node(Nref) of + {ok, Node} -> + {Reply, State} = case has_true_avp(Node) of + false -> + {{ok, Node}, State0}; + true -> + {RetAttr, State1} = ensure_retired_nref(State0), + R = case is_retired_avp_present(Node, RetAttr) of + true -> {error, retired}; + false -> {ok, Node} + end, + {R, State1} + end, + {reply, Reply, State}; + {error, _} = Err -> + {reply, Err, State0} + end; handle_call({get_relationships, Nref, Direction}, _From, State) -> {reply, do_get_relationships(Nref, Direction), State}; @@ -377,6 +413,13 @@ handle_call({add_relationship, SourceNref, CharNref, TargetNref, ReciprocalNref} graphdb_instance:add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref), State}; +handle_call({retire_node, Nref}, _From, State0) -> + {Reply, State} = set_retired(Nref, true, State0), + {reply, Reply, State}; +handle_call({unretire_node, Nref}, _From, State0) -> + {Reply, State} = set_retired(Nref, false, State0), + {reply, Reply, State}; + handle_call({delete_node, Nref}, _From, State) -> case check_category_guard(Nref) of {error, _} = Err -> @@ -497,6 +540,93 @@ check_category_guard(Nref) -> end. +%%----------------------------------------------------------------------------- +%% set_retired(Nref, Bool, State) -> {ok | {error, Reason}, State'} +%% +%% Tier-2 wrapper. Static arithmetic guard refuses the whole permanent tier +%% (Nref < ?NREF_START); otherwise lazily resolves the seeded `retired` +%% nref (caching it in State) and runs the tier-1 primitive through the +%% transaction seam. Returns the possibly-updated State so the cache sticks. +%%----------------------------------------------------------------------------- +set_retired(Nref, _Bool, State) when Nref < ?NREF_START -> + {{error, permanent_node_immutable}, State}; +set_retired(Nref, Bool, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> set_retired_(Nref, Bool, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. + +%%----------------------------------------------------------------------------- +%% ensure_retired_nref(State) -> {RetAttr, State'} +%% +%% Lazily fetches the seeded `retired` nref from graphdb_attr the first +%% time it is needed and caches it in State. graphdb_attr is started after +%% graphdb_mgr, so this cannot be done at init/1. +%%----------------------------------------------------------------------------- +ensure_retired_nref(#state{retired_nref = undefined} = State) -> + {ok, #{retired := RetAttr}} = graphdb_attr:seeded_nrefs(), + {RetAttr, State#state{retired_nref = RetAttr}}; +ensure_retired_nref(#state{retired_nref = RetAttr} = State) -> + {RetAttr, State}. + +%%----------------------------------------------------------------------------- +%% set_retired_(Nref, Bool, RetAttr) -> ok +%% Tier-1 primitive. Must run inside an active mnesia transaction. Reads the +%% node under a write lock, rewrites its AVP list so the `retired` marker +%% reflects Bool, writes it back. Aborts with not_found if absent. +%%----------------------------------------------------------------------------- +set_retired_(Nref, Bool, RetAttr) -> + case mnesia:read(nodes, Nref, write) of + [] -> mnesia:abort(not_found); + [Node] -> + AVPs0 = Node#node.attribute_value_pairs, + AVPs1 = set_marker(AVPs0, RetAttr, Bool), + mnesia:write(nodes, + Node#node{attribute_value_pairs = AVPs1}, write) + end. + +%%----------------------------------------------------------------------------- +%% set_marker(AVPs, RetAttr, Bool) -> AVPs' +%% Removes any existing `retired` AVP; if Bool is true, appends a fresh +%% #{attribute => RetAttr, value => true}. Setting false leaves it removed. +%%----------------------------------------------------------------------------- +set_marker(AVPs, RetAttr, Bool) -> + Stripped = [P || P <- AVPs, not is_retired_avp(P, RetAttr)], + case Bool of + true -> Stripped ++ [#{attribute => RetAttr, value => true}]; + false -> Stripped + end. + +is_retired_avp(#{attribute := A}, RetAttr) -> A =:= RetAttr; +is_retired_avp(_, _) -> false. + +%%----------------------------------------------------------------------------- +%% has_true_avp(Node) -> boolean() +%% Quick pre-filter: true iff the node has any AVP with value => true. +%% Used by the get_node handle_call to short-circuit the ensure_retired_nref +%% lookup on ordinary (non-retired) reads, keeping get_node callable without +%% graphdb_attr running (e.g. read_ops tests that start only graphdb_mgr). +%%----------------------------------------------------------------------------- +has_true_avp(#node{attribute_value_pairs = AVPs}) -> + lists:any(fun + (#{value := true}) -> true; + (_) -> false + end, AVPs). + +%%----------------------------------------------------------------------------- +%% is_retired_avp_present(Node, RetAttr) -> boolean() +%% True iff Node carries the `retired` boolean marker AVP +%% (attribute=RetAttr, value=true). +%%----------------------------------------------------------------------------- +is_retired_avp_present(#node{attribute_value_pairs = AVPs}, RetAttr) -> + lists:any(fun(#{attribute := A, value := true}) when A =:= RetAttr -> true; + (_) -> false + end, AVPs). + + %%----------------------------------------------------------------------------- %% Cache invariant audit / repair helpers %% diff --git a/apps/graphdb/test/graphdb_attr_SUITE.erl b/apps/graphdb/test/graphdb_attr_SUITE.erl index 39eb01a..3cbf1c7 100644 --- a/apps/graphdb/test/graphdb_attr_SUITE.erl +++ b/apps/graphdb/test/graphdb_attr_SUITE.erl @@ -66,6 +66,7 @@ seeds_attribute_literals_subgroup/1, attr_literal_seeds_parented_under_subgroup/1, seeds_instantiable_marker/1, + seeds_retired_marker/1, %% Creators create_name_attribute_basic/1, create_literal_attribute_stores_type/1, @@ -122,7 +123,8 @@ groups() -> template_avp_marker_idempotent, seeds_attribute_literals_subgroup, attr_literal_seeds_parented_under_subgroup, - seeds_instantiable_marker + seeds_instantiable_marker, + seeds_retired_marker ]}, {creators, [], [ create_name_attribute_basic, @@ -397,6 +399,22 @@ seeds_instantiable_marker(_Config) -> ?assert(lists:member(#{attribute => AtNref, value => literal}, Node#node.attribute_value_pairs)). +seeds_retired_marker(_Config) -> + {ok, _} = graphdb_attr:start_link(), + {ok, #{retired := RetNref, + attribute_literals_group := AttrLitNref, + attribute_type := AtNref}} = + graphdb_attr:seeded_nrefs(), + ?assert(is_integer(RetNref)), + ?assert(RetNref > ?NREF_ENGLISH andalso RetNref < ?NREF_START), + {ok, Node} = graphdb_attr:get_attribute(RetNref), + ?assertEqual(attribute, Node#node.kind), + ?assertEqual([AttrLitNref], Node#node.parents), + ?assert(lists:member(#{attribute => ?NAME_ATTR_ATTRIBUTE, + value => "retired"}, Node#node.attribute_value_pairs)), + ?assert(lists:member(#{attribute => AtNref, value => literal}, + Node#node.attribute_value_pairs)). + %%============================================================================= %% Creator Tests @@ -699,8 +717,8 @@ list_attributes_includes_bootstrap_and_runtime(_Config) -> {ok, _} = graphdb_attr:start_link(), {ok, Before} = graphdb_attr:list_attributes(), %% Bootstrap has 27 attribute nodes (nrefs 6-31 = 26, plus lang_code); - %% seeding adds the Attribute Literals sub-group + 5 literal attrs = 6 - ?assertEqual(27 + 6, length(Before)), + %% seeding adds the Attribute Literals sub-group + 6 literal attrs = 7 + ?assertEqual(27 + 7, length(Before)), {ok, _} = graphdb_attr:create_name_attribute("One"), {ok, _} = graphdb_attr:create_name_attribute("Two"), diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index d1ccc5a..cceb868 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -68,6 +68,8 @@ create_instance_writes_compositional_arcs/1, create_instance_refused_for_abstract_class/1, create_instance_allowed_for_unmarked_class/1, + create_instance_refuses_retired_class/1, + create_instance_refuses_retired_parent/1, %% Relationships add_relationship_basic/1, add_relationship_both_directions/1, @@ -84,6 +86,7 @@ add_relationship_stamps_user_avps/1, add_relationship_avps_are_per_direction/1, add_relationship_default_avps_empty/1, + add_relationship_refuses_retired_endpoint/1, class_of_returns_class/1, %% Lookups get_instance_returns_node/1, @@ -119,6 +122,7 @@ add_class_membership_rejects_missing_class/1, add_class_membership_rejects_non_class_target/1, add_class_membership_refuses_abstract_class/1, + add_class_membership_refuses_retired_class/1, class_memberships_initial/1, %% Multi-membership resolver resolve_value_unique_across_two_classes/1, @@ -205,7 +209,9 @@ groups() -> create_instance_writes_membership_arcs, create_instance_writes_compositional_arcs, create_instance_refused_for_abstract_class, - create_instance_allowed_for_unmarked_class + create_instance_allowed_for_unmarked_class, + create_instance_refuses_retired_class, + create_instance_refuses_retired_parent ]}, {relationships, [], [ add_relationship_basic, @@ -223,6 +229,7 @@ groups() -> add_relationship_stamps_user_avps, add_relationship_avps_are_per_direction, add_relationship_default_avps_empty, + add_relationship_refuses_retired_endpoint, class_of_returns_class ]}, {lookups, [], [ @@ -262,6 +269,7 @@ groups() -> add_class_membership_rejects_missing_class, add_class_membership_rejects_non_class_target, add_class_membership_refuses_abstract_class, + add_class_membership_refuses_retired_class, class_memberships_initial, resolve_value_unique_across_two_classes, resolve_value_same_value_two_classes, @@ -365,8 +373,26 @@ init_per_testcase(TC, Config) -> {ok, _} = graphdb_class:start_link(), {ok, _} = graphdb_instance:start_link(), {ok, _} = graphdb_rules:start_link(), + %% Retire-guard tests need runtime nrefs so retire_node/1 accepts them. + %% Mirror production graphdb:start/2: flip to runtime tier after all workers + %% have seeded so that user-level create_* calls allocate runtime nrefs. + maybe_set_runtime_phase(TC), setup_firing_fixtures(TC, Config1). +%% Test cases that call graphdb_mgr:retire_node/1 require runtime nrefs. +%% Flip to the runtime tier (nref >= ?NREF_START) after seeding is complete. +%% NOTE: add any future retire-guard test case to this guard list — without +%% it the test runs in permanent phase and retire_node/1 rejects its +%% runtime-tier target with {error, permanent_node_immutable}. +maybe_set_runtime_phase(TC) when + TC =:= create_instance_refuses_retired_class; + TC =:= create_instance_refuses_retired_parent; + TC =:= add_class_membership_refuses_retired_class; + TC =:= add_relationship_refuses_retired_endpoint -> + ok = graphdb_nref:set_runtime_phase(); +maybe_set_runtime_phase(_TC) -> + ok. + %% For firing-group test cases, create the shared class fixtures and add %% them to Config. Other test cases pass through unchanged. setup_firing_fixtures(TC, Config) -> @@ -586,6 +612,25 @@ create_instance_allowed_for_unmarked_class(_Config) -> ?assertMatch({ok, _, _}, graphdb_instance:create_instance("Inst1", ClassNref, 5)). +%%----------------------------------------------------------------------------- +%% create_instance rejects a retired class node. +%%----------------------------------------------------------------------------- +create_instance_refuses_retired_class(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("RetClass", 3), + ok = graphdb_mgr:retire_node(ClassNref), + ?assertEqual({error, {class_retired, ClassNref}}, + graphdb_instance:create_instance("i", ClassNref, 3)). + +%%----------------------------------------------------------------------------- +%% create_instance rejects a retired compositional parent. +%%----------------------------------------------------------------------------- +create_instance_refuses_retired_parent(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("PClass", 3), + {ok, Parent, _} = graphdb_instance:create_instance("p", ClassNref, 3), + ok = graphdb_mgr:retire_node(Parent), + ?assertEqual({error, {parent_retired, Parent}}, + graphdb_instance:create_instance("child", ClassNref, Parent)). + %%============================================================================= %% Relationship Tests @@ -875,6 +920,22 @@ add_relationship_default_avps_empty(_Config) -> ?assertEqual([#{attribute => ?ARC_TEMPLATE, value => DefaultTmpl}], Fwd#relationship.avps). +%%----------------------------------------------------------------------------- +%% add_relationship rejects a retired endpoint. +%%----------------------------------------------------------------------------- +add_relationship_refuses_retired_endpoint(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("ArcClass", 3), + {ok, Src, _} = graphdb_instance:create_instance("s", ClassNref, 3), + {ok, Tgt, _} = graphdb_instance:create_instance("t", ClassNref, 3), + {ok, {Fwd, Rec}} = + graphdb_attr:create_relationship_attribute_pair("Likes", "LikedBy", instance), + ok = graphdb_instance:add_relationship(Src, Fwd, Tgt, Rec), + ok = graphdb_mgr:retire_node(Tgt), + {ok, Tgt2, _} = graphdb_instance:create_instance("t2", ClassNref, 3), + ok = graphdb_mgr:retire_node(Tgt2), + ?assertEqual({error, {endpoint_retired, Tgt2}}, + graphdb_instance:add_relationship(Src, Fwd, Tgt2, Rec)). + %%----------------------------------------------------------------------------- %% class_of returns the membership class via the instance->class arc. @@ -1297,6 +1358,17 @@ add_class_membership_refuses_abstract_class(_Config) -> graphdb_instance:add_class_membership(Instance, Abstract)), ?assertEqual(RelsBefore, mnesia:table_info(relationships, size)). +%%----------------------------------------------------------------------------- +%% add_class_membership rejects a retired class node. +%%----------------------------------------------------------------------------- +add_class_membership_refuses_retired_class(_Config) -> + {ok, ClassA} = graphdb_class:create_class("MemA", 3), + {ok, ClassB} = graphdb_class:create_class("MemB", 3), + {ok, Inst, _} = graphdb_instance:create_instance("m", ClassA, 3), + ok = graphdb_mgr:retire_node(ClassB), + ?assertEqual({error, {class_retired, ClassB}}, + graphdb_instance:add_class_membership(Inst, ClassB)). + %%----------------------------------------------------------------------------- %% After create_instance/3, class_memberships/1 returns the single class. %%----------------------------------------------------------------------------- diff --git a/apps/graphdb/test/graphdb_mgr_SUITE.erl b/apps/graphdb/test/graphdb_mgr_SUITE.erl index 11a9ede..c397223 100644 --- a/apps/graphdb/test/graphdb_mgr_SUITE.erl +++ b/apps/graphdb/test/graphdb_mgr_SUITE.erl @@ -95,7 +95,13 @@ transaction_commit_returns_ok/1, transaction_abort_rolls_back/1, transaction_composition_rolls_back/1, - transaction_crash_passes_through/1 + transaction_crash_passes_through/1, + %% Soft-retire + retire_node_sets_and_clears_marker/1, + retire_node_is_idempotent/1, + retire_node_refuses_permanent_tier/1, + retire_node_not_found/1, + get_node_hides_retired/1 ]). @@ -109,7 +115,8 @@ suite() -> all() -> [{group, init_tests}, {group, read_ops}, {group, category_guard}, {group, write_delegation}, - {group, cache_audit}, {group, transaction_seam}]. + {group, cache_audit}, {group, transaction_seam}, + {group, soft_retire}]. groups() -> [ @@ -156,6 +163,13 @@ groups() -> transaction_abort_rolls_back, transaction_composition_rolls_back, transaction_crash_passes_through + ]}, + {soft_retire, [], [ + retire_node_sets_and_clears_marker, + retire_node_is_idempotent, + retire_node_refuses_permanent_tier, + retire_node_not_found, + get_node_hides_retired ]} ]. @@ -209,7 +223,12 @@ init_per_testcase(TC, Config) when TC =:= create_attribute_unknown_parent; TC =:= create_class_delegates; TC =:= create_instance_delegates; - TC =:= add_relationship_delegates -> + TC =:= add_relationship_delegates; + TC =:= retire_node_sets_and_clears_marker; + TC =:= retire_node_is_idempotent; + TC =:= retire_node_refuses_permanent_tier; + TC =:= retire_node_not_found; + TC =:= get_node_hides_retired -> Config1 = setup_isolated_env(Config), BootstrapFile = proplists:get_value(bootstrap_file, Config), application:set_env(seerstone_graph_db, bootstrap_file, BootstrapFile), @@ -223,6 +242,9 @@ init_per_testcase(TC, Config) when {ok, _} = graphdb_class:start_link(), {ok, _} = graphdb_instance:start_link(), {ok, _} = graphdb_rules:start_link(), + %% Mirror production graphdb:start/2: flip to runtime tier after all + %% workers have seeded so that user-level create_* calls allocate runtime nrefs. + ok = graphdb_nref:set_runtime_phase(), Config1; init_per_testcase(_TC, Config) -> Config1 = setup_isolated_env(Config), @@ -811,3 +833,77 @@ transaction_crash_passes_through(_Config) -> ?assertMatch({error, {crash_in_txn, _Stack}}, graphdb_mgr:transaction(Fun)), ?assertEqual([], mnesia:dirty_read(nodes, NrefC)). + + +%%============================================================================= +%% Soft-Retire Tests +%% +%% Workers (graphdb_attr, graphdb_class, graphdb_instance, graphdb_rules) are +%% pre-started by init_per_testcase (full-stack clause). The `retired` nref +%% is resolved lazily on first use from graphdb_attr:seeded_nrefs/0. +%%============================================================================= + +%%----------------------------------------------------------------------------- +%% retire_node stamps the `retired` boolean AVP; unretire_node removes it. +%%----------------------------------------------------------------------------- +retire_node_sets_and_clears_marker(_Config) -> + {ok, ClassNref} = graphdb_mgr:create_class("RetireMe", 3), + ?assert(ClassNref >= ?NREF_START), + ok = graphdb_mgr:retire_node(ClassNref), + [#node{attribute_value_pairs = AVPs1}] = + mnesia:dirty_read(nodes, ClassNref), + %% Consideration (future hardening): this predicate matches ANY + %% value=>true AVP, not the `retired` attribute specifically. A fresh + %% class carries no other boolean-true AVP today, so it is not a false + %% positive — but a stricter, attribute-specific check would be: + %% {ok, #{retired := RetiredNref}} = graphdb_attr:seeded_nrefs(), + %% ?assert(lists:any( + %% fun(#{attribute := A, value := true}) when A =:= RetiredNref -> true; + %% (_) -> false end, AVPs1)), + ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs1)), + ok = graphdb_mgr:unretire_node(ClassNref), + [#node{attribute_value_pairs = AVPs2}] = + mnesia:dirty_read(nodes, ClassNref), + ?assertEqual(false, + lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs2)). + +%%----------------------------------------------------------------------------- +%% retire_node and unretire_node are both idempotent. +%%----------------------------------------------------------------------------- +retire_node_is_idempotent(_Config) -> + {ok, ClassNref} = graphdb_mgr:create_class("RetireIdem", 3), + ok = graphdb_mgr:retire_node(ClassNref), + ok = graphdb_mgr:retire_node(ClassNref), + ok = graphdb_mgr:unretire_node(ClassNref), + ok = graphdb_mgr:unretire_node(ClassNref). + +%%----------------------------------------------------------------------------- +%% Both operations refuse permanent-tier nrefs (Nref < ?NREF_START). +%%----------------------------------------------------------------------------- +retire_node_refuses_permanent_tier(_Config) -> + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:retire_node(1)), + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:retire_node(27)), + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:unretire_node(27)). + +%%----------------------------------------------------------------------------- +%% Both operations return {error, not_found} for a nonexistent runtime nref. +%%----------------------------------------------------------------------------- +retire_node_not_found(_Config) -> + BadNref = ?NREF_START + 999999, + ?assertEqual({error, not_found}, graphdb_mgr:retire_node(BadNref)), + ?assertEqual({error, not_found}, graphdb_mgr:unretire_node(BadNref)). + +%%----------------------------------------------------------------------------- +%% get_node/1 returns {error, retired} for a retired node; unretiring +%% restores the {ok, #node{}} response. +%%----------------------------------------------------------------------------- +get_node_hides_retired(_Config) -> + {ok, ClassNref} = graphdb_mgr:create_class("HideMe", 3), + {ok, _} = graphdb_mgr:get_node(ClassNref), + ok = graphdb_mgr:retire_node(ClassNref), + ?assertEqual({error, retired}, graphdb_mgr:get_node(ClassNref)), + ok = graphdb_mgr:unretire_node(ClassNref), + {ok, #node{nref = ClassNref}} = graphdb_mgr:get_node(ClassNref). diff --git a/docs/Architecture.md b/docs/Architecture.md index cb8b3de..b1cf0e5 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -15,20 +15,20 @@ SPDX-License-Identifier: GPL-2.0-or-later ## 1. Status -| Component | State | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Build | Compiles clean — zero warnings (Erlang/OTP, the Open Telecom Platform, version 28 / rebar3 3.27) | -| `nref` subsystem | Fully implemented; backed by DETS (Disk-based Erlang Term Storage); `set_floor/1` API | -| `dictionary_imp` | Implemented; not yet wired to `dictionary_server` / `term_server` | -| `graphdb_bootstrap` | Implemented — Mnesia schema, table creation, scaffold loader | -| `graphdb_mgr` | Implemented — bootstrap startup, read API, category guard, cache audit/repair. Write-side delegation pending. | -| `graphdb_attr` | Implemented — attribute library (name, literal, relationship attributes) | -| `graphdb_class` | Implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph); abstract (non-instantiable) classes via the `instantiable` marker | -| `graphdb_instance` | Implemented — compositional hierarchy + four-level inheritance with multi-class membership and ambiguity-detecting class resolver; refuses instantiation/membership of abstract classes; fires composition rules on `create_instance/3` and surfaces `proposed` outcomes for propose-mode rules; fires connection rules via a caller-supplied resolver on `create_instance/4`; applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/5` | -| `graphdb_rules` | Implemented — rule meta-ontology, applies_to attachment, scope-aware create/retrieve, taxonomy-walking effective-rules read, composition firing engine, propose mode, connection firing, horizontal conflict precedence | -| `graphdb_language` | Implemented — multilingual overlay layer (label resolution, dialect chains, per-language Mnesia overlay tables) | -| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS | -| Tests | 523 passing (418 Common Test + 105 EUnit) | +| Component | State | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Build | Compiles clean — zero warnings (Erlang/OTP, the Open Telecom Platform, version 28 / rebar3 3.27) | +| `nref` subsystem | Fully implemented; backed by DETS (Disk-based Erlang Term Storage); `set_floor/1` API | +| `dictionary_imp` | Implemented; not yet wired to `dictionary_server` / `term_server` | +| `graphdb_bootstrap` | Implemented — Mnesia schema, table creation, scaffold loader | +| `graphdb_mgr` | Implemented — bootstrap startup, read API, category guard, cache audit/repair; `retire_node/1` / `unretire_node/1` soft-retire runtime nodes via a boolean `retired` marker AVP; public `get_node/1` returns `{error, retired}` for retired nodes; `delete_node/1` remains unimplemented, reserved for a future hard delete. | +| `graphdb_attr` | Implemented — attribute library (name, literal, relationship attributes); seeds the `retired` lifecycle marker literal-attribute | +| `graphdb_class` | Implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph); abstract (non-instantiable) classes via the `instantiable` marker | +| `graphdb_instance` | Implemented — compositional hierarchy + four-level inheritance with multi-class membership and ambiguity-detecting class resolver; refuses instantiation/membership of abstract classes; refuses retired nodes as new instance targets, compositional parents, and arc endpoints; fires composition rules on `create_instance/3` and surfaces `proposed` outcomes for propose-mode rules; fires connection rules via a caller-supplied resolver on `create_instance/4`; applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/5` | +| `graphdb_rules` | Implemented — rule meta-ontology, applies_to attachment, scope-aware create/retrieve, taxonomy-walking effective-rules read, composition firing engine, propose mode, connection firing, horizontal conflict precedence | +| `graphdb_language` | Implemented — multilingual overlay layer (label resolution, dialect chains, per-language Mnesia overlay tables) | +| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS | +| Tests | 537 passing (432 Common Test + 105 EUnit) | The kernel is functional under multi-inheritance, multi-class- membership, and per-class template semantics. Multilingual label @@ -38,7 +38,8 @@ taxonomy-walk effective-rules read, the composition firing engine, propose mode (`create_instance/3` surfaces `proposed` outcomes), connection firing, and horizontal conflict precedence. The later firing-engine work — the instantiation engine and reactive learning — -remains. +remains. Node soft-retire (`retire_node/1` / `unretire_node/1`) is +implemented; hard delete (`delete_node/1`) remains reserved. --- @@ -259,8 +260,8 @@ any future database-level coordination services without reintroducing the Worker boundaries: each `graphdb_*` worker owns the schema/contract it maintains. `graphdb_mgr` is the public entry point and routes to the -workers — read path implemented; write-side routing is pending -(see [`../TASKS.md`](../TASKS.md)). +workers — read path and soft-retire implemented; remaining write-side +routing is pending (see [`../TASKS.md`](../TASKS.md)). --- diff --git a/docs/designs/delete-node-soft-retire-design.md b/docs/designs/delete-node-soft-retire-design.md new file mode 100644 index 0000000..64befc3 --- /dev/null +++ b/docs/designs/delete-node-soft-retire-design.md @@ -0,0 +1,376 @@ + + +# Node Deletion — Soft-Retire — Design + +**Status:** Designed; not yet planned or implemented. First real consumer +of the write-path transaction-layering seam. + +**Context:** Slice A of the **write-path completion** track in `TASKS.md`. +The transaction-layering seam (`graphdb_mgr:transaction/1`) it builds on is +**already merged** (PR #41); this design assumes it exists. This document +specifies two new operations — `graphdb_mgr:retire_node/1` and its inverse +`graphdb_mgr:unretire_node/1`. The existing `delete_node/1` stub is left +**untouched** (it still returns `{error, not_implemented}`) and is reserved +for a future *real* (hard) delete. + +**Spec citation:** none. `docs/TheKnowledgeNetwork.md` is a data model and +is silent on deletion mechanics. This is an infrastructural design that +records *what "delete" means* for a runtime node, chosen to stay compatible +with the planned history / versioning / bounded-lifetime feature. + +--- + +## 1. Decision summary + +"Deleting" a node is a **soft-retire**: a boolean `retired => true` marker +AVP is stamped on the node row. The node and all its arcs stay in Mnesia. +The operation is named `retire_node/1` (reversible via `unretire_node/1`); +the name `delete_node` is deliberately **not** reused, so it remains +available for a future real delete. + +Soft-retire was chosen over hard delete after grounding the design in the +code: + +- The environment/project split that a "refuse-if-referenced hard delete" + policy depends on is **not physically realized** — there is one shared + `nodes` / `relationships` table pair, instances draw nrefs from the + environment runtime allocator (`graphdb_nref`), and the Projects + category (`nref` 5) is a bare scaffold. So a project instance is not + reliably distinguishable from an environment instance-kind node (e.g. a + rule), and any env-vs-project discriminator would be a fragile heuristic + that could mis-classify catastrophically. +- Soft-retire **never orphans an arc or a cache regardless of node role**, + so it needs no discriminator at all. +- It is forward-compatible with the future history/versioning feature: + retirement is a degenerate bounded lifetime, and a later background + purge (tracked separately in `TASKS.md`) reclaims retired rows once that + feature defines what is safe to forget. Mistakes are hidden now without + being destroyed. + +The hard-delete fast-path for project instances is deferred behind the +project-boundary work (`TASKS.md`). + +### 1.1 Settled decisions + +| Question | Decision | +| --------------------- | -------------------------------------------------------------------------------------------------------------- | +| Marker representation | **Boolean** `retired => true` AVP; absence = active (mirrors L9 `instantiable`) | +| Read-visibility scope | **Pragmatic middle** — hide from direct lookup *and* block a retired node from taking on **new** participation | +| Reversible? | **Yes** — ship `unretire_node/1` alongside | +| Public name | **`retire_node/1`** — `delete_node/1` is left untouched, reserved for a future real delete | +| `get_node` on retired | Returns **`{error, retired}`** (distinct from `not_found`) | +| Permanent-tier guard | `retire_node`/`unretire_node` refuse **all** `Nref < ?NREF_START` with a new atom `permanent_node_immutable` | + +--- + +## 2. The retired marker + +A new seeded boolean literal-attribute, `retired`, created by +`graphdb_attr` exactly as `instantiable` is today: +`ensure_seed("retired", AttrLitNref)` in the **Attribute Literals** +sub-group under Literals (`?NREF_LITERALS`, nref 7), cached in +`graphdb_attr`'s state and exposed through `graphdb_attr:seeded_nrefs/0` +(the returned map gains a `retired => integer()` key). + +Unlike `instantiable` (a class-only marker), `retired` is a **general +node-lifecycle marker** and may appear on a node of any kind. It is seeded +in the Attribute Literals sub-group for consistency with `instantiable`; +no new sub-group is introduced. + +The marker is stored as an ordinary AVP on the `#node.attribute_value_pairs` +list: `#{attribute => RetiredNref, value => true}`. A node is retired iff +that AVP is present with `value => true`; absence (or `value => false`) +means active. The check mirrors `graphdb_class`'s +`is_marked_non_instantiable/2`: + +```erlang +%% is_retired(AVPs, RetiredAttr) -> boolean() +is_retired(AVPs, RetiredAttr) -> + lists:any( + fun(#{attribute := A, value := true}) when A =:= RetiredAttr -> true; + (_) -> false + end, AVPs). +``` + +Workers that need the marker cache its nref at `init/1` from +`graphdb_attr:seeded_nrefs/0`, as `graphdb_class` / `graphdb_instance` +already do for `instantiable`: `graphdb_mgr` (for the retire/unretire +primitives and the lookup filter) and `graphdb_instance` (for the +block-new-participation guards). + +--- + +## 3. The public API + +Two new operations on `graphdb_mgr`, kept as `gen_server:call`s (they need +the seeded `retired` nref cached in state and are low-frequency admin +operations; see §6 for why this deviates from the seam's plain-function +guidance): + +```erlang +%% retire_node(Nref) -> ok | {error, Reason} +%% Soft-retires a runtime node (sets retired => true). Idempotent: +%% re-retiring an already-retired node returns ok. +retire_node(Nref) -> ... + +%% unretire_node(Nref) -> ok | {error, Reason} +%% Clears the retired marker. Idempotent: unretiring a node that is not +%% retired returns ok. +unretire_node(Nref) -> ... +``` + +Both are new exports. `delete_node/1` is **not** modified — it keeps +returning `{error, not_implemented}` and stays reserved for a future real +(hard) delete; `check_category_guard/1` and the +`category_nodes_are_immutable` atom remain in place for that path and for +the still-unimplemented `update_node_avps`. + +### 3.1 Error contract (retire / unretire) + +| Reason | When | +| -------------------------- | ------------------------------------------------------------ | +| `permanent_node_immutable` | `Nref < ?NREF_START` (categories, scaffold, permanent seeds) | +| `not_found` | `Nref >= ?NREF_START` but no such node row | + +The `Nref < ?NREF_START` guard is a pure arithmetic static guard: it +refuses the **whole** permanent tier, not just categories. This is +deliberate — a permanent arc-label attribute (e.g. nref 27, "Parent", +`kind=attribute`) is not a category, so a category-only guard would let it +be retired, and the block-new-participation rule (§4.1) would then break +`create_instance`. The new atom `permanent_node_immutable` is introduced +for the new function only; it renames nothing and changes no existing test. +(Unifying the permanent-tier immutability concept with `delete_node`'s +narrower category guard is a tracked follow-up — see `TASKS.md`.) + +`not_found` is detected in-transaction (the marker write reads the row +under a write lock). + +--- + +## 4. Read-visibility — the pragmatic middle + +"Retire" means **hidden from address, and blocked from new participation**, +but *not* removed from existing graph structure. Concretely: + +### 4.1 What retire DOES change + +**(a) Direct lookup is hidden.** The public `graphdb_mgr:get_node/1` +returns `{error, retired}` for a retired node (distinct from `not_found`, +so an admin/unretire flow can tell a retired node from an absent one). The +internal `do_get_node/1` stays **raw** — it still returns the row — because +every internal guard, cache audit, and the retire/unretire primitives +themselves must see retired nodes. Only the public `get_node/1` handle_call +applies the filter. + +**(b) New participation is refused.** A retired node may not be newly +referenced by a write. The guards live where the existing endpoint +validation already lives, in `graphdb_instance`: + +| Write path | New guard | Reason atom | +| ------------------------------ | ---------------------------------------------------------------------- | ------------------------------ | +| `create_instance` target class | class must not be retired (alongside the `instantiable` check) | `{class_retired, ClassNref}` | +| `create_instance` parent | parent must not be retired | `{parent_retired, ParentNref}` | +| `add_class_membership` target | class must not be retired | `{class_retired, ClassNref}` | +| `add_relationship` endpoints | none of source / characterization / target / reciprocal may be retired | `{endpoint_retired, Nref}` | + +These mirror the existing `is_instantiable`-style guard pattern: read the +node inside the existing validation transaction, reject if the marker is +set. + +### 4.2 What retire does NOT change (deferred) + +Existing structural participation is left intact — this is the deliberate +boundary of the "middle": + +- Traversals, children/parent enumeration, class→instance and + instance→class enumeration still include retired nodes. +- The `graphdb_class` inheritance / ancestors walk still passes through a + retired class. +- **A retired rule still fires.** A `graphdb_rules` rule node is reached + through existing structure, so retiring it does not stop it firing. + This is a concern, not a comfortable limitation, and is tracked as its + own follow-up in `TASKS.md` ("retired rules must not fire") — the natural + fix is a single filter at the firing read chokepoint + (`effective_rules_for_class` / `effective_connection_rules`), kept out of + this slice so the slice stays scoped to the retire mechanism itself. +- Query-engine results still include retired nodes. +- A node referenced as an AVP *value* elsewhere is unaffected (no index; + the pre-existing non-goal). +- There is no "list all retired nodes" operation; enumerating retired rows + is a concern of the background purge (tracked separately). + +The justification: these all read existing arcs/rows that remain valid. +Blocking them generally would mean rewriting the hot read/firing paths and +pre-empting lifecycle semantics the versioning feature will define — except +rule firing, which is called out above and tracked. + +--- + +## 5. Tier structure (on the seam) + +### 5.1 Tier-1 primitive + +```erlang +%% set_retired_(Nref, Bool, RetiredAttr) -> ok +%% Must run inside an active mnesia transaction. +%% Reads the node under a write lock, rewrites its AVP list so the +%% `retired` marker reflects Bool, and writes the row back. +%% Aborts with not_found if the row is absent. +set_retired_(Nref, Bool, RetiredAttr) -> + case mnesia:read(nodes, Nref, write) of + [] -> mnesia:abort(not_found); + [Node] -> + AVPs0 = Node#node.attribute_value_pairs, + AVPs1 = set_marker(AVPs0, RetiredAttr, Bool), + mnesia:write(nodes, Node#node{attribute_value_pairs = AVPs1}, write) + end. +``` + +`set_marker/3` removes any existing `retired` AVP and, when `Bool` is +`true`, appends `#{attribute => RetiredAttr, value => true}`. Setting +`false` simply removes the marker (absence = active), keeping the AVP list +free of dead `value => false` entries and making `retire_node`/ +`unretire_node` exact inverses. + +### 5.2 Tier-2 wrappers + +Both wrappers do the arithmetic static guard, then run the primitive under +`graphdb_mgr:transaction/1`: + +```erlang +handle_call({retire_node, Nref}, _From, State) -> + {reply, set_retired(Nref, true, State), State}; +handle_call({unretire_node, Nref}, _From, State) -> + {reply, set_retired(Nref, false, State), State}. + +set_retired(Nref, _Bool, _State) when Nref < ?NREF_START -> + {error, permanent_node_immutable}; +set_retired(Nref, Bool, #state{retired_nref = RetiredAttr}) -> + case graphdb_mgr:transaction(fun() -> set_retired_(Nref, Bool, RetiredAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end. +``` + +`transaction/1` maps `{atomic, ok}` → `{ok, ok}`; the wrapper normalises +that to a bare `ok` for the public contract, and passes `{error, Reason}` +(including `{error, not_found}` from the abort) straight through. + +--- + +## 6. Why these stay gen_server calls + +The seam recommends write entry points be **plain functions** so the +transaction runs in the caller and high-throughput writes are not +serialised through the `graphdb_mgr` process. `retire_node` / +`unretire_node` deliberately deviate: + +- they need the seeded `retired` nref, which is cached in `graphdb_mgr`'s + gen_server state; +- they are **low-frequency administrative** operations, so serialising + them through the server is harmless. + +The transaction body is still the seam's tier-1 primitive run via +`transaction/1`; only the wrapper is a `call`. High-frequency consumers +(e.g. a future batch `mutate/1`) should still follow the plain-function +form. This deviation is recorded here so the seam convention is not read as +violated by accident. + +--- + +## 7. Testing + +Common Test in the per-case scratch database, runtime-tier scratch nrefs. +`delete_node` is untouched, so its three existing guard cases +(`category_guard_delete`, `category_guard_allows_noncategory_delete`, +`category_guard_delete_nonexistent`) stay green **unchanged** — no test +churn. + +**`graphdb_mgr_SUITE` — retire lifecycle (new)** + +1. Retire a runtime node → `ok`; `get_node/1` then returns + `{error, retired}`; `unretire_node/1` then succeeds (proves the row was + not removed). +2. `unretire_node/1` → `ok`; `get_node/1` returns `{ok, Node}` again; the + `retired` AVP is gone (exact-inverse check). +3. Idempotence: re-retiring a retired node → `ok`; unretiring a + non-retired node → `ok`. +4. `retire_node(Nref)` / `unretire_node(Nref)` with `Nref < ?NREF_START` + (e.g. a category and a permanent attribute such as nref 27) + → `{error, permanent_node_immutable}`; the node is unchanged. +5. `retire_node(RuntimeNonexistent)` (nref `>= ?NREF_START`, no row) + → `{error, not_found}`. + +**`graphdb_instance_SUITE` — block-new-participation guards (new)** + +6. `create_instance` against a retired class → `{error, + {class_retired, ClassNref}}`. +7. `create_instance` under a retired parent → `{error, + {parent_retired, ParentNref}}`. +8. `add_class_membership` to a retired class → `{error, + {class_retired, ClassNref}}`. +9. `add_relationship` where the characterization / target / reciprocal is + retired → `{error, {endpoint_retired, Nref}}` (one case per endpoint + position, plus a clean-arc canary that still succeeds). + +--- + +## 8. Files touched + +| File | Change | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/graphdb/src/graphdb_attr.erl` | Seed `retired` literal-attribute; add to state + `seeded_nrefs/0` map | +| `apps/graphdb/src/graphdb_mgr.erl` | Add `retire_node/1` + `unretire_node/1` (+exports); `get_node/1` retired filter; tier-1 `set_retired_/3`; cache `retired_nref` at init | +| `apps/graphdb/src/graphdb_instance.erl` | Cache `retired_nref`; block-new-participation guards (class target, parent, arc endpoints) | +| `apps/graphdb/test/graphdb_mgr_SUITE.erl` | New retire/unretire lifecycle cases (existing delete-guard cases unchanged) | +| `apps/graphdb/test/graphdb_instance_SUITE.erl` | New guard cases | +| `docs/Architecture.md` | Note `retire_node` / `unretire_node` soft-retire and the `retired` marker | +| `docs/diagrams/ontology-tree.md` | Add the `retired` seed under the Attribute Literals sub-group | +| `TASKS.md` | Mark node-deletion slice A designed; add the two follow-up tasks (§9) | + +`delete_node/1` and `check_category_guard/1` are intentionally **not** in +this list. + +--- + +## 9. Dependencies, ordering, and follow-ups + +1. **Transaction-layering seam** (`transaction/1`) — already merged (PR + #41). +2. This slice — `retire_node` + `unretire_node`. +3. **Project boundary** (architectural, `TASKS.md`) — unblocks the later + hard-delete fast-path for project instances; the reserved `delete_node` + is where that real delete eventually lands. +4. **Retired-node purge** (background GC, `TASKS.md`) — reclaims retired + rows once the history/versioning feature defines what is safe to forget. + +New follow-up tasks this design adds to `TASKS.md`: + +- **Retired rules must not fire** — exclude retired rule nodes at the + firing read chokepoint (`effective_rules_for_class` / + `effective_connection_rules`). Deferred from this slice (§4.2). +- **Unify permanent-tier immutability** — `delete_node`'s category-only + guard (`category_nodes_are_immutable`) is too narrow; categories are not + the only permanent nodes. When the real `delete_node` lands, its guard + (and that of `update_node_avps`) should refuse the whole permanent tier, + consistent with `retire_node`'s `permanent_node_immutable`. + +--- + +## 10. Decision log + +All open items from the brainstorm are resolved: + +1. `get_node/1` on a retired node → `{error, retired}` (distinguishes + retired from absent). +2. Operation is named `retire_node/1` (+ `unretire_node/1`); `delete_node` + is **not** aliased — it is reserved for a future real delete. +3. `retire_node`/`unretire_node` refuse the whole permanent tier + (`Nref < ?NREF_START`) with the new atom `permanent_node_immutable`; + `delete_node` and `category_nodes_are_immutable` are untouched; the + broader-guard unification is a tracked follow-up (§9). +4. Retired rules still firing is a tracked follow-up (§9), not an accepted + limitation. diff --git a/docs/diagrams/ontology-tree.md b/docs/diagrams/ontology-tree.md index 78ab1ec..a6cf40f 100644 --- a/docs/diagrams/ontology-tree.md +++ b/docs/diagrams/ontology-tree.md @@ -1,6 +1,6 @@ # Ontology Tree — Bootstrap + Runtime Init Seeds -**Status:** current as of 2026-06-11 (post F4 B4 Task 1 — `reciprocal_nref` literal added). +**Status:** current as of 2026-06-17 (post soft-retire Task 1 — `retired` lifecycle marker added). This diagram is the **organisational shape of the environment ontology** immediately after `application:start(database)` finishes. It captures: @@ -79,6 +79,7 @@ graph LR NRA["relationship_avp
(runtime, attribute)"]:::attr NAT["attribute_type
(runtime, attribute)"]:::attr NIN["instantiable
(runtime, attribute)"]:::attr + NRE["retired
(runtime, attribute)"]:::attr NBL["base_language
(runtime, attribute)"]:::attr NPL["project_language
(runtime, attribute)"]:::attr NRCC["child_class_nref
(runtime, attribute)"]:::attr @@ -168,6 +169,7 @@ graph LR NAL ==> NRA NAL ==> NAT NAL ==> NIN + NAL ==> NRE NLL ==> NBL NLL ==> NPL NRL ==> NRCC @@ -246,12 +248,13 @@ Subtree → arc kind: Runtime sub-group / attribute / class nrefs sit at 10000+ and are not enumerated here (they shift between sessions); the L7 Attribute -Literals and Language Literals sub-groups are seeded by -`graphdb_attr:init/1` and `graphdb_language:init/1`, and the F4 -Rule Literals sub-group (8 literals, including `reciprocal_nref` added -in B4) plus the `Rule` / `CompositionRule` / `ConnectionRule` -meta-classes and the `applies_to` / `applied_by` pair are seeded by -`graphdb_rules:init/1`. +Literals sub-group (6 literals: `literal_type`, `target_kind`, +`relationship_avp`, `attribute_type`, `instantiable`, `retired`) and +Language Literals sub-group are seeded by `graphdb_attr:init/1` and +`graphdb_language:init/1`, and the F4 Rule Literals sub-group (8 +literals, including `reciprocal_nref` added in B4) plus the `Rule` / +`CompositionRule` / `ConnectionRule` meta-classes and the `applies_to` +/ `applied_by` pair are seeded by `graphdb_rules:init/1`. ## Maintenance diff --git a/docs/superpowers/plans/2026-06-17-delete-node-soft-retire.md b/docs/superpowers/plans/2026-06-17-delete-node-soft-retire.md new file mode 100644 index 0000000..0af4add --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-delete-node-soft-retire.md @@ -0,0 +1,803 @@ + + +# Node Soft-Retire (retire_node / unretire_node) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement reversible soft-retire for runtime graph nodes — +`graphdb_mgr:retire_node/1` + `unretire_node/1` — backed by a boolean +`retired` marker AVP, a hidden direct lookup, and block-new-participation +guards. + +**Architecture:** A new seeded boolean literal-attribute `retired` (owned +by `graphdb_attr`, mirroring L9 `instantiable`) is stamped as an AVP on a +node's row to retire it. `graphdb_mgr` gains `retire_node/1` / +`unretire_node/1` (tier-2 wrappers over a tier-1 `set_retired_/3` primitive +run through the existing `graphdb_mgr:transaction/1` seam) and filters +retired nodes out of the public `get_node/1`. `graphdb_instance` refuses a +retired node as a new instance target, parent, or arc endpoint. Nothing is +removed from Mnesia, so no arc or cache is ever orphaned. + +**Tech Stack:** Erlang/OTP 28.5, rebar3 3.27 (`./rebar3`), Mnesia, Common +Test, EUnit. + +**Spec:** `docs/designs/delete-node-soft-retire-design.md` + +## Global Constraints + +- Build/test with the repo-local `./rebar3` (kerl PATH is preset — no + `source ~/.bashrc`). Compile must stay **zero-warning**. +- New source/test files start with the project header (copyright block, + author/created/description, revision history, module attributes, + NYI/UEM macros where applicable). Match surrounding files. +- Indentation is **tabs**, matching every existing `apps/graphdb` file. +- Explicit `-export([...])` lists only — never `-compile(export_all)`. +- The marker is a **boolean**: `#{attribute => RetiredNref, value => true}` + means retired; absence means active. Setting active **removes** the AVP + (no dead `value => false` entries). +- `delete_node/1`, `check_category_guard/1`, and the + `category_nodes_are_immutable` atom are **left untouched**. Their three + existing `graphdb_mgr_SUITE` cases must stay green unchanged. +- Permanent-tier guard atom is exactly `permanent_node_immutable`; + retired-lookup atom is exactly `retired`; participation atoms are exactly + `{class_retired, ClassNref}`, `{parent_retired, ParentNref}`, + `{endpoint_retired, Nref}`. +- `?NREF_START`, `?NREF_ENGLISH`, arc-label macros come from + `apps/graphdb/include/graphdb_nrefs.hrl` (already included by every + target module). +- `graphdb_mgr` starts **before** `graphdb_attr` (graphdb_sup children 3 + vs 4), so `graphdb_mgr` must fetch the seeded `retired` nref **lazily** + (on first use), not at `init/1`. `graphdb_instance` starts **after** + `graphdb_attr`, so it fetches at `init/1` (as it already does for + `instantiable`). + +--- + +## Task 1: Seed the `retired` marker in graphdb_attr + +**Files:** +- Modify: `apps/graphdb/src/graphdb_attr.erl` (state record ~line 103; + `init/1` ~line 342; `seeded_nrefs` handle_call ~line 412) +- Test: `apps/graphdb/test/graphdb_attr_SUITE.erl` + +**Interfaces:** +- Produces: `graphdb_attr:seeded_nrefs/0` returns a map that now includes + `retired => integer()` (the nref of the seeded `retired` + literal-attribute, under the Attribute Literals sub-group, in the + permanent tier `(?NREF_ENGLISH, ?NREF_START)`). + +- [ ] **Step 1: Write the failing test** + +Add to `apps/graphdb/test/graphdb_attr_SUITE.erl`: export +`seeds_retired_marker/1`, add it to the same `all/0` group that lists +`seeds_instantiable_marker`, and add this case (mirrors +`seeds_instantiable_marker`): + +```erlang +seeds_retired_marker(_Config) -> + {ok, _} = graphdb_attr:start_link(), + {ok, #{retired := RetNref, + attribute_literals_group := AttrLitNref, + attribute_type := AtNref}} = + graphdb_attr:seeded_nrefs(), + ?assert(is_integer(RetNref)), + ?assert(RetNref > ?NREF_ENGLISH andalso RetNref < ?NREF_START), + {ok, Node} = graphdb_attr:get_attribute(RetNref), + ?assertEqual(attribute, Node#node.kind), + ?assertEqual([AttrLitNref], Node#node.parents), + ?assert(lists:member(#{attribute => ?NAME_ATTR_ATTRIBUTE, + value => "retired"}, Node#node.attribute_value_pairs)), + ?assert(lists:member(#{attribute => AtNref, value => literal}, + Node#node.attribute_value_pairs)). +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_attr_SUITE --case seeds_retired_marker` +Expected: FAIL — `seeded_nrefs/0` map has no `retired` key (badmatch on the +map pattern). + +- [ ] **Step 3: Implement the seed** + +In `apps/graphdb/src/graphdb_attr.erl`: + +1. Add a field to the `-record(state, {...})` (after `instantiable_nref`): + +```erlang + instantiable_nref, %% integer() -- seeded marker literal attribute + retired_nref %% integer() -- seeded `retired` lifecycle marker +``` + +2. In `init/1`, add the seed inside the `#state{...}` construction (after + the `instantiable_nref` line): + +```erlang + instantiable_nref = ensure_seed("instantiable", AttrLitNref), + retired_nref = ensure_seed("retired", AttrLitNref) +``` + +3. Extend the `init/1` `logger:info` format string and args to include + `retired=~p` / `State#state.retired_nref` (keep the existing entries). + +4. In the `seeded_nrefs` handle_call, add the `retired` entry to the map: + +```erlang + instantiable => State#state.instantiable_nref, + retired => State#state.retired_nref +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_attr_SUITE` +Expected: PASS (all cases, including `seeds_retired_marker`). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_attr.erl apps/graphdb/test/graphdb_attr_SUITE.erl +git commit -m "feat(graphdb_attr): seed retired lifecycle marker literal-attribute" +``` + +--- + +## Task 2: retire_node / unretire_node + tier-1 primitive (graphdb_mgr) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` (exports ~line 103; state + record line 94; public API near `delete_node/1` ~line 240; handle_calls + ~line 380) +- Test: `apps/graphdb/test/graphdb_mgr_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_attr:seeded_nrefs/0` (Task 1, `retired` key); + `graphdb_mgr:transaction/1` (existing seam: `fun(() -> R) -> {ok, R} | + {error, term()}`). +- Produces: + - `graphdb_mgr:retire_node(Nref) -> ok | {error, permanent_node_immutable + | not_found}` + - `graphdb_mgr:unretire_node(Nref) -> ok | {error, + permanent_node_immutable | not_found}` + - both idempotent; both refuse `Nref < ?NREF_START`. + +- [ ] **Step 1: Write the failing tests** + +In `apps/graphdb/test/graphdb_mgr_SUITE.erl`: export and register (in +`all/0`) four cases, and add them to the **full-stack** `init_per_testcase` +clause (the `when TC =:= ...` list that starts `graphdb_attr` / +`graphdb_class` / `graphdb_instance`) so the lazy `seeded_nrefs/0` fetch and +`create_class/2` work: + +```erlang +retire_node_sets_and_clears_marker(_Config) -> + {ok, ClassNref} = graphdb_mgr:create_class("RetireMe", 3), + ?assert(ClassNref >= ?NREF_START), + ok = graphdb_mgr:retire_node(ClassNref), + [#node{attribute_value_pairs = AVPs1}] = + mnesia:dirty_read(nodes, ClassNref), + ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs1)), + ok = graphdb_mgr:unretire_node(ClassNref), + [#node{attribute_value_pairs = AVPs2}] = + mnesia:dirty_read(nodes, ClassNref), + ?assertEqual(false, + lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs2)). + +retire_node_is_idempotent(_Config) -> + {ok, ClassNref} = graphdb_mgr:create_class("RetireIdem", 3), + ok = graphdb_mgr:retire_node(ClassNref), + ok = graphdb_mgr:retire_node(ClassNref), + ok = graphdb_mgr:unretire_node(ClassNref), + ok = graphdb_mgr:unretire_node(ClassNref). + +retire_node_refuses_permanent_tier(_Config) -> + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:retire_node(1)), + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:retire_node(27)), + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:unretire_node(27)). + +retire_node_not_found(_Config) -> + BadNref = ?NREF_START + 999999, + ?assertEqual({error, not_found}, graphdb_mgr:retire_node(BadNref)), + ?assertEqual({error, not_found}, graphdb_mgr:unretire_node(BadNref)). +``` + +Note the suite already defines a local `-record(node, ...)` (top of file) +and includes `graphdb_nrefs.hrl`, so `#node{}`, `?NREF_START` and +`mnesia:dirty_read/2` are available. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE --case retire_node_sets_and_clears_marker` +Expected: FAIL — `graphdb_mgr:retire_node/1` is undefined. + +- [ ] **Step 3: Implement retire_node / unretire_node** + +In `apps/graphdb/src/graphdb_mgr.erl`: + +1. Add to the external API `-export([...])` (next to `delete_node/1`): + +```erlang + delete_node/1, + retire_node/1, + unretire_node/1, +``` + +2. Add a field to the (currently empty) state record: + +```erlang +-record(state, { + retired_nref %% integer() | undefined -- seeded `retired` + %% marker nref; lazily fetched from graphdb_attr + %% on first use (graphdb_attr starts after mgr) +}). +``` + +3. Add the public functions near `delete_node/1`: + +```erlang +%% retire_node(Nref) -> ok | {error, Reason} +%% Soft-retires a runtime node (sets the boolean `retired` marker AVP). +%% Idempotent. Refuses the permanent tier (Nref < ?NREF_START). +retire_node(Nref) -> + gen_server:call(?MODULE, {retire_node, Nref}). + +%% unretire_node(Nref) -> ok | {error, Reason} +%% Clears the `retired` marker. Idempotent. +unretire_node(Nref) -> + gen_server:call(?MODULE, {unretire_node, Nref}). +``` + +4. Add the handle_calls (next to the existing `{delete_node, Nref}` + clause): + +```erlang +handle_call({retire_node, Nref}, _From, State0) -> + {Reply, State} = set_retired(Nref, true, State0), + {reply, Reply, State}; +handle_call({unretire_node, Nref}, _From, State0) -> + {Reply, State} = set_retired(Nref, false, State0), + {reply, Reply, State}; +``` + +5. Add the wrapper + lazy-cache + tier-1 primitive helpers (place them + near `do_get_node/1` / `check_category_guard/1`): + +```erlang +%%----------------------------------------------------------------------------- +%% set_retired(Nref, Bool, State) -> {ok | {error, Reason}, State'} +%% +%% Tier-2 wrapper. Static arithmetic guard refuses the whole permanent tier +%% (Nref < ?NREF_START); otherwise lazily resolves the seeded `retired` +%% nref (caching it in State) and runs the tier-1 primitive through the +%% transaction seam. Returns the possibly-updated State so the cache sticks. +%%----------------------------------------------------------------------------- +set_retired(Nref, _Bool, State) when Nref < ?NREF_START -> + {{error, permanent_node_immutable}, State}; +set_retired(Nref, Bool, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> set_retired_(Nref, Bool, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. + +%%----------------------------------------------------------------------------- +%% ensure_retired_nref(State) -> {RetAttr, State'} +%% +%% Lazily fetches the seeded `retired` nref from graphdb_attr the first +%% time it is needed and caches it in State. graphdb_attr is started after +%% graphdb_mgr, so this cannot be done at init/1. +%%----------------------------------------------------------------------------- +ensure_retired_nref(#state{retired_nref = undefined} = State) -> + {ok, #{retired := RetAttr}} = graphdb_attr:seeded_nrefs(), + {RetAttr, State#state{retired_nref = RetAttr}}; +ensure_retired_nref(#state{retired_nref = RetAttr} = State) -> + {RetAttr, State}. + +%%----------------------------------------------------------------------------- +%% set_retired_(Nref, Bool, RetAttr) -> ok +%% Tier-1 primitive. Must run inside an active mnesia transaction. Reads the +%% node under a write lock, rewrites its AVP list so the `retired` marker +%% reflects Bool, writes it back. Aborts with not_found if absent. +%%----------------------------------------------------------------------------- +set_retired_(Nref, Bool, RetAttr) -> + case mnesia:read(nodes, Nref, write) of + [] -> mnesia:abort(not_found); + [Node] -> + AVPs0 = Node#node.attribute_value_pairs, + AVPs1 = set_marker(AVPs0, RetAttr, Bool), + mnesia:write(nodes, + Node#node{attribute_value_pairs = AVPs1}, write) + end. + +%%----------------------------------------------------------------------------- +%% set_marker(AVPs, RetAttr, Bool) -> AVPs' +%% Removes any existing `retired` AVP; if Bool is true, appends a fresh +%% #{attribute => RetAttr, value => true}. Setting false leaves it removed. +%%----------------------------------------------------------------------------- +set_marker(AVPs, RetAttr, Bool) -> + Stripped = [P || P <- AVPs, not is_retired_avp(P, RetAttr)], + case Bool of + true -> Stripped ++ [#{attribute => RetAttr, value => true}]; + false -> Stripped + end. + +is_retired_avp(#{attribute := A}, RetAttr) -> A =:= RetAttr; +is_retired_avp(_, _) -> false. +``` + +Note `mnesia:read/3` (write lock) and `mnesia:abort/1` may already be +present elsewhere in the file; if `?NREF_START` is not yet referenced in +`graphdb_mgr.erl`, it resolves from the already-included +`graphdb_nrefs.hrl`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE` +Expected: PASS — the four new cases plus all existing cases (the three +`category_guard_*` delete cases unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_mgr.erl apps/graphdb/test/graphdb_mgr_SUITE.erl +git commit -m "feat(graphdb_mgr): add retire_node/unretire_node soft-retire" +``` + +--- + +## Task 3: Hide retired nodes from the public get_node/1 + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` (`get_node` handle_call ~line + 352; `do_get_node/1` ~line 442) +- Test: `apps/graphdb/test/graphdb_mgr_SUITE.erl` + +**Interfaces:** +- Consumes: the lazy `ensure_retired_nref/1` cache and `is_retired_avp/2` + helper from Task 2. +- Produces: public `graphdb_mgr:get_node(Nref)` returns `{error, retired}` + for a retired node; internal `do_get_node/1` stays raw (returns the row). + +- [ ] **Step 1: Write the failing test** + +Add to `apps/graphdb/test/graphdb_mgr_SUITE.erl` (export + register in +`all/0` + add to the full-stack `init_per_testcase` clause): + +```erlang +get_node_hides_retired(_Config) -> + {ok, ClassNref} = graphdb_mgr:create_class("HideMe", 3), + {ok, _} = graphdb_mgr:get_node(ClassNref), + ok = graphdb_mgr:retire_node(ClassNref), + ?assertEqual({error, retired}, graphdb_mgr:get_node(ClassNref)), + ok = graphdb_mgr:unretire_node(ClassNref), + {ok, #node{nref = ClassNref}} = graphdb_mgr:get_node(ClassNref). +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE --case get_node_hides_retired` +Expected: FAIL — `get_node/1` still returns `{ok, Node}` for the retired +node (the `{error, retired}` assertion fails). + +- [ ] **Step 3: Implement the filter** + +Replace the `get_node` handle_call so it threads the lazy cache and filters +retired nodes (leave `do_get_node/1` unchanged — it must stay raw): + +```erlang +handle_call({get_node, Nref}, _From, State0) -> + case do_get_node(Nref) of + {ok, Node} -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case is_retired_avp_present(Node, RetAttr) of + true -> {error, retired}; + false -> {ok, Node} + end, + {reply, Reply, State}; + {error, _} = Err -> + {reply, Err, State0} + end; +``` + +Add the small predicate near `is_retired_avp/2`: + +```erlang +is_retired_avp_present(#node{attribute_value_pairs = AVPs}, RetAttr) -> + lists:any(fun(#{attribute := A, value := true}) when A =:= RetAttr -> true; + (_) -> false + end, AVPs). +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE` +Expected: PASS — `get_node_hides_retired` plus all existing cases. + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_mgr.erl apps/graphdb/test/graphdb_mgr_SUITE.erl +git commit -m "feat(graphdb_mgr): hide retired nodes from public get_node/1" +``` + +--- + +## Task 4: Block-new-participation guards (graphdb_instance) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` (state record; + `init/1` ~line 372; create_instance handle_call ~line 391; + add_class_membership handle_call ~line 405; `do_create_instance/4`; + `do_validate_class/2` ~line 1112; `do_validate_parent/1` ~line 1143; + `do_add_class_membership/3`; `validate_arc_endpoints/5`; `do_add_relationship/7`) +- Test: `apps/graphdb/test/graphdb_instance_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_attr:seeded_nrefs/0` `retired` key (Task 1); + `graphdb_mgr:retire_node/1` (Task 2). +- Produces: `create_instance` / `add_class_membership` reject a retired + target class with `{error, {class_retired, ClassNref}}`; `create_instance` + rejects a retired parent with `{error, {parent_retired, ParentNref}}`; + `add_relationship` rejects any retired endpoint with `{error, + {endpoint_retired, Nref}}`. + +- [ ] **Step 1: Write the failing tests** + +In `apps/graphdb/test/graphdb_instance_SUITE.erl` (export + register in +`all/0`; the suite already starts the full worker stack): + +```erlang +create_instance_refuses_retired_class(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("RetClass", 3), + ok = graphdb_mgr:retire_node(ClassNref), + ?assertEqual({error, {class_retired, ClassNref}}, + graphdb_instance:create_instance("i", ClassNref, 3)). + +add_class_membership_refuses_retired_class(_Config) -> + {ok, ClassA} = graphdb_class:create_class("MemA", 3), + {ok, ClassB} = graphdb_class:create_class("MemB", 3), + {ok, Inst, _} = graphdb_instance:create_instance("m", ClassA, 3), + ok = graphdb_mgr:retire_node(ClassB), + ?assertEqual({error, {class_retired, ClassB}}, + graphdb_instance:add_class_membership(Inst, ClassB)). + +create_instance_refuses_retired_parent(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("PClass", 3), + {ok, Parent, _} = graphdb_instance:create_instance("p", ClassNref, 3), + ok = graphdb_mgr:retire_node(Parent), + ?assertEqual({error, {parent_retired, Parent}}, + graphdb_instance:create_instance("child", ClassNref, Parent)). + +add_relationship_refuses_retired_endpoint(_Config) -> + %% Build a valid arc, then retire the target and re-attempt. + {ok, ClassNref} = graphdb_class:create_class("ArcClass", 3), + {ok, Src, _} = graphdb_instance:create_instance("s", ClassNref, 3), + {ok, Tgt, _} = graphdb_instance:create_instance("t", ClassNref, 3), + {ok, Fwd} = graphdb_attr:create_relationship_attribute_pair( + "Likes", "LikedBy", instance), + {ok, Rec} = graphdb_attr:get_reciprocal(Fwd), + ok = graphdb_instance:add_relationship(Src, Fwd, Tgt, Rec), + ok = graphdb_mgr:retire_node(Tgt), + {ok, Tgt2, _} = graphdb_instance:create_instance("t2", ClassNref, 3), + ok = graphdb_mgr:retire_node(Tgt2), + ?assertEqual({error, {endpoint_retired, Tgt2}}, + graphdb_instance:add_relationship(Src, Fwd, Tgt2, Rec)). +``` + +If the helper names `graphdb_attr:create_relationship_attribute_pair/3`, +`graphdb_attr:get_reciprocal/1`, or `graphdb_instance:add_relationship/4` +differ in this suite's existing tests, mirror whatever the suite's existing +`add_relationship` cases use to construct a valid arc — the assertion of +interest is only the final `{error, {endpoint_retired, Tgt2}}`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case create_instance_refuses_retired_class` +Expected: FAIL — `create_instance` currently succeeds against a retired +class (returns `{ok, _, _}`), so the `{error, {class_retired, _}}` +assertion fails. + +- [ ] **Step 3: Implement the guards** + +In `apps/graphdb/src/graphdb_instance.erl`: + +1. Add `retired_nref` to the state record (after `instantiable_nref`): + +```erlang + instantiable_nref, %% integer() -- seeded `instantiable` marker + retired_nref %% integer() -- seeded `retired` marker +``` + +2. In `init/1`, fetch `retired` alongside the existing keys: + +```erlang + {ok, #{target_kind := TkAttr, instantiable := InstAttr, + retired := RetAttr}} = graphdb_attr:seeded_nrefs(), + {ok, #state{target_kind_avp_nref = TkAttr, + instantiable_nref = InstAttr, + retired_nref = RetAttr}}. +``` + +3. In the `create_instance` handle_call, add `ret_attr` to the Ctx and pull + it from State: + +```erlang +handle_call({create_instance, Name, ClassNref, ParentNref, Resolver, + ConflictResolver}, _From, + #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> + Ctx = #{inst_attr => InstAttr, ret_attr => RetAttr, on_path => [], + resolver => Resolver, conflict_resolver => ConflictResolver, + root_parent => ParentNref, root_source => undefined}, + {reply, do_create_instance(Name, ClassNref, ParentNref, Ctx), State}; +``` + +4. In `do_create_instance/4`, thread the retired attr into both validators: + +```erlang +do_create_instance(Name, ClassNref, ParentNref, Ctx) -> + InstAttr = maps:get(inst_attr, Ctx), + RetAttr = maps:get(ret_attr, Ctx), + case do_validate_class(ClassNref, InstAttr, RetAttr) of + ok -> + case do_validate_parent(ParentNref, RetAttr) of + ok -> + fire_create(Name, ClassNref, ParentNref, Ctx); + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end. +``` + +5. Extend `do_validate_class/2` to `do_validate_class/3` (retired check + first, then the existing instantiable check): + +```erlang +do_validate_class(ClassNref, InstAttr, RetAttr) -> + case mnesia:dirty_read(nodes, ClassNref) of + [#node{kind = class, attribute_value_pairs = AVPs}] -> + case is_retired(AVPs, RetAttr) of + true -> {error, {class_retired, ClassNref}}; + false -> + case is_marked_non_instantiable(AVPs, InstAttr) of + true -> {error, {class_not_instantiable, ClassNref}}; + false -> ok + end + end; + [#node{kind = Kind}] -> {error, {not_a_class, Kind}}; + [] -> {error, class_not_found} + end. +``` + +6. Extend `do_validate_parent/1` to `do_validate_parent/2`: + +```erlang +do_validate_parent(ParentNref, RetAttr) -> + case mnesia:dirty_read(nodes, ParentNref) of + [#node{attribute_value_pairs = AVPs}] -> + case is_retired(AVPs, RetAttr) of + true -> {error, {parent_retired, ParentNref}}; + false -> ok + end; + [] -> {error, parent_not_found} + end. +``` + +7. Add the `is_retired/2` predicate next to `is_marked_non_instantiable/2` + (deliberate small duplication, same YAGNI rationale already documented + there): + +```erlang +%% is_retired(AVPs, RetAttr) -> boolean() +%% True only when AVPs contains #{attribute => RetAttr, value => true}. +is_retired(AVPs, RetAttr) -> + lists:any(fun + (#{attribute := A, value := true}) when A =:= RetAttr -> true; + (_) -> false + end, AVPs). +``` + +8. Update `do_add_class_membership/3` to thread `RetAttr` into + `do_validate_class`, and its handle_call to pass `State#state.retired_nref`: + +```erlang +handle_call({add_class_membership, InstanceNref, ClassNref}, _From, + #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> + {reply, do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr), + State}; +``` + +```erlang +do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr) -> + case do_get_instance(InstanceNref) of + {ok, _} -> + case do_validate_class(ClassNref, InstAttr, RetAttr) of + ok -> do_write_class_membership(InstanceNref, + ClassNref); + {error, _} = Err -> Err + end; + {error, _} = Err -> + Err + end. +``` + +9. Extend `validate_arc_endpoints/5` to `/6` (add `RetAttr`) and reject a + retired endpoint. Bind all four resolved nodes' AVP lists in the success + clause and gate on `first_retired/2` **before** the existing kind checks, + which are otherwise preserved verbatim (the target kind `TKind` and the + `check_target_kind(CharNode, TKind, TkAttr)` call are unchanged): + +```erlang +validate_arc_endpoints(SourceNref, CharNref, TargetNref, ReciprocalNref, + TkAttr, RetAttr) -> + F = fun() -> + Source = mnesia:read(nodes, SourceNref), + Target = mnesia:read(nodes, TargetNref), + Char = mnesia:read(nodes, CharNref), + Recip = mnesia:read(nodes, ReciprocalNref), + {Source, Target, Char, Recip} + end, + case mnesia:transaction(F) of + {atomic, {[], _, _, _}} -> + {error, {source_not_found, SourceNref}}; + {atomic, {_, [], _, _}} -> + {error, {target_not_found, TargetNref}}; + {atomic, {_, _, [], _}} -> + {error, {characterization_not_found, CharNref}}; + {atomic, {_, _, _, []}} -> + {error, {reciprocal_not_found, ReciprocalNref}}; + {atomic, {[#node{attribute_value_pairs = SAVPs}], + [#node{kind = TKind, attribute_value_pairs = TAVPs}], + [#node{kind = CKind, attribute_value_pairs = CAVPs} = CharNode], + [#node{kind = RKind, attribute_value_pairs = RAVPs}]}} -> + case first_retired([{SourceNref, SAVPs}, {TargetNref, TAVPs}, + {CharNref, CAVPs}, {ReciprocalNref, RAVPs}], + RetAttr) of + {retired, RNref} -> + {error, {endpoint_retired, RNref}}; + none -> + case {CKind, RKind} of + {attribute, attribute} -> + check_target_kind(CharNode, TKind, TkAttr); + {attribute, _} -> + {error, {reciprocal_not_an_attribute, + ReciprocalNref, RKind}}; + {_, _} -> + {error, {characterization_not_an_attribute, + CharNref, CKind}} + end + end; + {aborted, Reason} -> + {error, Reason} + end. + +%% first_retired([{Nref, AVPs}], RetAttr) -> {retired, Nref} | none +first_retired([], _RetAttr) -> none; +first_retired([{Nref, AVPs} | Rest], RetAttr) -> + case is_retired(AVPs, RetAttr) of + true -> {retired, Nref}; + false -> first_retired(Rest, RetAttr) + end. +``` + +10. Update `do_add_relationship/7` to pass `State#state.retired_nref` into + `validate_arc_endpoints`: + +```erlang + case validate_arc_endpoints(SourceNref, CharNref, TargetNref, + ReciprocalNref, TkAttr, State#state.retired_nref) of +``` + + (`TkAttr` is already bound from `State#state.target_kind_avp_nref` at + the top of `do_add_relationship/7`.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE` +Expected: PASS — the four new guard cases plus all existing instance cases +(the existing `create_instance` / `add_relationship` / membership cases must +remain green, proving the threading didn't break the happy paths). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl apps/graphdb/test/graphdb_instance_SUITE.erl +git commit -m "feat(graphdb_instance): refuse retired nodes as new participation" +``` + +--- + +## Task 5: Documentation and TASKS status + +**Files:** +- Modify: `docs/Architecture.md` +- Modify: `docs/diagrams/ontology-tree.md` +- Modify: `TASKS.md` + +**Interfaces:** none (docs only). + +- [ ] **Step 1: Update `docs/Architecture.md`** + +In the `graphdb_mgr` description, note the new read/write contract: public +`get_node/1` returns `{error, retired}` for a retired node; `retire_node/1` +and `unretire_node/1` soft-retire / restore a runtime node via a boolean +`retired` marker AVP (built on `transaction/1`); `delete_node/1` remains +unimplemented and reserved for a future real delete. Keep it at +architectural altitude (a few sentences), matching the file's tone. Add a +one-line note that `graphdb_attr` seeds the `retired` lifecycle marker and +`graphdb_instance` refuses retired nodes as new instance targets/parents/arc +endpoints. + +- [ ] **Step 2: Update `docs/diagrams/ontology-tree.md`** + +Add a `retired` entry under the **Attribute Literals** sub-group (alongside +`instantiable`, `literal_type`, `target_kind`, `relationship_avp`, +`attribute_type`) in the Mermaid block, matching the existing node style. + +- [ ] **Step 3: Mark slice A implemented in `TASKS.md`** + +In the "Node deletion (slice A) — DESIGNED" subsection, change the heading +to "— IMPLEMENTED" and add a one-line pointer to the design doc and the +delivered functions (`graphdb_mgr:retire_node/1`, `unretire_node/1`). Leave +the two follow-up tasks (retired rules must not fire; unify permanent-tier +immutability), the project-boundary, and the retired-node-purge entries in +place. + +- [ ] **Step 4: Verify the full suite is green and warning-free** + +Run: `./rebar3 compile` (expect zero warnings), then +`make test-ct-parallel` and `./rebar3 eunit` +Expected: all CT + EUnit green. The project total grows by 9 CT cases +(1 attr + 4 mgr lifecycle/guard + 1 mgr get_node-filter + ... confirm the +exact count and update any README/MEMORY count only if the repo tracks it +in a checked-in file; do not edit `.wolf/`). + +- [ ] **Step 5: Commit** + +```bash +git add docs/Architecture.md docs/diagrams/ontology-tree.md TASKS.md +git commit -m "docs: record node soft-retire (retire_node/unretire_node)" +``` + +--- + +## Self-Review + +**Spec coverage** (`docs/designs/delete-node-soft-retire-design.md`): + +- §2 retired marker seeded by graphdb_attr → Task 1. ✓ +- §3 retire_node/unretire_node, idempotent, permanent_node_immutable, + not_found → Task 2. ✓ +- §4.1(a) get_node → {error, retired}, do_get_node raw → Task 3. ✓ +- §4.1(b) block-new-participation (class target, parent, arc endpoints) → + Task 4. ✓ +- §5 tier-1 `set_retired_/3` + tier-2 wrappers over `transaction/1` → + Task 2. ✓ +- §6 gen_server-call rationale (lazy cache because mgr starts before attr) + → Task 2 (`ensure_retired_nref/1`). ✓ +- §7 tests, including "existing delete-guard cases unchanged" → Tasks 2–4 + (delete_node untouched). ✓ +- §8 files touched → Tasks 1–5 cover every row. ✓ +- §4.2 deferred items (retired rules still fire; query/traversal + visibility) → intentionally **not** implemented; tracked in TASKS.md. + Correct per spec. ✓ + +**Placeholder scan:** none. Every code step shows complete, runnable code; +Task 4 step 9 preserves the original kind-check (`TKind` / +`check_target_kind`) verbatim and only inserts the `first_retired/2` gate. + +**Type/name consistency:** `retired` seeded-map key, `retired_nref` state +field, `is_retired/2` predicate, `permanent_node_immutable` / `retired` / +`{class_retired,_}` / `{parent_retired,_}` / `{endpoint_retired,_}` atoms, +`set_retired/3` / `set_retired_/3` / `set_marker/3` / `ensure_retired_nref/1` +are used consistently across tasks and match the design's §10 decision log.