Skip to content
51 changes: 42 additions & 9 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,15 +324,48 @@ node written. Design `docs/designs/slice-c-instance-only-qc-design.md`.
Decide and document the intended contract before any caller comes to
depend on the current behaviour.

### Relationship mutation (slice E)

Only `add_relationship` (create) exists today — there is no remove or
update. `remove_relationship` deletes both directed rows of a logical edge
atomically and fixes the `parents`/`classes` caches on the referrers; it
shares the arc-removal primitive with `delete_node` (slice A).
`update_relationship` changes `characterization` / `target_nref` /
`reciprocal` / AVPs; the AVP-only edit mirrors `update_node_avps`
(slice B). Built on the transaction seam.
### Relationship mutation (slice E) — IMPLEMENTED

Design: `docs/designs/slice-e-relationship-mutation-design.md`; plan
`docs/superpowers/plans/2026-06-28-slice-e-relationship-mutation.md`.

Delivered, **connection-arcs only** (the exact mirror of `add_relationship`;
no `parents`/`classes` cache work — connection arcs are never cached):

- `remove_relationship/3,4` — atomically delete both directed rows of a
logical connection edge. (The earlier note that it "fixes the caches" and
"shares the arc-removal primitive with `delete_node`" was aspirational:
slice A shipped soft-retire only, so no hard-delete primitive exists to
share, and connection removal needs no cache fix.)
- `update_relationship/4,5` + `update_relationship_both/4,5` — AVP-only edit
of an existing edge, reusing slice B's `validate_avp_updates/1` +
`apply_avp_updates/2`. Single-direction is the one tier-1 primitive;
`*_both` composes it twice with independent `{Fwd, Rev}` lists. The
`?ARC_TEMPLATE` scope AVP is protected from edit.

Because nothing dedups connection edges at write time, the identity contract
is: `(S, C, T)` (optionally narrowed by `Template`) matches a logical edge;
zero matches → `relationship_not_found`, more than one → `ambiguous_relationship`.
**Remove is edge-level (both rows); AVP update is directed-row-level (one
row).** Built on the transaction seam; all three kinds compose into
`mutate/1`.

Deferred (recorded in the design): structural rewiring (`characterization` /
`target_nref` / `reciprocal` — expressible as `mutate([remove, add])`); a
rel-id-keyed form (the only disambiguator for genuine duplicate edges).

### Compositional arc mutation — `add_parent` / `add_child` / `remove_parent` / `remove_child` (follow-up)

Compositional-hierarchy ("part of") arc creators and removers between
instances: `add_parent(Child, Parent)` / `add_child(Parent, Child)` write a
`kind=composition` arc, and `remove_parent(Child, Parent)` /
`remove_child(Parent, Child)` delete it — all **maintaining the child's
`parents` cache** — the cache-touching counterpart that slice E
(connection-only) deliberately does not cover. The cache-maintenance core
here is also what a future `delete_node` hard-delete and a general
(kind-agnostic) arc remover would reuse. Built on the transaction seam;
tier-1 primitives + tier-2 wrappers + `mutate/1` grammar, with
`verify_caches/0` clean after each.

### Multilingual write-path integration (slice D)

Expand Down
22 changes: 21 additions & 1 deletion apps/graphdb/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,25 @@ Creates and manages instance nodes in the project (instance space).
its own txn). The caller allocates the rel-id pair up-front.
`do_add_relationship/7` (tier-2) and `graphdb_mgr:mutate/1` (tier-3) both
compose it into their single transaction.
- `remove_relationship/3,4` (source, char, target [, template]) — deletes
**both** directed rows of a logical connection edge atomically
(connection-arcs only; no cache work). `/3` ignores template; `/4` narrows
by it. Identity contract: zero matches → `{error, relationship_not_found}`,
more than one (duplicate edges — nothing dedups at write time) →
`{error, {ambiguous_relationship, Templates}}`; a missing symmetric partner
aborts `{error, {dangling_half_edge, Id}}` (never deletes a half-edge).
Tier-1 `remove_relationship_in_txn/4` + shared `resolve_forward_connection/4`
resolver are the in-txn primitives (slice E).
- `update_relationship/4,5` + `update_relationship_both/4,5` (source, char,
target [, template], Updates | {Fwd, Rev}) — AVP-only edit of an existing
connection edge, reusing slice B's `validate_avp_updates/1` +
`apply_avp_updates/2`. **Remove is edge-level; AVP update is
directed-row-level**: `update_relationship` edits the single row named by
`(S, C, T)` (edit the reverse by naming `(T, R, S)`); `*_both` edits both
directions with independent `{Fwd, Rev}` lists, composing the single tier-1
primitive `update_relationship_avps_in_txn/5` twice. The `?ARC_TEMPLATE`
scope AVP is protected from edit. Same not-found/ambiguity arms as remove
(slice E).
- `add_class_membership/2` (instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9)
- `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2`

Expand Down Expand Up @@ -378,7 +397,8 @@ Single public entry point; delegates to the five specialized workers.
- Rejects any runtime request to create, modify, or delete a `category` node with `{error, category_nodes_are_immutable}`
- Sequences Nref allocation → record write → Nref confirmation
- `mutate/1` — tier-3 batch entry point. Applies an ordered list of
`add_relationship` / `retire_node` / `unretire_node` / `update_node_avps`
`add_relationship` / `retire_node` / `unretire_node` / `update_node_avps` /
`remove_relationship` / `update_relationship` / `update_relationship_both`
mutations atomically in one `transaction/1` (all commit or none). Tagged-tuple
grammar; opaque bare-reason contract `{ok, [ok, ...]}` | `{error, Reason}`
with whole-batch rollback; `mutate([]) -> {ok, []}`. A **plain function**, not
Expand Down
239 changes: 239 additions & 0 deletions apps/graphdb/src/graphdb_instance.erl
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@
add_class_membership/2,
%% Tier-1 in-transaction primitive (write-path seam)
add_relationship_in_txn/9,
remove_relationship/3,
remove_relationship/4,
remove_relationship_in_txn/4,
resolve_forward_connection/4,
template_of/1,
update_relationship/4,
update_relationship/5,
update_relationship_avps_in_txn/5,
has_template_update/1,
update_relationship_both/4,
update_relationship_both/5,
update_relationship_both_in_txn/6,
%% Lookups
get_instance/1,
children/1,
Expand Down Expand Up @@ -1236,6 +1248,233 @@ add_relationship_in_txn({_Id1, _Id2} = IdPair, SourceNref, CharNref,
Rows).


%%-----------------------------------------------------------------------------
%% resolve_forward_connection(SourceNref, CharNref, TargetNref, TemplateSpec)
%% -> {ok, #relationship{}} | not_found | {ambiguous, [TemplateNref]}
%%
%% Tier-1 in-transaction helper. Finds the directed connection row(s) whose
%% (source, characterization, target) match, narrowed by TemplateSpec
%% (`any` = ignore template; an integer = match that template AVP). Classifies
%% none / exactly-one / many; the ambiguous case carries each matching row's
%% template so a /3 caller can re-issue as /4. Reads only; never aborts.
%%-----------------------------------------------------------------------------
resolve_forward_connection(SourceNref, CharNref, TargetNref, TemplateSpec) ->
Rows = mnesia:index_read(relationships, SourceNref,
#relationship.source_nref),
Matches = [R || R <- Rows,
R#relationship.kind =:= connection,
R#relationship.characterization =:= CharNref,
R#relationship.target_nref =:= TargetNref,
template_matches(R, TemplateSpec)],
case Matches of
[] -> not_found;
[Row] -> {ok, Row};
Many -> {ambiguous, [template_of(R) || R <- Many]}
end.

template_matches(_Row, any) ->
true;
template_matches(Row, TemplateNref) ->
template_of(Row) =:= TemplateNref.

%% The Template AVP rides on a connection row's avps.
template_of(#relationship{avps = AVPs}) ->
case find_avp_value(AVPs, ?ARC_TEMPLATE) of
{ok, V} -> V;
not_found -> undefined
end.

%%-----------------------------------------------------------------------------
%% remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec)
%% -> ok (aborts the enclosing transaction on any failure)
%%
%% Tier-1 primitive. Must run inside an active mnesia transaction; never opens
%% its own. Resolves the forward row (relationship_not_found /
%% {ambiguous_relationship, Templates}), locates its symmetric partner
%% (T, R, S) under the same concrete template, and deletes both rows. A
%% missing partner is an integrity violation -- aborts {dangling_half_edge, Id}
%% rather than deleting a half-edge. Used by remove_relationship/3,4 (tier-2)
%% and graphdb_mgr:mutate/1 (tier-3).
%%-----------------------------------------------------------------------------
remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec) ->
case resolve_forward_connection(SourceNref, CharNref, TargetNref,
TemplateSpec) of
not_found ->
mnesia:abort(relationship_not_found);
{ambiguous, Templates} ->
mnesia:abort({ambiguous_relationship, Templates});
{ok, Fwd} ->
Recip = Fwd#relationship.reciprocal,
Tmpl = template_of(Fwd),
case resolve_forward_connection(TargetNref, Recip, SourceNref,
Tmpl) of
{ok, Rev} ->
ok = mnesia:delete_object(relationships, Fwd, write),
ok = mnesia:delete_object(relationships, Rev, write);
_ ->
mnesia:abort({dangling_half_edge, Fwd#relationship.id})
end
end.

%%-----------------------------------------------------------------------------
%% remove_relationship(SourceNref, CharNref, TargetNref) -> ok | {error, term()}
%% remove_relationship(SourceNref, CharNref, TargetNref, TemplateNref)
%% -> ok | {error, term()}
%%
%% Tier-2 public API: deletes both directed rows of a logical connection edge
%% atomically. /3 ignores template (ambiguous if two templates match); /4
%% narrows by an explicit template. Plain functions owning one
%% graphdb_mgr:transaction/1 in the caller's process (no gen_server state).
%%-----------------------------------------------------------------------------
remove_relationship(SourceNref, CharNref, TargetNref) ->
txn_ok(fun() ->
remove_relationship_in_txn(SourceNref, CharNref, TargetNref, any)
end).

remove_relationship(SourceNref, CharNref, TargetNref, TemplateNref)
when is_integer(TemplateNref) ->
txn_ok(fun() ->
remove_relationship_in_txn(SourceNref, CharNref, TargetNref,
TemplateNref)
end).

%% Run an in-txn primitive in one transaction; normalise {ok, _} -> ok.
txn_ok(Fun) ->
case graphdb_mgr:transaction(Fun) of
{ok, _} -> ok;
{error, _} = Err -> Err
end.

%%-----------------------------------------------------------------------------
%% update_relationship_avps_in_txn(S, C, T, TemplateSpec, Updates) -> ok
%% (aborts the enclosing transaction on any failure)
%%
%% Tier-1 primitive: edits the AVPs of the SINGLE directed connection row named
%% by (S, C, T) (narrowed by TemplateSpec). Reuses slice B's pure
%% apply_avp_updates/2 (merge/upsert/delete). The ?ARC_TEMPLATE scope AVP is
%% protected -- any update targeting it aborts. Same not-found / ambiguity
%% arms as remove. The Template AVP at index 0 survives because no update may
%% reference it.
%%-----------------------------------------------------------------------------
update_relationship_avps_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec,
Updates) ->
case has_template_update(Updates) of
true ->
mnesia:abort({protected_relationship_avp, ?ARC_TEMPLATE});
false ->
case resolve_forward_connection(SourceNref, CharNref, TargetNref,
TemplateSpec) of
not_found ->
mnesia:abort(relationship_not_found);
{ambiguous, Templates} ->
mnesia:abort({ambiguous_relationship, Templates});
{ok, Row} ->
New = graphdb_mgr:apply_avp_updates(
Row#relationship.avps, Updates),
mnesia:write(relationships,
Row#relationship{avps = New}, write)
end
end.

%% True iff any update map targets the protected ?ARC_TEMPLATE scope AVP.
has_template_update(Updates) ->
lists:any(fun(#{attribute := A}) -> A =:= ?ARC_TEMPLATE end, Updates).

%%-----------------------------------------------------------------------------
%% update_relationship(S, C, T, Updates) -> ok | {error, term()}
%% update_relationship(S, C, T, TemplateNref, Updates) -> ok | {error, term()}
%%
%% Tier-2 public API: AVP-only edit of the single directed row named by
%% (S, C, T). Validates the update grammar client-side (slice B), then owns
%% one transaction.
%%-----------------------------------------------------------------------------
update_relationship(SourceNref, CharNref, TargetNref, Updates) ->
do_update_relationship(SourceNref, CharNref, TargetNref, any, Updates).

update_relationship(SourceNref, CharNref, TargetNref, TemplateNref, Updates)
when is_integer(TemplateNref) ->
do_update_relationship(SourceNref, CharNref, TargetNref, TemplateNref,
Updates).

do_update_relationship(SourceNref, CharNref, TargetNref, TemplateSpec,
Updates) ->
case graphdb_mgr:validate_avp_updates(Updates) of
ok ->
txn_ok(fun() ->
update_relationship_avps_in_txn(SourceNref, CharNref,
TargetNref, TemplateSpec, Updates)
end);
{error, _} = Err ->
Err
end.

%%-----------------------------------------------------------------------------
%% update_relationship_both_in_txn(S, C, T, TemplateSpec, FwdUpdates,
%% RevUpdates) -> ok (aborts the enclosing transaction on any failure)
%%
%% Tier-1 composite: resolves the forward row to discover the reciprocal label
%% and the concrete template, then edits both directed rows -- FwdUpdates on
%% (S, C, T), RevUpdates on (T, R, S) -- EACH through the single-edge primitive
%% (update_relationship_avps_in_txn/5). Reused by the tier-2 wrappers and by
%% graphdb_mgr:mutate/1. The two directions' updates are independent.
%%-----------------------------------------------------------------------------
update_relationship_both_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec,
FwdUpdates, RevUpdates) ->
case resolve_forward_connection(SourceNref, CharNref, TargetNref,
TemplateSpec) of
not_found ->
mnesia:abort(relationship_not_found);
{ambiguous, Templates} ->
mnesia:abort({ambiguous_relationship, Templates});
{ok, Fwd} ->
Recip = Fwd#relationship.reciprocal,
Tmpl = template_of(Fwd),
%% Confirm the symmetric partner exists before editing either row,
%% so a corrupt half-edge surfaces {dangling_half_edge, Id} (the
%% same arm as remove) rather than a misleading relationship_not_found
%% from the second single-edge edit.
case resolve_forward_connection(TargetNref, Recip, SourceNref,
Tmpl) of
{ok, _Rev} ->
ok = update_relationship_avps_in_txn(SourceNref, CharNref,
TargetNref, Tmpl, FwdUpdates),
ok = update_relationship_avps_in_txn(TargetNref, Recip,
SourceNref, Tmpl, RevUpdates);
_ ->
mnesia:abort({dangling_half_edge, Fwd#relationship.id})
end
end.

%%-----------------------------------------------------------------------------
%% update_relationship_both(S, C, T, {FwdUpdates, RevUpdates})
%% -> ok | {error, term()}
%% update_relationship_both(S, C, T, TemplateNref, {FwdUpdates, RevUpdates})
%% -> ok | {error, term()}
%%
%% Tier-2 convenience: edits both directions of one logical edge in a single
%% transaction. The two update lists are independent (forward need not mirror
%% reverse). Both lists are validated client-side (slice B grammar).
%%-----------------------------------------------------------------------------
update_relationship_both(SourceNref, CharNref, TargetNref, {Fwd, Rev}) ->
do_update_both(SourceNref, CharNref, TargetNref, any, Fwd, Rev).

update_relationship_both(SourceNref, CharNref, TargetNref, TemplateNref,
{Fwd, Rev}) when is_integer(TemplateNref) ->
do_update_both(SourceNref, CharNref, TargetNref, TemplateNref, Fwd, Rev).

do_update_both(SourceNref, CharNref, TargetNref, TemplateSpec, Fwd, Rev) ->
case {graphdb_mgr:validate_avp_updates(Fwd),
graphdb_mgr:validate_avp_updates(Rev)} of
{ok, ok} ->
txn_ok(fun() ->
update_relationship_both_in_txn(SourceNref, CharNref,
TargetNref, TemplateSpec, Fwd, Rev)
end);
{{error, _} = Err, _} -> Err;
{_, {error, _} = Err} -> Err
end.


%%-----------------------------------------------------------------------------
%% validate_arc_endpoints_in_txn(Source, Char, Target, Reciprocal, TkAttr,
%% RetAttr) -> ok (aborts the enclosing transaction on any violation)
Expand Down
Loading
Loading