From 9dc5bc3b4c6fe8e9a8398094c577ecf02fab6a4d Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 10:30:08 -0400 Subject: [PATCH 1/9] Slice E: relationship-mutation design + TASKS framing Connection-only remove_relationship / update_relationship (AVP-only), edge-level remove vs directed-row update, ambiguity contract; records the add_parent/add_child/remove_parent/remove_child compositional follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- TASKS.md | 46 ++- .../slice-e-relationship-mutation-design.md | 266 ++++++++++++++++++ 2 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 docs/designs/slice-e-relationship-mutation-design.md diff --git a/TASKS.md b/TASKS.md index f1da1b5..437171d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -326,13 +326,47 @@ node written. Design `docs/designs/slice-c-instance-only-qc-design.md`. ### Relationship mutation (slice E) +Design: `docs/designs/slice-e-relationship-mutation-design.md`. + 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. +update. Slice E adds, **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) diff --git a/docs/designs/slice-e-relationship-mutation-design.md b/docs/designs/slice-e-relationship-mutation-design.md new file mode 100644 index 0000000..7eccf76 --- /dev/null +++ b/docs/designs/slice-e-relationship-mutation-design.md @@ -0,0 +1,266 @@ + + +# Slice E — Relationship Mutation (`remove_relationship` / `update_relationship`) — Design + +## Goal + +Complete the relationship write-path. Today only `add_relationship` exists +— there is no way to remove a connection edge or to edit its per-direction +AVPs. Slice E adds: + +- `remove_relationship` — atomically delete both directed rows of a logical + connection edge. +- `update_relationship` — edit the per-direction AVP metadata of an existing + connection edge. + +Both are built on the write-path transaction seam and compose into +`graphdb_mgr:mutate/1`. + +## Scope + +**Connection arcs only** — the exact mirror of `add_relationship`, which +creates only `kind=connection` rows. This is the decisive scoping choice and +it moots all cache work: the `parents` (composition/taxonomy) and `classes` +(instantiation) caches never hold connection arcs, so removing or editing a +connection touches no cache. + +> **Correction to the prior TASKS.md note.** The earlier slice-E sketch said +> `remove_relationship` "fixes the `parents`/`classes` caches on the +> referrers" and "shares the arc-removal primitive with `delete_node` +> (slice A)." Both are aspirational, not current fact: slice A shipped as +> *soft-retire only*, so there is **no** hard-delete arc-removal primitive to +> share, and connection removal needs no cache fix. The cache-touching arc +> operations are `add_parent`/`add_child`/`remove_parent`/`remove_child` (compositional) — recorded as a +> separate follow-up, not part of this slice. + +**Out of scope (deferred):** + +- **Structural rewiring** — changing `characterization` / `target_nref` / + `reciprocal`. A rewire is semantically remove + re-add (full endpoint + + template-scope + `target_kind` re-validation), and once `remove_relationship` + and `add_relationship` both compose in `mutate/1`, a caller expresses it + atomically as `mutate([{remove_relationship, …}, {add_relationship, …}])`. + Building a dedicated structural-update path now is redundant. +- **A rel-id-keyed form** — the only thing that could disambiguate a genuine + duplicate edge (see the ambiguity contract). `add_relationship` does not + return rel-ids, so callers do not hold them; deferred as an escape hatch, + not a slice-E gap. +- **`add_parent` / `add_child` / `remove_parent` / `remove_child`** — + compositional-hierarchy arc creators and removers that *do* maintain the + `parents` cache. Recorded as a `TASKS.md` follow-up. + +## Background — how a logical edge is stored + +`add_relationship` writes **two** directed rows per logical edge, correlated +only by symmetry — there is no shared edge-id and no write-time dedup: + +``` +Forward row: source=S, characterization=C, target=T, reciprocal=R, avps=[Template | Fwd] +Reverse row: source=T, characterization=R, target=S, reciprocal=C, avps=[Template | Rev] +``` + +The Template AVP (`?ARC_TEMPLATE`, index 0 of each row's `avps`) records the +connection's scope. Each direction carries its own independent AVP list. + +Because nothing dedups at write time, **duplicate logical edges can exist**: +two `add_relationship` calls with identical arguments, or a B4 rule-fired +connection colliding with a manual one, both produce more than one logical +edge sharing the same `(S, C, T, Template)`. + +## Edge identity and the ambiguity contract + +A caller names an edge by the directed-row key `(S, C, T)`, optionally +narrowed by `Template`. Because duplicates are possible, **ambiguity means +"the supplied key matches more than one logical edge,"** at whatever +specificity was given: + +| Form | Matches forward rows by… | 0 rows | exactly 1 | > 1 rows | +|---------------------------------------|------------------------------------|------------------------------------|----------------------|------------------------------------------------| +| `remove_relationship/3 (S,C,T)` | source / char / target | `{error, relationship_not_found}` | remove the pair | `{error, {ambiguous_relationship, Templates}}` | +| `remove_relationship/4 (S,C,T,Tmpl)` | + Template AVP | `{error, relationship_not_found}` | remove the pair | `{error, {ambiguous_relationship, [Tmpl]}}` (true duplicate) | + +The ambiguity error carries the matching templates, so a `/3` caller learns +which `Template` value to pass to re-issue as `/4`. A `/4` collision is a +genuine duplicate edge: only the deferred rel-id form could distinguish them. +`update_relationship` carries the identical not-found / ambiguity contract. + +## The core asymmetry: remove is edge-level, update is row-level + +The same `(S, C, T)` key means different things to the two operations, by +design: + +- **`remove_relationship(S, C, T)` deletes both directed rows.** A half-edge + (one direction without its reciprocal) is an invalid state, so removal + always operates at logical-edge granularity. +- **`update_relationship(S, C, T, Updates)` edits exactly one directed row** + — the row whose `(source, characterization, target) = (S, C, T)`. To edit + the reverse direction, name it from the other endpoint: + `update_relationship(T, R, S, Updates)`. + +This is the one genuine cognitive hazard in the slice; it is intentional and +stated loudly here so it is reviewed, not discovered in code. + +## `remove_relationship` + +Public arities (homed in `graphdb_instance`, alongside `add_relationship`): + +| Arity | Form | +|------------------------------------|-----------------------------------| +| `remove_relationship/3` | `(SourceNref, CharNref, TargetNref)` | +| `remove_relationship/4` | `(SourceNref, CharNref, TargetNref, TemplateNref)` | + +Behaviour: resolve the forward row(s) per the ambiguity contract; from the +single forward row derive `R` and `Template`; locate the symmetric partner +`(T, R, S, Template)`; delete both rows. If the forward row exists but its +partner does not, that is an integrity violation — abort a **distinct** +reason (`{dangling_half_edge, …}`), never silently delete a half-edge. + +## `update_relationship` — AVP-only edit + +Edits the per-direction AVP metadata of an existing connection edge, reusing +slice B's exported, pure helpers unchanged: + +- `graphdb_mgr:validate_avp_updates/1` — client-side well-formedness (each + update map's key-set is exactly `[attribute]` (delete) or + `[attribute, value]` (upsert)). +- `graphdb_mgr:apply_avp_updates/2` — merge/upsert/delete against an existing + AVP list. + +**The Template AVP (`?ARC_TEMPLATE`) is protected.** Any update — upsert or +delete — targeting it aborts (`{protected_relationship_avp, ?ARC_TEMPLATE}`), +mirroring slice B's retired-marker guard. Changing scope is a structural +rewire, not metadata. + +### Single-direction forms (two arities, mirroring `remove`) + +| Arity | Form | +|-----------------------------|---------------------------------------------------| +| `update_relationship/4` | `(SourceNref, CharNref, TargetNref, Updates)` | +| `update_relationship/5` | `(SourceNref, CharNref, TargetNref, TemplateNref, Updates)` | + +Each edits the **single** directed row named by `(S, C, T)`, with the same +not-found / ambiguity arms as `remove`. + +### Bidirectional convenience forms (`*_both`) + +| Arity | Form | +|--------------------------------|-----------------------------------------------------------| +| `update_relationship_both/4` | `(SourceNref, CharNref, TargetNref, {FwdUpdates, RevUpdates})` | +| `update_relationship_both/5` | `(SourceNref, CharNref, TargetNref, TemplateNref, {FwdUpdates, RevUpdates})` | + +`*_both` resolves the edge **pair** by symmetry (the same `(T, R, S)` +partner-finding as `remove`, same ambiguity / not-found / dangling-half-edge +arms), then applies `FwdUpdates` to the forward row and `RevUpdates` to the +reverse row — **each through the one single-edge in-transaction primitive.** + +### Why two independent update lists + +The two directions' AVPs are **independent** — a forward edit need not be +mirrored on the reverse. Real callers update one side without touching the +other, or update each side differently. This is exactly why the +single-direction form is the primitive and why `*_both` takes **two +separate** update lists `{Fwd, Rev}` rather than one shared list. + +## Tier structure (write-path seam) + +There is exactly **one** tier-1 update primitive — single directed row, no +in-transaction variants. `*_both` is pure composition above it. + +### Tier 1 — in-transaction primitives (`graphdb_instance`, exported, `_in_txn`) + +Both are allocation-free and state-free: no `gen_server` call inside the +transaction (cleaner than `add_relationship`, and correct against the +load-bearing "never call a gen_server inside an Mnesia activity" invariant). + +- `remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec)` + — resolve forward row(s) (`relationship_not_found` / + `{ambiguous_relationship, …}`), locate the symmetric partner + (`{dangling_half_edge, …}` on a missing partner), delete both rows with + bare `mnesia:delete_object/3`. `TemplateSpec` is a template nref (the `/4` + path) or `any` (the `/3` path). +- `update_relationship_avps_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, Updates)` + — resolve the single directed row (same not-found / ambiguity arms), reject + any update targeting `?ARC_TEMPLATE`, apply `apply_avp_updates/2` to the + row's `avps` (preserving the Template AVP at index 0), write it back. + +A shared private helper resolves a forward row from `(S, C, T, TemplateSpec)` +and classifies none / one / many — used by every public form. + +### Tier 2 — single-op public API (`graphdb_instance`) + +Plain exported functions (not `gen_server:call`s), each owning one +`graphdb_mgr:transaction/1` in the caller's process: + +- `remove_relationship/3,4` +- `update_relationship/4,5` +- `update_relationship_both/4,5` (compose two `update_relationship_avps_in_txn` + calls in one transaction) + +### Tier 3 — batch (`graphdb_mgr:mutate/1`) + +The mutation grammar gains, composing the tier-1 primitives directly: + +```erlang +{remove_relationship, S, C, T} +{remove_relationship, S, C, T, Template} +{update_relationship, S, C, T, Updates} +{update_relationship, S, C, T, Template, Updates} +{update_relationship_both, S, C, T, {Fwd, Rev}} +{update_relationship_both, S, C, T, Template, {Fwd, Rev}} +``` + +Whole-batch rollback and the opaque bare-reason contract are unchanged. + +## Reuse note for a future `delete_node` hard-delete + +The connection-only row-pair-deletion core (`remove_relationship_in_txn`) is +exactly what a future `delete_node` hard-delete would reuse when tearing down +a node's connection arcs. This slice deliberately does **not** build the +kind-agnostic / cache-fixing machinery that a general arc remover would need +— that belongs to the hard-delete work, against the connection-only scope set +here. + +## Deferred work to record in `TASKS.md` + +1. **Structural relationship rewiring** — `characterization` / `target_nref` + / `reciprocal` change; expressible today as `mutate([remove, add])`. +2. **Rel-id-keyed remove/update** — the only disambiguator for genuine + duplicate edges. +3. **`add_parent` / `add_child` / `remove_parent` / `remove_child`** — + compositional-hierarchy arc creators and removers (kind=composition, + part-of) that maintain the `parents` cache. Distinct from slice E's + connection-only, cache-free scope; the cache-maintenance counterpart to a + future connection-arc remover. + +## Testing + +**EUnit (pure):** + +- Forward-row classification: none → `relationship_not_found`; one → the row; + many → `{ambiguous_relationship, Templates}` (templates carried). +- Symmetric-partner derivation from a forward row (`(T, R, S, Template)`). +- Template-AVP rejection: an update map targeting `?ARC_TEMPLATE` (upsert or + delete) → reject; a non-template update → accept. +- `*_both` decomposition: `{Fwd, Rev}` routes `Fwd` to the forward row and + `Rev` to the reverse row, each via the single-edge primitive. + +**CT (integration):** + +- `remove_relationship/3,4` happy path (both rows gone, reverse traversal + empty); not-found; duplicate-edge ambiguity (carrying templates); `/4` + disambiguation after a `/3` ambiguity. +- `dangling_half_edge` integrity abort (forward present, partner manually + removed) — no half-edge deleted, transaction rolled back. +- `update_relationship/4,5` single-direction edit (forward changed, reverse + untouched — proving independence); reverse-direction edit via `(T, R, S)`; + `?ARC_TEMPLATE` protection; not-found / ambiguity arms. +- `update_relationship_both/4,5` editing both directions with **different** + `{Fwd, Rev}` lists in one atomic call. +- `mutate/1` composition: a batch mixing `remove_relationship`, + `update_relationship`, and `update_relationship_both` commits atomically; a + failing entry rolls the whole batch back. +- `verify_caches/0` clean in `end_per_testcase` (connection mutation must + leave caches untouched), as every suite already does. From 3f7529febeb036fb12b7996f7355375033f2a3dd Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 10:30:09 -0400 Subject: [PATCH 2/9] Slice E: relationship-mutation implementation plan Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- ...026-06-28-slice-e-relationship-mutation.md | 801 ++++++++++++++++++ 1 file changed, 801 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-slice-e-relationship-mutation.md diff --git a/docs/superpowers/plans/2026-06-28-slice-e-relationship-mutation.md b/docs/superpowers/plans/2026-06-28-slice-e-relationship-mutation.md new file mode 100644 index 0000000..431f770 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-slice-e-relationship-mutation.md @@ -0,0 +1,801 @@ +# Slice E — Relationship Mutation 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:** Add `remove_relationship` and `update_relationship` (AVP-only) to the connection-arc write-path, composing into `mutate/1`. + +**Architecture:** Connection-arcs only — the exact mirror of `add_relationship`, so no `parents`/`classes` cache work. New tier-1 in-transaction primitives in `graphdb_instance` (allocation-free, state-free), tier-2 public wrappers as plain functions owning one `graphdb_mgr:transaction/1`, and three new `graphdb_mgr:mutate/1` grammar kinds. Reuses slice B's exported `validate_avp_updates/1` + `apply_avp_updates/2` unchanged. + +**Tech Stack:** Erlang/OTP 28.5, Mnesia, rebar3 3.27 (repo-local `./rebar3`), Common Test + EUnit. + +## Global Constraints + +- **Source uses HARD TABS** for indentation — every `.erl` edit must use tabs, never spaces. +- **Module header / NYI-UEM macros / explicit `-export`** convention is preserved (see `CLAUDE.md`); never `-compile(export_all)`. +- **Write-path transaction seam** (3-tier): tier-1 `_in_txn` primitives use bare Mnesia, signal failure via `mnesia:abort/1`, never open their own transaction; tier-2 owns one `graphdb_mgr:transaction/1`; tier-3 (`mutate/1`) composes tier-1 directly, never tier-2. +- **LOAD-BEARING INVARIANT:** never call a `gen_server` (incl. `rel_id_server`, `graphdb_attr`, `graphdb_class`) inside an Mnesia transaction fun. (These primitives need no such calls — they are allocation-free.) +- **Connection-arcs only.** Do NOT touch `parents`/`classes` caches; do NOT build kind-agnostic arc removal. +- **`?ARC_TEMPLATE`** (nref 31) is the protected scope AVP at index 0 of each connection row's `avps`; it must never be edited or deleted through `update_relationship`. +- Invoke rebar3 as plain `./rebar3 ...` (kerl PATH is preset). +- Run `graphdb_mgr:verify_caches/0` in `end_per_testcase` (the suite already does); it must stay `ok` — connection mutation leaves caches untouched. +- Design reference: `docs/designs/slice-e-relationship-mutation-design.md`. + +--- + +### Task 1: `remove_relationship` (tier-1 primitive + shared resolver + tier-2) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` (add exports + functions near `add_relationship`/`add_relationship_in_txn`, ~line 130 exports, ~line 1236 primitives) +- Test: `apps/graphdb/test/graphdb_instance_SUITE.erl` + +**Interfaces:** +- Consumes: `find_avp_value/2` (graphdb_instance, already exported), `graphdb_mgr:transaction/1`, `?ARC_TEMPLATE` (graphdb_nrefs.hrl), `#relationship{}` record. +- Produces: + - `resolve_forward_connection(S, C, T, TemplateSpec) -> {ok, #relationship{}} | not_found | {ambiguous, [TemplateNref]}` — in-txn, `TemplateSpec :: any | integer()`. + - `template_of(#relationship{}) -> integer() | undefined`. + - `remove_relationship_in_txn(S, C, T, TemplateSpec) -> ok` (aborts on failure). + - `remove_relationship/3 (S,C,T) -> ok | {error, Reason}`; `remove_relationship/4 (S,C,T,TemplateNref) -> ok | {error, Reason}`. + +- [ ] **Step 1: Write the failing CT cases** + +Add to the exported test list and the `all/0`/groups list (mirroring the `add_relationship_*` entries near lines 74 and 221), then add the bodies. Place a small helper at the end of the suite if not already present. + +```erlang +%% --- add to -export list --- + remove_relationship_basic/1, + remove_relationship_not_found/1, + remove_relationship_ambiguous/1, + remove_relationship_disambiguate_by_template/1, + remove_relationship_dangling_half_edge/1, + +%% --- test bodies --- + +%% Setup helper: class, default template, two instances, a reciprocal +%% arc-label pair, and one connection edge A--Char-->B. Returns the nrefs. +re_setup() -> + {ok, ClassNref} = graphdb_class:create_class("Org", 3), + {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), + {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), + #{class => ClassNref, tmpl => DefaultTmpl, a => A, b => B, + char => Char, recip => Recip}. + +%% count forward connection rows A--Char-->B +re_count(A, Char, B) -> + {atomic, Rows} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + length([R || R <- Rows, + R#relationship.kind =:= connection, + R#relationship.characterization =:= Char, + R#relationship.target_nref =:= B]). + +remove_relationship_basic(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ?assertEqual(1, re_count(A, Char, B)), + ?assertEqual(1, re_count(B, Recip, A)), + ok = graphdb_instance:remove_relationship(A, Char, B), + ?assertEqual(0, re_count(A, Char, B)), + ?assertEqual(0, re_count(B, Recip, A)). + +remove_relationship_not_found(_Config) -> + #{a := A, b := B, char := Char} = re_setup(), + ?assertEqual({error, relationship_not_found}, + graphdb_instance:remove_relationship(A, Char, B)). + +remove_relationship_ambiguous(_Config) -> + #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), + {ok, DefaultTmpl} = graphdb_class:default_template(Class), + {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, AltTmpl), + ?assertMatch({error, {ambiguous_relationship, [_, _]}}, + graphdb_instance:remove_relationship(A, Char, B)). + +remove_relationship_disambiguate_by_template(_Config) -> + #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), + {ok, DefaultTmpl} = graphdb_class:default_template(Class), + {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:remove_relationship(A, Char, B, DefaultTmpl), + %% one edge (the AltTmpl one) remains in each direction + ?assertEqual(1, re_count(A, Char, B)), + ?assertEqual(1, re_count(B, Recip, A)). + +remove_relationship_dangling_half_edge(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + %% manually delete the reverse row, leaving a half-edge + {atomic, ok} = mnesia:transaction(fun() -> + Rows = mnesia:index_read(relationships, B, #relationship.source_nref), + [Rev] = [R || R <- Rows, + R#relationship.characterization =:= Recip, + R#relationship.target_nref =:= A], + mnesia:delete_object(relationships, Rev, write) + end), + ?assertMatch({error, {dangling_half_edge, _}}, + graphdb_instance:remove_relationship(A, Char, B)), + %% the forward row is NOT deleted — rollback left it intact + ?assertEqual(1, re_count(A, Char, B)). +``` + +- [ ] **Step 2: Run the cases to verify they fail** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_instance_SUITE --case=remove_relationship_basic` +Expected: FAIL — `remove_relationship/3` undefined. + +- [ ] **Step 3: Add exports** + +In the `-export([...])` block of `graphdb_instance.erl` (near line 118-130), add: + +```erlang + remove_relationship/3, + remove_relationship/4, + remove_relationship_in_txn/4, + resolve_forward_connection/4, +``` + +- [ ] **Step 4: Implement the shared resolver, `template_of`, the tier-1 primitive, and the tier-2 wrappers** + +Add after `add_relationship_in_txn/9` (after ~line 1236), using HARD TABS: + +```erlang +%%----------------------------------------------------------------------------- +%% 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 at index 0 of 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. +``` + +- [ ] **Step 5: Compile and run the cases to verify they pass** + +Run: `./rebar3 compile && ./rebar3 ct --suite=apps/graphdb/test/graphdb_instance_SUITE --group=` +(or per-case `--case=remove_relationship_basic` etc.) +Expected: PASS, 0 warnings. + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl apps/graphdb/test/graphdb_instance_SUITE.erl +git commit -m "Slice E: remove_relationship (tier-1 primitive + tier-2)" +``` + +--- + +### Task 2: `update_relationship` single-direction (tier-1 primitive + tier-2) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` +- Test: `apps/graphdb/test/graphdb_instance_SUITE.erl` (CT) and `apps/graphdb/test/graphdb_instance_tests.erl` (EUnit, pure) + +**Interfaces:** +- Consumes: `resolve_forward_connection/4`, `template_of/1`, `txn_ok/1` (Task 1); `graphdb_mgr:validate_avp_updates/1`, `graphdb_mgr:apply_avp_updates/2`; `?ARC_TEMPLATE`. +- Produces: + - `has_template_update([map()]) -> boolean()` (pure; exported for EUnit). + - `update_relationship_avps_in_txn(S, C, T, TemplateSpec, Updates) -> ok` (aborts on failure). + - `update_relationship/4 (S,C,T,Updates) -> ok | {error, Reason}`; `update_relationship/5 (S,C,T,TemplateNref,Updates) -> ok | {error, Reason}`. + +- [ ] **Step 1: Write the failing pure EUnit test** + +In `apps/graphdb/test/graphdb_instance_tests.erl` (add `-include_lib("graphdb/include/graphdb_nrefs.hrl").` if absent): + +```erlang +has_template_update_true_test() -> + ?assert(graphdb_instance:has_template_update( + [#{attribute => ?ARC_TEMPLATE, value => 7}])). + +has_template_update_false_test() -> + ?assertNot(graphdb_instance:has_template_update( + [#{attribute => 9999, value => "x"}, #{attribute => 8888}])). +``` + +- [ ] **Step 2: Write the failing CT cases** + +Add to the `-export` list and `all/0`, then bodies (reuse `re_setup/0`, `re_count/3` from Task 1): + +```erlang +%% --- exports --- + update_relationship_single_direction/1, + update_relationship_reverse_direction/1, + update_relationship_protects_template/1, + update_relationship_not_found/1, + +%% helper: fetch the single forward row's avps +re_avps(A, Char, B) -> + {atomic, Rows} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + [R] = [X || X <- Rows, + X#relationship.kind =:= connection, + X#relationship.characterization =:= Char, + X#relationship.target_nref =:= B], + R#relationship.avps. + +update_relationship_single_direction(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + {ok, Note} = graphdb_attr:create_literal_attribute("note", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:update_relationship(A, Char, B, + [#{attribute => Note, value => "fwd"}]), + ?assert(lists:member(#{attribute => Note, value => "fwd"}, + re_avps(A, Char, B))), + %% reverse row untouched (proves independence) + ?assertNot(lists:member(#{attribute => Note, value => "fwd"}, + re_avps(B, Recip, A))). + +update_relationship_reverse_direction(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + {ok, Note} = graphdb_attr:create_literal_attribute("note", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + %% name the reverse direction from the other endpoint: (T, R, S) + ok = graphdb_instance:update_relationship(B, Recip, A, + [#{attribute => Note, value => "rev"}]), + ?assert(lists:member(#{attribute => Note, value => "rev"}, + re_avps(B, Recip, A))), + ?assertNot(lists:member(#{attribute => Note, value => "rev"}, + re_avps(A, Char, B))). + +update_relationship_protects_template(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ?assertEqual({error, {protected_relationship_avp, ?ARC_TEMPLATE}}, + graphdb_instance:update_relationship(A, Char, B, + [#{attribute => ?ARC_TEMPLATE, value => 7}])). + +update_relationship_not_found(_Config) -> + #{a := A, b := B, char := Char} = re_setup(), + {ok, Note} = graphdb_attr:create_literal_attribute("note", string), + ?assertEqual({error, relationship_not_found}, + graphdb_instance:update_relationship(A, Char, B, + [#{attribute => Note, value => "x"}])). +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `./rebar3 eunit --module=graphdb_instance_tests` and `./rebar3 ct --suite=apps/graphdb/test/graphdb_instance_SUITE --case=update_relationship_single_direction` +Expected: FAIL — functions undefined. + +- [ ] **Step 4: Add exports** + +```erlang + update_relationship/4, + update_relationship/5, + update_relationship_avps_in_txn/5, + has_template_update/1, +``` + +- [ ] **Step 5: Implement the primitive and tier-2 wrappers** + +Add after `remove_relationship_in_txn/4`, HARD TABS: + +```erlang +%%----------------------------------------------------------------------------- +%% 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. +``` + +- [ ] **Step 6: Compile and run the tests to verify they pass** + +Run: `./rebar3 compile && ./rebar3 eunit --module=graphdb_instance_tests && ./rebar3 ct --suite=apps/graphdb/test/graphdb_instance_SUITE --case=update_relationship_single_direction` +Expected: PASS, 0 warnings. + +- [ ] **Step 7: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl apps/graphdb/test/graphdb_instance_SUITE.erl apps/graphdb/test/graphdb_instance_tests.erl +git commit -m "Slice E: update_relationship single-direction AVP edit" +``` + +--- + +### Task 3: `update_relationship_both` (in-txn composite + tier-2) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` +- Test: `apps/graphdb/test/graphdb_instance_SUITE.erl` + +**Interfaces:** +- Consumes: `resolve_forward_connection/4`, `template_of/1`, `update_relationship_avps_in_txn/5`, `txn_ok/1`, `graphdb_mgr:validate_avp_updates/1`. +- Produces: + - `update_relationship_both_in_txn(S, C, T, TemplateSpec, FwdUpdates, RevUpdates) -> ok` (aborts on failure) — the single source of the bidirectional composition, reused by tier-3. + - `update_relationship_both/4 (S,C,T,{Fwd,Rev}) -> ok | {error, Reason}`; `update_relationship_both/5 (S,C,T,TemplateNref,{Fwd,Rev}) -> ok | {error, Reason}`. + +- [ ] **Step 1: Write the failing CT case** + +```erlang +%% --- exports --- + update_relationship_both_directions/1, + +update_relationship_both_directions(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + {ok, FAttr} = graphdb_attr:create_literal_attribute("fwd_meta", string), + {ok, RAttr} = graphdb_attr:create_literal_attribute("rev_meta", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:update_relationship_both(A, Char, B, + {[#{attribute => FAttr, value => "F"}], + [#{attribute => RAttr, value => "R"}]}), + FwdAVPs = re_avps(A, Char, B), + RevAVPs = re_avps(B, Recip, A), + ?assert(lists:member(#{attribute => FAttr, value => "F"}, FwdAVPs)), + ?assertNot(lists:member(#{attribute => RAttr, value => "R"}, FwdAVPs)), + ?assert(lists:member(#{attribute => RAttr, value => "R"}, RevAVPs)), + ?assertNot(lists:member(#{attribute => FAttr, value => "F"}, RevAVPs)). +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_instance_SUITE --case=update_relationship_both_directions` +Expected: FAIL — `update_relationship_both/4` undefined. + +- [ ] **Step 3: Add exports** + +```erlang + update_relationship_both/4, + update_relationship_both/5, + update_relationship_both_in_txn/6, +``` + +- [ ] **Step 4: Implement the composite and tier-2 wrappers** + +Add after `do_update_relationship/5`, HARD TABS: + +```erlang +%%----------------------------------------------------------------------------- +%% 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), + ok = update_relationship_avps_in_txn(SourceNref, CharNref, + TargetNref, Tmpl, FwdUpdates), + ok = update_relationship_avps_in_txn(TargetNref, Recip, + SourceNref, Tmpl, RevUpdates) + 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. +``` + +- [ ] **Step 5: Compile and run to verify it passes** + +Run: `./rebar3 compile && ./rebar3 ct --suite=apps/graphdb/test/graphdb_instance_SUITE --case=update_relationship_both_directions` +Expected: PASS, 0 warnings. + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl apps/graphdb/test/graphdb_instance_SUITE.erl +git commit -m "Slice E: update_relationship_both bidirectional convenience" +``` + +--- + +### Task 4: `mutate/1` grammar — remove/update/update_both kinds + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` (`validate_mutation/1` ~line 366-382, `prepare/1` ~line 408-422, `dispatch/3` ~line 427-436, and the `mutate/1` doc comment ~line 323-329) +- Test: `apps/graphdb/test/graphdb_mgr_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_instance:remove_relationship_in_txn/4`, `update_relationship_avps_in_txn/5`, `update_relationship_both_in_txn/6`; `validate_avp_updates/1` (graphdb_mgr, in scope). +- Produces: three new `mutate/1` grammar kinds (each with/without template): + - `{remove_relationship, S, C, T}` / `{remove_relationship, S, C, T, Template}` + - `{update_relationship, S, C, T, Updates}` / `{update_relationship, S, C, T, Template, Updates}` + - `{update_relationship_both, S, C, T, {Fwd, Rev}}` / `{update_relationship_both, S, C, T, Template, {Fwd, Rev}}` + +- [ ] **Step 1: Write the failing CT cases** + +Add to `graphdb_mgr_SUITE.erl` (mirror the existing `mutate_*` cases' setup — they create instances + a reciprocal pair like the instance suite): + +```erlang +%% --- exports --- + mutate_remove_relationship/1, + mutate_update_relationship/1, + mutate_mixed_rollback/1, + +mutate_remove_relationship(_Config) -> + {ok, Class} = graphdb_class:create_class("Org", 3), + {ok, A, _} = graphdb_instance:create_instance("A", Class, 5), + {ok, B, _} = graphdb_instance:create_instance("B", Class, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + {ok, [ok]} = graphdb_mgr:mutate([{remove_relationship, A, Char, B}]), + {atomic, Rows} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + ?assertEqual([], [R || R <- Rows, + R#relationship.kind =:= connection, + R#relationship.characterization =:= Char, + R#relationship.target_nref =:= B]), + ok = graphdb_mgr:verify_caches(). + +mutate_update_relationship(_Config) -> + {ok, Class} = graphdb_class:create_class("Org", 3), + {ok, A, _} = graphdb_instance:create_instance("A", Class, 5), + {ok, B, _} = graphdb_instance:create_instance("B", Class, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), + {ok, Note} = graphdb_attr:create_literal_attribute("note", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + {ok, [ok, ok]} = graphdb_mgr:mutate([ + {update_relationship, A, Char, B, [#{attribute => Note, value => "f"}]}, + {update_relationship_both, A, Char, B, + {[#{attribute => Note, value => "F"}], + [#{attribute => Note, value => "R"}]}}]), + ok = graphdb_mgr:verify_caches(). + +mutate_mixed_rollback(_Config) -> + {ok, Class} = graphdb_class:create_class("Org", 3), + {ok, A, _} = graphdb_instance:create_instance("A", Class, 5), + {ok, B, _} = graphdb_instance:create_instance("B", Class, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + %% second mutation removes a non-existent edge -> whole batch rolls back + {error, relationship_not_found} = graphdb_mgr:mutate([ + {remove_relationship, A, Char, B}, + {remove_relationship, A, Char, B}]), + {atomic, Rows} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + %% the first remove was rolled back -- the edge is still present + ?assertEqual(1, length([R || R <- Rows, + R#relationship.kind =:= connection, + R#relationship.characterization =:= Char, + R#relationship.target_nref =:= B])), + ok = graphdb_mgr:verify_caches(). +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_mgr_SUITE --case=mutate_remove_relationship` +Expected: FAIL — `mutate` returns `{error, {bad_mutation, ...}}`. + +- [ ] **Step 3: Add `validate_mutation/1` clauses** + +Insert BEFORE the catch-all `validate_mutation(M) -> {error, {bad_mutation, M}}` clause (~line 381), HARD TABS: + +```erlang +validate_mutation({remove_relationship, _S, _C, _T}) -> + ok; +validate_mutation({remove_relationship, _S, _C, _T, _Template}) -> + ok; +validate_mutation({update_relationship, _S, _C, _T, Updates}) -> + validate_avp_updates(Updates); +validate_mutation({update_relationship, _S, _C, _T, _Template, Updates}) -> + validate_avp_updates(Updates); +validate_mutation({update_relationship_both, _S, _C, _T, {Fwd, Rev}}) -> + validate_both_avp_updates(Fwd, Rev); +validate_mutation({update_relationship_both, _S, _C, _T, _Template, + {Fwd, Rev}}) -> + validate_both_avp_updates(Fwd, Rev); +``` + +And add the helper near `tier_guard/1`: + +```erlang +validate_both_avp_updates(Fwd, Rev) -> + case validate_avp_updates(Fwd) of + ok -> validate_avp_updates(Rev); + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 4: Add `prepare/1` clauses** + +These mutations need no resources (no rel-id allocation, no seeded nrefs). Insert before the end of the `prepare/1` clauses (~line 422): + +```erlang +prepare({remove_relationship, _S, _C, _T} = M) -> + M; +prepare({remove_relationship, _S, _C, _T, _Template} = M) -> + M; +prepare({update_relationship, _S, _C, _T, _U} = M) -> + M; +prepare({update_relationship, _S, _C, _T, _Template, _U} = M) -> + M; +prepare({update_relationship_both, _S, _C, _T, _Pair} = M) -> + M; +prepare({update_relationship_both, _S, _C, _T, _Template, _Pair} = M) -> + M; +``` + +- [ ] **Step 5: Add `dispatch/3` clauses** + +Insert before the end of the `dispatch/3` clauses (~line 436), HARD TABS: + +```erlang +dispatch({remove_relationship, S, C, T}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(S, C, T, any); +dispatch({remove_relationship, S, C, T, Template}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(S, C, T, Template); +dispatch({update_relationship, S, C, T, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(S, C, T, any, U); +dispatch({update_relationship, S, C, T, Template, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(S, C, T, Template, U); +dispatch({update_relationship_both, S, C, T, {Fwd, Rev}}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_both_in_txn(S, C, T, any, Fwd, Rev); +dispatch({update_relationship_both, S, C, T, Template, {Fwd, Rev}}, _TkAttr, + _RetAttr) -> + graphdb_instance:update_relationship_both_in_txn(S, C, T, Template, Fwd, + Rev); +``` + +- [ ] **Step 6: Update the `mutate/1` doc comment** + +In the grammar list (~line 323-329), add the three kinds so the comment stays the authoritative grammar reference: + +```erlang +%% {remove_relationship, S, C, T} remove edge (any template) +%% {remove_relationship, S, C, T, Template} remove edge (explicit template) +%% {update_relationship, S, C, T, Updates} edit one direction's AVPs +%% {update_relationship, S, C, T, Template, Updates} + explicit template +%% {update_relationship_both, S, C, T, {Fwd, Rev}} edit both directions' AVPs +%% {update_relationship_both, S, C, T, Template, {Fwd, Rev}} + explicit template +``` + +- [ ] **Step 7: Compile and run the cases to verify they pass** + +Run: `./rebar3 compile && ./rebar3 ct --suite=apps/graphdb/test/graphdb_mgr_SUITE --case=mutate_remove_relationship` +(repeat for `mutate_update_relationship`, `mutate_mixed_rollback`) +Expected: PASS, 0 warnings. + +- [ ] **Step 8: Commit** + +```bash +git add apps/graphdb/src/graphdb_mgr.erl apps/graphdb/test/graphdb_mgr_SUITE.erl +git commit -m "Slice E: mutate/1 grammar for remove/update relationship" +``` + +--- + +### Task 5: Full suite, docs, and TASKS.md + +**Files:** +- Modify: `docs/Architecture.md` (relationship write-path API), `apps/graphdb/CLAUDE.md` (`graphdb_instance` + `graphdb_mgr` worker tables), `TASKS.md` (mark slice E IMPLEMENTED, keep the deferred + `add_parent`/`add_child`/`remove_parent`/`remove_child` follow-up). +- (The design doc and the TASKS.md slice-E rewrite from brainstorming are already committed-or-staged; this task lands the doc updates that reflect shipped code.) + +- [ ] **Step 1: Run the full suite** + +Run: `make test-ct-parallel && ./rebar3 eunit` +Expected: all green (prior 488 CT + 133 EUnit, plus the ~14 new CT + 2 new EUnit cases), 0 compile warnings. + +- [ ] **Step 2: Update `docs/Architecture.md`** + +In the relationship/write-path section, document that `graphdb_instance` now exposes `remove_relationship/3,4`, `update_relationship/4,5`, `update_relationship_both/4,5` (connection-arcs only; AVP edits are per-directed-row; remove is logical-edge-level), and that `graphdb_mgr:mutate/1` carries the three new kinds. Keep it at architectural altitude. + +- [ ] **Step 3: Update `apps/graphdb/CLAUDE.md`** + +In the `graphdb_instance` bullet list add the three public APIs with one-line contracts (mirroring the `add_relationship` bullet's style). In the `graphdb_mgr` `mutate/1` bullet, extend the grammar list with the three new kinds. + +- [ ] **Step 4: Update `TASKS.md`** + +Mark the slice E section IMPLEMENTED (point at the design + this plan, summarise the shipped API and the edge-level/row-level contract), and leave the `add_parent` / `add_child` / `remove_parent` / `remove_child` follow-up and the structural-rewiring / rel-id-keyed deferrals in place. + +- [ ] **Step 5: Commit** + +```bash +git add docs/Architecture.md apps/graphdb/CLAUDE.md TASKS.md +git commit -m "Slice E: docs + TASKS for relationship mutation" +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task | +|------------------------------------------------|------| +| Connection-only scope, no cache work | All (CT asserts `verify_caches/0` clean) | +| Edge identity + ambiguity contract (`/3`,`/4`) | Task 1 (`resolve_forward_connection/4`, ambiguity + disambiguate cases) | +| remove = both rows; dangling-half-edge abort | Task 1 | +| update = one directed row; reverse via `(T,R,S)`| Task 2 | +| `?ARC_TEMPLATE` protected | Task 2 (`has_template_update/1`, CT + EUnit) | +| not-found / ambiguity arms on update | Task 2 | +| `*_both`, two independent `{Fwd,Rev}` lists, one primitive | Task 3 | +| `mutate/1` three new kinds + rollback | Task 4 | +| reuse slice-B `validate_avp_updates/1` + `apply_avp_updates/2` | Tasks 2-4 | +| docs / TASKS / follow-ups | Task 5 | + +Deferred items (structural rewiring, rel-id-keyed form, `add_parent`/`add_child`/`remove_parent`/`remove_child`) are intentionally **not** tasks — they are recorded in `TASKS.md`. + +**Placeholder scan:** none — every code/test step shows complete code and exact commands. + +**Type consistency:** `resolve_forward_connection/4` returns `{ok, #relationship{}} | not_found | {ambiguous, [integer()]}` and is consumed identically in Tasks 1-3; `template_of/1`, `txn_ok/1`, `has_template_update/1`, the three `_in_txn` primitives, and the public arities are named consistently across producing/consuming tasks and the `dispatch/3` clauses. From 65ed2dbc97c59f9e037a44728c77dd20b2693e64 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 10:37:59 -0400 Subject: [PATCH 3/9] Slice E: remove_relationship (tier-1 primitive + tier-2) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_instance.erl | 102 +++++++++++++++++++ apps/graphdb/test/graphdb_instance_SUITE.erl | 90 +++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index 4fd4bf2..23df9b5 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -128,6 +128,10 @@ 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, %% Lookups get_instance/1, children/1, @@ -1236,6 +1240,104 @@ 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. + + %%----------------------------------------------------------------------------- %% validate_arc_endpoints_in_txn(Source, Char, Target, Reciprocal, TkAttr, %% RetAttr) -> ok (aborts the enclosing transaction on any violation) diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index 68f8f3d..b9a0a7d 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -92,6 +92,12 @@ add_relationship_default_avps_empty/1, add_relationship_refuses_retired_endpoint/1, class_of_returns_class/1, + %% Remove relationships + remove_relationship_basic/1, + remove_relationship_not_found/1, + remove_relationship_ambiguous/1, + remove_relationship_disambiguate_by_template/1, + remove_relationship_dangling_half_edge/1, %% Lookups get_instance_returns_node/1, get_instance_not_found/1, @@ -238,7 +244,12 @@ groups() -> add_relationship_avps_are_per_direction, add_relationship_default_avps_empty, add_relationship_refuses_retired_endpoint, - class_of_returns_class + class_of_returns_class, + remove_relationship_basic, + remove_relationship_not_found, + remove_relationship_ambiguous, + remove_relationship_disambiguate_by_template, + remove_relationship_dangling_half_edge ]}, {lookups, [], [ get_instance_returns_node, @@ -2368,3 +2379,80 @@ b5_custom_resolver_pure_additive(_Config) -> graphdb_instance:create_instance("car", Car, 5, Conn, Additive), {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(2, length(Kids)). %% additive: both fire + + +%%============================================================================= +%% Remove-relationship helpers and test cases +%%============================================================================= + +%% Setup helper: class, default template, two instances, a reciprocal +%% arc-label pair, and one connection edge A--Char-->B. Returns the nrefs. +re_setup() -> + {ok, ClassNref} = graphdb_class:create_class("Org", 3), + {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), + {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), + #{class => ClassNref, tmpl => DefaultTmpl, a => A, b => B, + char => Char, recip => Recip}. + +%% count forward connection rows A--Char-->B +re_count(A, Char, B) -> + {atomic, Rows} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + length([R || R <- Rows, + R#relationship.kind =:= connection, + R#relationship.characterization =:= Char, + R#relationship.target_nref =:= B]). + +remove_relationship_basic(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ?assertEqual(1, re_count(A, Char, B)), + ?assertEqual(1, re_count(B, Recip, A)), + ok = graphdb_instance:remove_relationship(A, Char, B), + ?assertEqual(0, re_count(A, Char, B)), + ?assertEqual(0, re_count(B, Recip, A)). + +remove_relationship_not_found(_Config) -> + #{a := A, b := B, char := Char} = re_setup(), + ?assertEqual({error, relationship_not_found}, + graphdb_instance:remove_relationship(A, Char, B)). + +remove_relationship_ambiguous(_Config) -> + #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), + {ok, DefaultTmpl} = graphdb_class:default_template(Class), + {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, AltTmpl), + ?assertMatch({error, {ambiguous_relationship, [_, _]}}, + graphdb_instance:remove_relationship(A, Char, B)). + +remove_relationship_disambiguate_by_template(_Config) -> + #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), + {ok, DefaultTmpl} = graphdb_class:default_template(Class), + {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:remove_relationship(A, Char, B, DefaultTmpl), + %% one edge (the AltTmpl one) remains in each direction + ?assertEqual(1, re_count(A, Char, B)), + ?assertEqual(1, re_count(B, Recip, A)). + +remove_relationship_dangling_half_edge(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + %% manually delete the reverse row, leaving a half-edge + {atomic, ok} = mnesia:transaction(fun() -> + Rows = mnesia:index_read(relationships, B, #relationship.source_nref), + [Rev] = [R || R <- Rows, + R#relationship.characterization =:= Recip, + R#relationship.target_nref =:= A], + mnesia:delete_object(relationships, Rev, write) + end), + ?assertMatch({error, {dangling_half_edge, _}}, + graphdb_instance:remove_relationship(A, Char, B)), + %% the forward row is NOT deleted -- rollback left it intact + ?assertEqual(1, re_count(A, Char, B)). From ea0b410a9baa34e9aab0cd1f35603700c53b2918 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 10:43:55 -0400 Subject: [PATCH 4/9] Slice E: export template_of/1 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_instance.erl | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index 23df9b5..d5bedeb 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -132,6 +132,7 @@ remove_relationship/4, remove_relationship_in_txn/4, resolve_forward_connection/4, + template_of/1, %% Lookups get_instance/1, children/1, From 2b1c1f9f645309de569d1123869882ff3b7086d3 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 14:03:22 -0400 Subject: [PATCH 5/9] Slice E: update_relationship single-direction AVP edit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_instance.erl | 67 ++++++++++++++++++++ apps/graphdb/test/graphdb_instance_SUITE.erl | 65 ++++++++++++++++++- apps/graphdb/test/graphdb_instance_tests.erl | 13 ++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index d5bedeb..8119dd8 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -133,6 +133,10 @@ 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, %% Lookups get_instance/1, children/1, @@ -1338,6 +1342,69 @@ txn_ok(Fun) -> {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. + %%----------------------------------------------------------------------------- %% validate_arc_endpoints_in_txn(Source, Char, Target, Reciprocal, TkAttr, diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index b9a0a7d..adfbc0f 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -98,6 +98,11 @@ remove_relationship_ambiguous/1, remove_relationship_disambiguate_by_template/1, remove_relationship_dangling_half_edge/1, + %% Update relationships + update_relationship_single_direction/1, + update_relationship_reverse_direction/1, + update_relationship_protects_template/1, + update_relationship_not_found/1, %% Lookups get_instance_returns_node/1, get_instance_not_found/1, @@ -249,7 +254,11 @@ groups() -> remove_relationship_not_found, remove_relationship_ambiguous, remove_relationship_disambiguate_by_template, - remove_relationship_dangling_half_edge + remove_relationship_dangling_half_edge, + update_relationship_single_direction, + update_relationship_reverse_direction, + update_relationship_protects_template, + update_relationship_not_found ]}, {lookups, [], [ get_instance_returns_node, @@ -2456,3 +2465,57 @@ remove_relationship_dangling_half_edge(_Config) -> graphdb_instance:remove_relationship(A, Char, B)), %% the forward row is NOT deleted -- rollback left it intact ?assertEqual(1, re_count(A, Char, B)). + + +%%============================================================================= +%% Update-relationship (single direction) helpers and test cases +%%============================================================================= + +%% fetch the single forward row's avps +re_avps(A, Char, B) -> + {atomic, Rows} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + [R] = [X || X <- Rows, + X#relationship.kind =:= connection, + X#relationship.characterization =:= Char, + X#relationship.target_nref =:= B], + R#relationship.avps. + +update_relationship_single_direction(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + {ok, Note} = graphdb_attr:create_literal_attribute("note", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:update_relationship(A, Char, B, + [#{attribute => Note, value => "fwd"}]), + ?assert(lists:member(#{attribute => Note, value => "fwd"}, + re_avps(A, Char, B))), + %% reverse row untouched (proves independence) + ?assertNot(lists:member(#{attribute => Note, value => "fwd"}, + re_avps(B, Recip, A))). + +update_relationship_reverse_direction(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + {ok, Note} = graphdb_attr:create_literal_attribute("note", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + %% name the reverse direction from the other endpoint: (T, R, S) + ok = graphdb_instance:update_relationship(B, Recip, A, + [#{attribute => Note, value => "rev"}]), + ?assert(lists:member(#{attribute => Note, value => "rev"}, + re_avps(B, Recip, A))), + ?assertNot(lists:member(#{attribute => Note, value => "rev"}, + re_avps(A, Char, B))). + +update_relationship_protects_template(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ?assertEqual({error, {protected_relationship_avp, ?ARC_TEMPLATE}}, + graphdb_instance:update_relationship(A, Char, B, + [#{attribute => ?ARC_TEMPLATE, value => 7}])). + +update_relationship_not_found(_Config) -> + #{a := A, b := B, char := Char} = re_setup(), + {ok, Note} = graphdb_attr:create_literal_attribute("note", string), + ?assertEqual({error, relationship_not_found}, + graphdb_instance:update_relationship(A, Char, B, + [#{attribute => Note, value => "x"}])). diff --git a/apps/graphdb/test/graphdb_instance_tests.erl b/apps/graphdb/test/graphdb_instance_tests.erl index 42b26e4..794254d 100644 --- a/apps/graphdb/test/graphdb_instance_tests.erl +++ b/apps/graphdb/test/graphdb_instance_tests.erl @@ -119,3 +119,16 @@ summarize_counts_test() -> ?assertEqual(#{fired => 1, failed => 1, not_attempted => 0, proposed => 1, connected => 0, required => 0, not_connected => 0}, graphdb_instance:summarize(R2)). + + +%%============================================================================= +%% has_template_update/1 tests +%%============================================================================= + +has_template_update_true_test() -> + ?assert(graphdb_instance:has_template_update( + [#{attribute => ?ARC_TEMPLATE, value => 7}])). + +has_template_update_false_test() -> + ?assertNot(graphdb_instance:has_template_update( + [#{attribute => 9999, value => "x"}, #{attribute => 8888}])). From a0890871997ebd76c4ec98d717747bfed2dc4008 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 14:06:33 -0400 Subject: [PATCH 6/9] Slice E: update_relationship_both bidirectional convenience Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_instance.erl | 59 ++++++++++++++++++++ apps/graphdb/test/graphdb_instance_SUITE.erl | 19 ++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index 8119dd8..255c0f7 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -137,6 +137,9 @@ 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, @@ -1405,6 +1408,62 @@ do_update_relationship(SourceNref, CharNref, TargetNref, TemplateSpec, 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), + ok = update_relationship_avps_in_txn(SourceNref, CharNref, + TargetNref, Tmpl, FwdUpdates), + ok = update_relationship_avps_in_txn(TargetNref, Recip, + SourceNref, Tmpl, RevUpdates) + 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, diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index adfbc0f..a1ea8c1 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -103,6 +103,7 @@ update_relationship_reverse_direction/1, update_relationship_protects_template/1, update_relationship_not_found/1, + update_relationship_both_directions/1, %% Lookups get_instance_returns_node/1, get_instance_not_found/1, @@ -258,7 +259,8 @@ groups() -> update_relationship_single_direction, update_relationship_reverse_direction, update_relationship_protects_template, - update_relationship_not_found + update_relationship_not_found, + update_relationship_both_directions ]}, {lookups, [], [ get_instance_returns_node, @@ -2519,3 +2521,18 @@ update_relationship_not_found(_Config) -> ?assertEqual({error, relationship_not_found}, graphdb_instance:update_relationship(A, Char, B, [#{attribute => Note, value => "x"}])). + +update_relationship_both_directions(_Config) -> + #{a := A, b := B, char := Char, recip := Recip} = re_setup(), + {ok, FAttr} = graphdb_attr:create_literal_attribute("fwd_meta", string), + {ok, RAttr} = graphdb_attr:create_literal_attribute("rev_meta", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:update_relationship_both(A, Char, B, + {[#{attribute => FAttr, value => "F"}], + [#{attribute => RAttr, value => "R"}]}), + FwdAVPs = re_avps(A, Char, B), + RevAVPs = re_avps(B, Recip, A), + ?assert(lists:member(#{attribute => FAttr, value => "F"}, FwdAVPs)), + ?assertNot(lists:member(#{attribute => RAttr, value => "R"}, FwdAVPs)), + ?assert(lists:member(#{attribute => RAttr, value => "R"}, RevAVPs)), + ?assertNot(lists:member(#{attribute => FAttr, value => "F"}, RevAVPs)). From ce7c15c24cf09ab81be7c4e770ccf388bf933cfc Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 14:12:11 -0400 Subject: [PATCH 7/9] Slice E: mutate/1 grammar for remove/update relationship Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_mgr.erl | 53 +++++++++++++++++- apps/graphdb/test/graphdb_mgr_SUITE.erl | 71 +++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/apps/graphdb/src/graphdb_mgr.erl b/apps/graphdb/src/graphdb_mgr.erl index 904c9b3..9252635 100644 --- a/apps/graphdb/src/graphdb_mgr.erl +++ b/apps/graphdb/src/graphdb_mgr.erl @@ -327,6 +327,12 @@ transaction(Fun) -> %% {retire_node, Nref} %% {unretire_node, Nref} %% {update_node_avps, Nref, AVPs} merge/upsert AVP list +%% {remove_relationship, S, C, T} remove edge (any template) +%% {remove_relationship, S, C, T, Template} remove edge (explicit template) +%% {update_relationship, S, C, T, Updates} edit one direction's AVPs +%% {update_relationship, S, C, T, Template, Updates} + explicit template +%% {update_relationship_both, S, C, T, {Fwd, Rev}} edit both directions' AVPs +%% {update_relationship_both, S, C, T, Template, {Fwd, Rev}} + explicit template %% %% Returns {ok, [Result]} -- one native success value per mutation in list %% order (every op returns `ok` today, so {ok, [ok, ok, ...]}) -- or the bare @@ -378,12 +384,31 @@ validate_mutation({update_node_avps, Nref, AVPs}) when is_integer(Nref) -> ok -> tier_guard(Nref); {error, _} = Err -> Err end; +validate_mutation({remove_relationship, _S, _C, _T}) -> + ok; +validate_mutation({remove_relationship, _S, _C, _T, _Template}) -> + ok; +validate_mutation({update_relationship, _S, _C, _T, Updates}) -> + validate_avp_updates(Updates); +validate_mutation({update_relationship, _S, _C, _T, _Template, Updates}) -> + validate_avp_updates(Updates); +validate_mutation({update_relationship_both, _S, _C, _T, {Fwd, Rev}}) -> + validate_both_avp_updates(Fwd, Rev); +validate_mutation({update_relationship_both, _S, _C, _T, _Template, + {Fwd, Rev}}) -> + validate_both_avp_updates(Fwd, Rev); validate_mutation(M) -> {error, {bad_mutation, M}}. tier_guard(Nref) when Nref >= ?NREF_START -> ok; tier_guard(_Nref) -> {error, permanent_node_immutable}. +validate_both_avp_updates(Fwd, Rev) -> + case validate_avp_updates(Fwd) of + ok -> validate_avp_updates(Rev); + {error, _} = Err -> Err + end. + %% Phases 2 + 3. Precondition: Mutations already passed validate_mutations/1. %% Empty batch short-circuits with no transaction. run_mutations([]) -> @@ -419,6 +444,18 @@ prepare({retire_node, _Nref} = M) -> prepare({unretire_node, _Nref} = M) -> M; prepare({update_node_avps, _Nref, _AVPs} = M) -> + M; +prepare({remove_relationship, _S, _C, _T} = M) -> + M; +prepare({remove_relationship, _S, _C, _T, _Template} = M) -> + M; +prepare({update_relationship, _S, _C, _T, _U} = M) -> + M; +prepare({update_relationship, _S, _C, _T, _Template, _U} = M) -> + M; +prepare({update_relationship_both, _S, _C, _T, _Pair} = M) -> + M; +prepare({update_relationship_both, _S, _C, _T, _Template, _Pair} = M) -> M. %% Phase 3 dispatch. Runs INSIDE the transaction: no gen_server calls, no @@ -433,7 +470,21 @@ dispatch({retire_node, Nref}, _TkAttr, RetAttr) -> dispatch({unretire_node, Nref}, _TkAttr, RetAttr) -> set_retired_(Nref, false, RetAttr); dispatch({update_node_avps, Nref, AVPs}, _TkAttr, RetAttr) -> - update_node_avps_in_txn(Nref, AVPs, RetAttr). + update_node_avps_in_txn(Nref, AVPs, RetAttr); +dispatch({remove_relationship, S, C, T}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(S, C, T, any); +dispatch({remove_relationship, S, C, T, Template}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(S, C, T, Template); +dispatch({update_relationship, S, C, T, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(S, C, T, any, U); +dispatch({update_relationship, S, C, T, Template, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(S, C, T, Template, U); +dispatch({update_relationship_both, S, C, T, {Fwd, Rev}}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_both_in_txn(S, C, T, any, Fwd, Rev); +dispatch({update_relationship_both, S, C, T, Template, {Fwd, Rev}}, _TkAttr, + _RetAttr) -> + graphdb_instance:update_relationship_both_in_txn(S, C, T, Template, Fwd, + Rev). %%----------------------------------------------------------------------------- diff --git a/apps/graphdb/test/graphdb_mgr_SUITE.erl b/apps/graphdb/test/graphdb_mgr_SUITE.erl index 55e4b54..38dc7f8 100644 --- a/apps/graphdb/test/graphdb_mgr_SUITE.erl +++ b/apps/graphdb/test/graphdb_mgr_SUITE.erl @@ -132,7 +132,10 @@ %% instance-only QC enforcement update_node_avps_rejects_instance_only/1, update_node_avps_delete_instance_only_ok/1, - mutate_rejects_instance_only/1 + mutate_rejects_instance_only/1, + mutate_remove_relationship/1, + mutate_update_relationship/1, + mutate_mixed_rollback/1 ]). @@ -218,7 +221,10 @@ groups() -> mutate_update_avps_rollback, mutate_update_avps_malformed, mutate_update_avps_not_found, - mutate_rejects_instance_only + mutate_rejects_instance_only, + mutate_remove_relationship, + mutate_update_relationship, + mutate_mixed_rollback ]}, {update_avps, [], [ update_node_avps_upsert_roundtrip, @@ -319,7 +325,10 @@ init_per_testcase(TC, Config) when TC =:= mutate_update_avps_not_found; TC =:= update_node_avps_rejects_instance_only; TC =:= update_node_avps_delete_instance_only_ok; - TC =:= mutate_rejects_instance_only -> + TC =:= mutate_rejects_instance_only; + TC =:= mutate_remove_relationship; + TC =:= mutate_update_relationship; + TC =:= mutate_mixed_rollback -> Config1 = setup_isolated_env(Config), BootstrapFile = proplists:get_value(bootstrap_file, Config), application:set_env(seerstone_graph_db, bootstrap_file, BootstrapFile), @@ -1468,3 +1477,59 @@ mutate_rejects_instance_only(_Config) -> [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, ClassNref), ?assert(lists:member( #{attribute => Attr, value => undefined, instance_only => true}, AVPs)). + +%% count outgoing connection rows Source--Char-->Target via the public read API +mutate_conn_count(Source, Char, Target) -> + {ok, Rels} = graphdb_mgr:get_relationships(Source), + length([R || R <- Rels, + R#relationship.kind =:= connection, + R#relationship.characterization =:= Char, + R#relationship.target_nref =:= Target]). + +mutate_remove_relationship(_Config) -> + {ok, Class} = graphdb_class:create_class("MRemoveOrg", 3), + {ok, A, _} = graphdb_instance:create_instance("MRA", Class, 5), + {ok, B, _} = graphdb_instance:create_instance("MRB", Class, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("MRKnows", "MRKnownBy", + instance), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ?assertEqual(1, mutate_conn_count(A, Char, B)), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate([{remove_relationship, A, Char, B}])), + ?assertEqual(0, mutate_conn_count(A, Char, B)), + ?assertEqual(0, mutate_conn_count(B, Recip, A)), + ok = graphdb_mgr:verify_caches(). + +mutate_update_relationship(_Config) -> + {ok, Class} = graphdb_class:create_class("MUpdOrg", 3), + {ok, A, _} = graphdb_instance:create_instance("MUA", Class, 5), + {ok, B, _} = graphdb_instance:create_instance("MUB", Class, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("MUKnows", "MUKnownBy", + instance), + {ok, Note} = graphdb_attr:create_literal_attribute("munote", string), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ?assertEqual({ok, [ok, ok]}, graphdb_mgr:mutate([ + {update_relationship, A, Char, B, [#{attribute => Note, value => "f"}]}, + {update_relationship_both, A, Char, B, + {[#{attribute => Note, value => "F"}], + [#{attribute => Note, value => "R"}]}}])), + ok = graphdb_mgr:verify_caches(). + +mutate_mixed_rollback(_Config) -> + {ok, Class} = graphdb_class:create_class("MMixOrg", 3), + {ok, A, _} = graphdb_instance:create_instance("MMA", Class, 5), + {ok, B, _} = graphdb_instance:create_instance("MMB", Class, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("MMKnows", "MMKnownBy", + instance), + ok = graphdb_instance:add_relationship(A, Char, B, Recip), + %% second mutation removes a non-existent edge -> whole batch rolls back + ?assertEqual({error, relationship_not_found}, graphdb_mgr:mutate([ + {remove_relationship, A, Char, B}, + {remove_relationship, A, Char, B}])), + %% the first remove was rolled back -- the edge is still present + ?assertEqual(1, mutate_conn_count(A, Char, B)), + ?assertEqual(1, mutate_conn_count(B, Recip, A)), + ok = graphdb_mgr:verify_caches(). From 6385e53c262582220de1754e7ed021804dd519a3 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 14:14:56 -0400 Subject: [PATCH 8/9] Slice E: docs + TASKS for relationship mutation Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- TASKS.md | 11 +++++------ apps/graphdb/CLAUDE.md | 22 +++++++++++++++++++++- docs/Architecture.md | 18 ++++++++++++++++-- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/TASKS.md b/TASKS.md index 437171d..9ae69e4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -324,14 +324,13 @@ 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) +### Relationship mutation (slice E) — IMPLEMENTED -Design: `docs/designs/slice-e-relationship-mutation-design.md`. +Design: `docs/designs/slice-e-relationship-mutation-design.md`; plan +`docs/superpowers/plans/2026-06-28-slice-e-relationship-mutation.md`. -Only `add_relationship` (create) exists today — there is no remove or -update. Slice E adds, **connection-arcs only** (the exact mirror of -`add_relationship`; no `parents`/`classes` cache work — connection arcs are -never cached): +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 diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index da16796..0c52a21 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -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` @@ -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 diff --git a/docs/Architecture.md b/docs/Architecture.md index e4f6c8f..ecec0b9 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -213,6 +213,18 @@ it is forbidden on relationships of any other kind. Template nodes are compositional children of class nodes (see §3 cache field sources). API: `graphdb_instance:add_relationship/4,5`. +Connection edges are mutated through `graphdb_instance` +(connection-arcs only — these never touch the `parents`/`classes` caches): +`remove_relationship/3,4` deletes **both** directed rows of a logical edge +atomically; `update_relationship/4,5` and `update_relationship_both/4,5` edit +the per-direction AVP metadata, reusing the slice-B AVP merge grammar. Remove +is logical-edge-level; AVP update is directed-row-level (the `(S,C,T)` triple +names one directed row — name `(T,R,S)` to edit the reverse). The `Template` +AVP is protected from edit. Since nothing dedups connection edges at write +time, a key matching more than one logical edge yields +`{ambiguous_relationship, Templates}`; no match yields +`relationship_not_found`. + --- ## 5. Supervision Tree @@ -264,8 +276,10 @@ workers — read path and soft-retire implemented; remaining write-side routing is pending (see [`../TASKS.md`](../TASKS.md)). The tier-3 batch entry point `graphdb_mgr:mutate/1` applies an ordered list -of `add_relationship` / `retire_node` / `unretire_node` mutations atomically -in one transaction, composing the tier-1 primitives directly. +of `add_relationship` / `retire_node` / `unretire_node` / `update_node_avps` / +`remove_relationship` / `update_relationship` / `update_relationship_both` +mutations atomically in one transaction, composing the tier-1 primitives +directly. --- From 265d387a404abcff8742f975b4f8eafac00401f3 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 28 Jun 2026 14:24:20 -0400 Subject: [PATCH 9/9] Slice E: _both surfaces dangling_half_edge; assert Template AVP survives edit Final-review minors: update_relationship_both now checks the symmetric partner before editing (same {dangling_half_edge, Id} arm as remove, keeping the documented contract accurate); single-direction CT now asserts the protected Template AVP survives an ordinary edit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_instance.erl | 18 ++++++++++++++---- apps/graphdb/test/graphdb_instance_SUITE.erl | 3 +++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index 255c0f7..497bb0c 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -1429,10 +1429,20 @@ update_relationship_both_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, {ok, Fwd} -> Recip = Fwd#relationship.reciprocal, Tmpl = template_of(Fwd), - ok = update_relationship_avps_in_txn(SourceNref, CharNref, - TargetNref, Tmpl, FwdUpdates), - ok = update_relationship_avps_in_txn(TargetNref, Recip, - SourceNref, Tmpl, RevUpdates) + %% 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. %%----------------------------------------------------------------------------- diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index a1ea8c1..cee5bb1 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -2492,6 +2492,9 @@ update_relationship_single_direction(_Config) -> [#{attribute => Note, value => "fwd"}]), ?assert(lists:member(#{attribute => Note, value => "fwd"}, re_avps(A, Char, B))), + %% the protected Template AVP survives an ordinary edit + ?assert(lists:any(fun(#{attribute := X}) -> X =:= ?ARC_TEMPLATE end, + re_avps(A, Char, B))), %% reverse row untouched (proves independence) ?assertNot(lists:member(#{attribute => Note, value => "fwd"}, re_avps(B, Recip, A))).