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.