Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 35 additions & 35 deletions README.md

Large diffs are not rendered by default.

100 changes: 81 additions & 19 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 9 additions & 5 deletions apps/graphdb/src/graphdb_attr.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
}).


Expand Down Expand Up @@ -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} ->
Expand Down Expand Up @@ -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};

Expand Down
137 changes: 93 additions & 44 deletions apps/graphdb/src/graphdb_instance.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
}).


Expand Down Expand Up @@ -379,20 +380,21 @@ 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}}.


%%-----------------------------------------------------------------------------
%% handle_call/3 -- Creators
%%-----------------------------------------------------------------------------
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};

Expand All @@ -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};

%%-----------------------------------------------------------------------------
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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}
Expand All @@ -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.

Expand All @@ -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} ->
Expand All @@ -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),
Expand All @@ -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 ->
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading