From c4d05cb8d07eb0ec4c27b3756011c2197ced1db1 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Thu, 18 Jun 2026 06:18:57 -0400 Subject: [PATCH 01/12] docs: mark transaction-layering seam IMPLEMENTED in TASKS.md The seam shipped in PR #41 (81b2962); the heading still read "IN DESIGN". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 873e03a..06185b1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -100,7 +100,7 @@ independent and broken into slices A–E below. `graphdb_mgr` owns the generic low-level node/relationship CRUD; type-specific behaviour delegates to the owning worker. -### Transaction-layering seam (slice A prerequisite) — IN DESIGN +### Transaction-layering seam (slice A prerequisite) — IMPLEMENTED The decided convention for all write-path mutation: separate the Mnesia transaction boundary from the CRUD logic, so operations compose into one From 771f8c1827b0887a25af7290ca4a575b9f576625 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:08:39 -0400 Subject: [PATCH 02/12] docs: add transaction-seam retrofit design (seam follow-up 1) Brainstormed spec for the first tracked Transaction-layering seam follow-up: route every hand-rolled mnesia:transaction site (40 across the six graphdb workers + bootstrap) through graphdb_mgr:transaction/1. Behaviour-preserving; documents the conversion taxonomy, four behaviour-preservation traps, the full per-site inventory, and the two-part testing standard (existing 537 are the oracle; new/reshaped paths get new tests). Atomic add_relationship and batch mutate/1 remain separate slices. Co-Authored-By: Claude Opus 4.8 --- .../transaction-seam-retrofit-design.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 docs/designs/transaction-seam-retrofit-design.md diff --git a/docs/designs/transaction-seam-retrofit-design.md b/docs/designs/transaction-seam-retrofit-design.md new file mode 100644 index 0000000..397165c --- /dev/null +++ b/docs/designs/transaction-seam-retrofit-design.md @@ -0,0 +1,268 @@ +# Transaction-Seam Retrofit — Design + +**Status:** Approved (design) — not yet planned/implemented +**Date:** 2026-06-20 +**Author:** David W. Thomas (with Claude) +**Slice:** Transaction-layering seam, tracked follow-up 1 of 2 + +## Background + +The write-path transaction-layering seam shipped in PR #41 (`81b2962`, +`docs/designs/write-path-transaction-seam-design.md`). It defines three tiers: + +- **Tier 1** — in-transaction primitives: bare mnesia ops, signal failure via + `mnesia:abort/1`, never open their own transaction, so they compose. +- **Tier 2** — single-op public API: owns exactly one transaction via + `graphdb_mgr:transaction/1`. +- **Tier 3** — batch/composite: wraps one transaction, calls tier-1 primitives + directly, never tier-2 (no nested transactions). + +`graphdb_mgr:transaction/1` is the seam's helper — a plain exported function +(not a `gen_server:call`, because `mnesia:transaction/1` runs in the calling +process): + +```erlang +transaction(Fun) -> + case mnesia:transaction(Fun) of + {atomic, Result} -> {ok, Result}; + {aborted, Reason} -> {error, Reason} + end. +``` + +`TASKS.md` records two tracked follow-ups under "Transaction-layering seam": + +1. **Retrofit existing write ops** onto the primitive/wrapper layering — + uniform convention, no behaviour change. *(This document.)* +2. **Batch `mutate([Mutation])`** — the tier-3 entry point. *(Separate spec; + consumes the primitives this retrofit produces.)* + +The two have a producer/consumer relationship: this retrofit produces the +clean tier-1 primitives; `mutate/1` composes them. Hence retrofit first. + +## Goal + +Make `graphdb_mgr:transaction/1` the **single** place in the `graphdb` +application that pattern-matches `{atomic, _}` / `{aborted, _}`. Every other +`mnesia:transaction` call site is reshaped into a tier-1 primitive invoked +through `transaction/1`, with its public return shape and error terms +unchanged. + +## Scope + +A full sweep of every hand-rolled `mnesia:transaction` call site across the +six graphdb workers, plus the two assertion-form sites in the bootstrap +loader — **40 sites total** (counts in the inventory below). + +### Non-goals (explicitly out) + +| Out of scope | Why / where it goes | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Atomic `add_relationship` | Needs `graphdb_class` tier-1 in-txn read primitives (its reads are `gen_server:call` today). Its own slice, sequenced with / before `mutate/1`, which wants those primitives anyway. | +| Batch `mutate/1` | The next spec (tracked follow-up 2). | +| Any public return-shape / error change | This is a behaviour-preserving refactor. | + +`add_relationship` currently runs **four separate transactions** (validate → +resolve classes → resolve template → write); it is not atomic today. This +retrofit converts each of those sites' `{atomic/aborted}` mapping to the +convention but does **not** merge them — making the operation atomic is the +deferred slice above. + +## The convention + +- **Tier-1 primitive** — body is bare mnesia ops; returns the operation's + natural success value; signals a *rollback-worthy* failure via + `mnesia:abort(Reason)` using the **exact** `Reason` term the public contract + already exposes. Documented "Must run inside an active mnesia transaction." +- **Tier-2 wrapper** — calls `graphdb_mgr:transaction(fun() -> Primitive(...) + end)`, then projects `{ok, Value}` to the public shape and propagates + `{error, Reason}` in whatever form that site's contract already uses. +- **Single mnesia mapping point** — only `transaction/1` matches + `{atomic, _}` / `{aborted, _}`. After this slice, `grep "mnesia:transaction"` + over `apps/graphdb/src/` should return only `transaction/1`'s own definition. + +## Conversion taxonomy + +Every site maps to exactly one recipe. The recipe is determined by the site's +current `{atomic,_}` / `{aborted,_}` arms. + +| Shape | Current arm(s) | Recipe | +| -------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Identity value/list | `{atomic, V} -> {ok, V}` | `graphdb_mgr:transaction(F)` (the mapping is already identity) | +| Unwrap ok | `{atomic, ok} -> ok` | wrapper `{ok, ok} -> ok` | +| Tagged success | `{atomic, ok} -> {ok, Nref}` | fun returns `ok`; wrapper `{ok, ok} -> {ok, Nref}` (Nref from outer scope) | +| Projection-in-fun | `{atomic, {value, R}} -> {ok, X}; false -> not_found` | move projection into the fun (fun returns `{ok, X}` \| `not_found`); wrapper `{ok, Result} -> Result` | +| Collapse idempotent | `{atomic, ok} -> ok; already_exists -> ok` | fun returns `ok` in both branches; wrapper `{ok, ok} -> ok` | +| Abort-relocation | `{atomic, {[], _, _, _}} -> {error, ...}` (tuple-match) | relocate each domain failure into the fun via `mnesia:abort(ExactReason)`; fun returns `ok`; wrapper `{ok, ok} -> ok` | +| Throw-on-abort | `{aborted, R} -> throw({error, R})` | wrapper re-throws: `{error, R} -> throw({error, R})` (combined with the value recipe) | +| Reply-in-handle_call | `{atomic, ok} -> {reply, ok, State}` | wrapper builds the reply tuple from `{ok, _}` / `{error, _}` | +| Side-effects-after | `{atomic, ok} -> ` | wrapper runs the same post-commit work on `{ok, ok}` | +| Assertion form | `{atomic, ok} = mnesia:transaction(..)` | `{ok, ok} = graphdb_mgr:transaction(..)` (preserves crash-on-failure) | + +## Behaviour-preservation traps + +This is not a blind find-and-replace. Four traps must be honored per site: + +1. **Failure propagation is not uniform.** Three contracts coexist — + return `{error, R}`, `throw({error, R})` (the init/seed helpers in + `graphdb_attr`, `graphdb_language`, `graphdb_rules`), and + `{reply, {error, R}, State}` (`graphdb_language` `set_labels`). Each + wrapper must reproduce *its* site's contract; do not normalize them. + +2. **`{atomic, {error, _} = E} -> E` must NOT become an abort.** + `graphdb_class:832` lets the fun *return* `{error, _}` as a committed + value (no rollback). Converting it to `mnesia:abort` would change rollback + semantics. Preserve via `{ok, {error, _} = E} -> E`. + +3. **Abort-swallow sites.** `graphdb_class:704` + (`do_find_template_by_name`) and `graphdb_instance:1760` + (`resolve_from_connected`) map `{aborted, _} -> not_found` — a real mnesia + abort is swallowed to a domain value. Preserve via + `{ok, Result} -> Result; {error, _} -> not_found`. + +4. **Abort-relocation must use the exact `Reason` term** so the public error + is byte-for-byte unchanged (e.g. `mnesia:abort({source_not_found, + SourceNref})`). The relocated funs are read-only, so they are retry-safe + under mnesia's lock-conflict re-execution. + +## Inventory + +All 40 sites, grouped by module, with line, function, and recipe. The +implementation plan repeats this with the exact before/after code per site. + +### `graphdb_mgr` (4) + +| Line | Function | Recipe | +| ---- | ----------------------------------- | --------------------------------------------------------------- | +| 317 | `verify_caches/0` | Projection (read-only): `{ok, []} -> ok; {ok, M} -> {error, M}` | +| 338 | `rebuild_caches/0` | Unwrap ok | +| 502 | `do_get_relationships/2` (outgoing) | Identity | +| 509 | `do_get_relationships/2` (incoming) | Identity | + +### `graphdb_instance` (7) + +| Line | Function | Recipe | +| ---- | ----------------------------- | ----------------------------------------------------------------------- | +| 579 | `execute/5` | Result-building on both arms (success report vs `report_not_attempted`) | +| 1238 | `validate_arc_endpoints/6` | Abort-relocation (the marquee trap-4 site) | +| 1394 | `write_connection_arcs/6` | Unwrap ok | +| 1453 | `do_write_class_membership/2` | Collapse idempotent (`already_exists -> ok`) | +| 1487 | `do_class_of/1` | Projection-in-fun | +| 1518 | `do_children/1` | Identity list | +| 1760 | `resolve_from_connected/2` | Side-effects-after + abort-swallow → `not_found` | + +### `graphdb_attr` (9) + +| Line | Recipe | +| ---- | -------------------------------------------------------------------------------- | +| 499 | Projection-in-fun (`{value} -> {ok, Nref}; false -> not_found`) + throw-on-abort | +| 562 | Tagged success (`{ok, Nref}`) | +| 657 | Tagged success (`{ok, {FwdNref, RevNref}}`) | +| 685 | Projection (multi-clause read: `{ok, N}` / `not_an_attribute` / `not_found`) | +| 700 | Identity list | +| 713 | Identity list | +| 755 | Projection-in-fun + side-effects-after (nested `find_attribute_type_value`) | +| 799 | Collapse (`ok` / `_Other -> ok`) + throw-on-abort | +| 883 | Unwrap ok + throw-on-abort | + +### `graphdb_class` (8) + +| Line | Recipe | +| ---- | -------------------------------------------------------------------------- | +| 499 | Tagged success (txn value ignored: `{atomic, _Writes} -> {ok, ClassNref}`) | +| 624 | Collapse idempotent | +| 682 | Tagged success (`{ok, TemplateNref}`) | +| 704 | Projection-in-fun + abort-swallow → `not_found` (trap 3) | +| 738 | Identity list | +| 832 | Collapse idempotent + fun-returns-`{error, _}`-value (trap 2) | +| 869 | Unwrap ok | +| 911 | Identity list | + +### `graphdb_language` (7) + +| Line | Recipe | +| ---- | --------------------------------------------------------------------------- | +| 310 | Reply-in-handle_call (`set_labels`) | +| 395 | Projection-in-fun + throw (`false -> throw({class_not_found, Name})`) | +| 447 | Tagged success + throw | +| 508 | Tuple-value (`{atomic, {CM, DM}} -> {CM, DM}`) + throw | +| 629 | Side-effects-after (`register_language`: `ensure_overlay_table` + NewState) | +| 692 | Side-effects-after (`register_dialect`) | +| 734 | Projection (`not_found` / `{ok, Code}`) | + +### `graphdb_rules` (3) + +| Line | Recipe | +| ---- | ----------------------------------------------- | +| 608 | Tagged success (`{atomic, ok} -> Nref`) + throw | +| 654 | Projection-in-fun + throw | +| 857 | Tagged success (`{ok, RuleNref}`) | + +### `graphdb_bootstrap` (2) + +| Line | Recipe | +| ---- | ----------------------------------------- | +| 509 | Assertion form (node writes) | +| 546 | Assertion form (relationship-pair writes) | + +## Testing standard + +Two-part: + +1. **The existing suite is the behaviour oracle.** All 537 tests + (432 CT + 105 EUnit) must stay green with **zero test-expectation edits**. + Any required edit signals an unintended behaviour change — a defect, not a + test to update. + +2. **New or reshaped code paths require new, passing tests.** A pure 1:1 + refactor of an existing path rides the existing tests. Where a conversion + creates a branch the suite does not already exercise, that branch gets its + own test. Each task includes a **coverage check** per converted site and + adds a test wherever the converted path is otherwise unverified. The likely + spots: + + - **Abort-relocation arms** (`validate_arc_endpoints`): each domain failure + now flows through `mnesia:abort(Reason)`. Every relocated arm + (`source_not_found`, `target_not_found`, `characterization_not_found`, + `reciprocal_not_found`, `endpoint_retired`, characterization/reciprocal + "not an attribute", `target_kind_mismatch`) must have a test asserting the + **exact** error term. Any arm not already covered → add a test. + - **Collapse / projection sites** where a branch (`already_exists -> ok`, + `false -> not_found`) is not currently asserted → add a test that locks + it. + - Any tier-1 primitive that becomes **independently reachable** in this + slice (none planned — primitives stay internal, invoked only via their + wrapper; flagged so the plan re-checks). + +The coverage check doubles as a guard against the four traps: if a +relocated/collapsed branch has no test, the conversion's behaviour-preservation +is unproven — so write the test first, then convert. + +Principle: **behaviour-preserving conversions ride existing tests; every new +or newly-shaped path gets a new, passing test.** + +## Risks + +| Risk | Mitigation | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| The four traps (silent behaviour change) | Each affected site is flagged by recipe in the inventory; the coverage check forces a test before conversion | +| Throw-vs-return contract mismatch | Trap 1 is called out per affected module; the plan repeats the exact contract per site | +| Large mechanical diff hiding a subtle change | Convert and run the full suite **per module**, not once at the end | +| Init-time call into `transaction/1` (bootstrap) | `transaction/1` is a plain function, not a `gen_server:call`; safe to call from `graphdb_mgr:init/1`'s bootstrap load path | + +## Relationship to the other seam follow-ups + +- **Atomic `add_relationship`** (deferred slice): once `graphdb_class` exposes + tier-1 in-transaction read primitives, the four `add_relationship` + transactions can collapse into one. The primitives this retrofit produces + in `graphdb_instance` are the write half of that future work. +- **Batch `mutate/1`** (follow-up 2): the tier-3 entry point composes the + tier-1 primitives this retrofit establishes. Brainstormed as its own spec + after this slice lands. + +## References + +- `docs/designs/write-path-transaction-seam-design.md` — the seam (PR #41) +- `TASKS.md` — "Transaction-layering seam" section (tracked follow-ups) +- `apps/graphdb/src/graphdb_mgr.erl:291` — `transaction/1` +- `apps/graphdb/src/graphdb_mgr.erl:551` — `set_retired/3` + `set_retired_/3`, + the reference tier-1/tier-2 pair (PR #42) From 7df11a9743bf364f626b0f94979914a68a7ef2cd Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:19:07 -0400 Subject: [PATCH 03/12] docs: track atomic add_relationship follow-up; link retrofit design in TASKS.md Inserts the out-of-scope Atomic add_relationship item between the retrofit and batch-mutate follow-ups under the Transaction-layering seam section, and links the retrofit design doc on the retrofit bullet. Co-Authored-By: Claude Opus 4.8 --- TASKS.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 06185b1..e46b8ec 100644 --- a/TASKS.md +++ b/TASKS.md @@ -128,7 +128,16 @@ Tracked follow-ups (not in the seam spec): - **Retrofit existing write ops** (`create_instance`, `add_relationship`, the membership `do_*` ops) onto the primitive/wrapper layering — uniform - convention, no behaviour change. + convention, no behaviour change. Design (full sweep of all 40 + `mnesia:transaction` sites across the six workers + bootstrap): + `docs/designs/transaction-seam-retrofit-design.md`. +- **Atomic `add_relationship`** — collapse its four separate transactions + (validate → resolve classes → resolve template → write) into one. + Blocked on `graphdb_class` exposing tier-1 in-transaction read + primitives: its reads (`default_template`, `get_template`, + `class_in_ancestry`) are `gen_server:call` today, which cannot run inside + an Mnesia transaction. Sequence with / before `mutate/1`, which wants + those primitives too. - **Batch `mutate([Mutation])`** — the tier-3 entry point. ### Node deletion (slice A) — IMPLEMENTED From b02042c780ac2b6a17acb075e0eb481a15788b3e Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:32:44 -0400 Subject: [PATCH 04/12] docs: add transaction-seam retrofit implementation plan Task-by-task plan (7 tasks, one per worker module + final verification) to route all 40 mnesia:transaction sites through graphdb_mgr:transaction/1. Per-site before/after code, the four behaviour-preservation traps flagged at their sites, and the only 2 new tests (instance abort-relocation arms: characterization_not_found / reciprocal_not_found). Behaviour-preserving: existing 537 are the oracle, zero expectation edits. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-20-transaction-seam-retrofit.md | 1136 +++++++++++++++++ 1 file changed, 1136 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-transaction-seam-retrofit.md diff --git a/docs/superpowers/plans/2026-06-20-transaction-seam-retrofit.md b/docs/superpowers/plans/2026-06-20-transaction-seam-retrofit.md new file mode 100644 index 0000000..cd82125 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-transaction-seam-retrofit.md @@ -0,0 +1,1136 @@ +# Transaction-Seam Retrofit 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:** Route every hand-rolled `mnesia:transaction` call site in the graphdb app (40 sites across six workers + the bootstrap loader) through `graphdb_mgr:transaction/1`, so it becomes the single `{atomic,_}`/`{aborted,_}` mapping point — with zero behaviour change. + +**Architecture:** Each site's transaction fun is reshaped into a tier-1 primitive (bare mnesia ops; rollback-worthy failures via `mnesia:abort(ExactReason)`) and invoked via `graphdb_mgr:transaction/1`. The wrapper projects `{ok, Value}` to the site's existing public shape and reproduces the site's existing failure contract (return / `throw` / `{reply,...}`). Because `transaction/1`'s `{aborted,Reason} -> {error,Reason}` mapping is byte-identical to the inline mapping, any fun-internal abort/throw Reason is preserved automatically; only the four traps below need deliberate handling. + +**Tech Stack:** Erlang/OTP 28.5, rebar3 3.27 (repo-local `./rebar3`), Mnesia, Common Test + EUnit. + +**Design:** `docs/designs/transaction-seam-retrofit-design.md` (approved). + +## Global Constraints + +- **Behaviour-preserving.** The existing 537 tests (432 CT + 105 EUnit) are the oracle: they must stay green with **zero test-expectation edits**. Any required edit signals an unintended behaviour change — a defect. +- **Single mapping point.** Only `graphdb_mgr:transaction/1` may pattern-match `{atomic,_}`/`{aborted,_}`. After the plan, `grep -rn "mnesia:transaction" apps/graphdb/src/` returns only `transaction/1`'s own definition (`graphdb_mgr.erl:293`). +- **Call it qualified:** `graphdb_mgr:transaction(Fun)` at every site (matches the existing in-module caller `set_retired/3`), including inside `graphdb_mgr` and `graphdb_bootstrap`. +- **Exact Reason terms.** Relocated aborts (`mnesia:abort(Reason)`) use the *exact* term the site currently returns as `{error, Reason}`. +- **Preserve each site's failure contract.** Three coexist — return `{error,R}`, `throw({error,R})`, and `{reply,{error,R},State}`. Do not normalize them. +- **Trap 1 — non-uniform failure propagation:** reproduce the site's contract (return / throw / reply) exactly. +- **Trap 2 — `{atomic,{error,_}=E} -> E` is NOT an abort:** the fun *returns* `{error,_}` as a committed value (`graphdb_class:832`). Preserve via `{ok,{error,_}=E} -> E`. Never convert it to `mnesia:abort`. +- **Trap 3 — abort-swallow:** `graphdb_class:704` and `graphdb_instance:1760` map a real abort to a domain value (`not_found`). Preserve via `{error,_} -> not_found`. +- **Trap 4 — abort-relocation:** `graphdb_instance:validate_arc_endpoints` moves domain failures inside the fun via `mnesia:abort(ExactReason)`. +- **Whitespace:** match each site's existing indentation. Most graphdb files use **tabs**; `graphdb_language.erl` uses **spaces** at some sites (e.g. line 310). Change only the matched tokens; keep the surrounding indentation byte-for-byte. +- **Headers/copyright unchanged.** Do not touch module headers, `-revision`, NYI/UEM macros, or export lists except where a task explicitly adds a test export. +- **Line numbers drift.** All `file:line` references are anchored to the pre-change tree; after an edit, later line numbers in the *same file* shift. Locate sites by the `case mnesia:transaction` text and the function name, not by absolute line. + +**Commands (run from repo root):** +- Compile: `./rebar3 compile` — Expected: `===> Compiling ...` with no warnings/errors. +- One CT suite: `./rebar3 ct --suite apps/graphdb/test/graphdb__SUITE` — Expected: `All N tests passed.` +- One EUnit module: `./rebar3 eunit --module graphdb__tests` — Expected: `All N tests passed.` +- Full CT (fast): `make test-ct-parallel` — Expected: aggregate `PASS`, exit 0. +- Full EUnit: `./rebar3 eunit` — Expected: `All 105 tests passed.` +- Grep gate: `grep -rn "mnesia:transaction" apps/graphdb/src/` — Expected after plan: one line (`graphdb_mgr.erl:` `transaction/1`). + +--- + +## Task ordering + +Tasks are independent (each module's sites are self-contained); ordered simplest-first so the convention is well-practiced before the trickiest site. The grep gate is fully satisfied only after the last task. + +1. `graphdb_mgr` + `graphdb_bootstrap` +2. `graphdb_attr` +3. `graphdb_class` +4. `graphdb_language` +5. `graphdb_rules` +6. `graphdb_instance` (includes the abort-relocation trap + the only 2 new tests) +7. Final verification + +--- + +## Task 1: `graphdb_mgr` + `graphdb_bootstrap` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` (`verify_caches/0`, `rebuild_caches/0`, `do_get_relationships/2`) +- Modify: `apps/graphdb/src/graphdb_bootstrap.erl` (2 assertion-form sites) +- Test: existing `graphdb_mgr_SUITE`, `graphdb_bootstrap_SUITE`, `graphdb_bootstrap_tests` (no new tests) + +**Interfaces:** +- Consumes: `graphdb_mgr:transaction/1` (already exists, `graphdb_mgr.erl:291-296`). +- Produces: nothing new; behaviour-identical functions. + +- [ ] **Step 1: Convert `verify_caches/0`** + +Replace the `case` (currently `graphdb_mgr.erl:317-321`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, []} -> ok; + {atomic, Mismatches} -> {error, Mismatches}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, []} -> ok; + {ok, Mismatches} -> {error, Mismatches}; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 2: Convert `rebuild_caches/0`** + +Replace (currently `graphdb_mgr.erl:338-341`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 3: Convert both `do_get_relationships/2` clauses (identity)** + +The `outgoing` clause (currently `graphdb_mgr.erl:501-507`) becomes: + +```erlang +do_get_relationships(Nref, outgoing) -> + graphdb_mgr:transaction(fun() -> + mnesia:index_read(relationships, Nref, #relationship.source_nref) + end); +``` + +The `incoming` clause (currently `:508-514`) becomes: + +```erlang +do_get_relationships(Nref, incoming) -> + graphdb_mgr:transaction(fun() -> + mnesia:index_read(relationships, Nref, #relationship.target_nref) + end); +``` + +(The `both` clause is unchanged — it composes the other two.) `transaction/1` already returns `{ok, Rels}` / `{error, Reason}`, identical to the old mapping. + +- [ ] **Step 4: Convert the two `graphdb_bootstrap` assertion sites** + +At `graphdb_bootstrap.erl:509` and `:546`, replace each: + +```erlang + {atomic, ok} = mnesia:transaction(fun() -> +``` + +with: + +```erlang + {ok, ok} = graphdb_mgr:transaction(fun() -> +``` + +(The fun bodies and the closing `end)` are unchanged. This preserves crash-on-failure: a non-`{ok,ok}` result is a `badmatch`, exactly as before.) + +- [ ] **Step 5: Compile** + +Run: `./rebar3 compile` +Expected: compiles clean, no warnings. + +- [ ] **Step 6: Grep this task's files** + +Run: `grep -n "mnesia:transaction" apps/graphdb/src/graphdb_mgr.erl apps/graphdb/src/graphdb_bootstrap.erl` +Expected: a single hit — the `transaction/1` definition in `graphdb_mgr.erl` (the `case mnesia:transaction(Fun) of` line). `graphdb_bootstrap.erl`: no hits. + +- [ ] **Step 7: Run the suites** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE --suite apps/graphdb/test/graphdb_bootstrap_SUITE` +Then: `./rebar3 eunit --module graphdb_mgr_tests --module graphdb_bootstrap_tests` +Expected: all pass; zero test-file edits made. + +- [ ] **Step 8: Commit** + +```bash +git add apps/graphdb/src/graphdb_mgr.erl apps/graphdb/src/graphdb_bootstrap.erl +git commit -m "refactor(graphdb_mgr,bootstrap): route txn sites through transaction/1" +``` + +--- + +## Task 2: `graphdb_attr` (9 sites) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_attr.erl` +- Test: existing `graphdb_attr_SUITE` (no new tests — branches already covered, e.g. `not_an_attribute` at suite line 709) + +**Interfaces:** +- Consumes: `graphdb_mgr:transaction/1`. +- Produces: behaviour-identical functions. + +- [ ] **Step 1: `find_attribute_by_name/2` — projection-in-fun + throw** + +Replace the whole `case mnesia:transaction(F) of ... end` tail (currently `:499-503`). Move the `{value,...}`/`false` projection into the fun and re-throw on `{error,_}`: + +```erlang + F = fun() -> + Children = downward_children_by_arc(ParentNref, ?ARC_ATTR_CHILD, + taxonomy), + case lists:search(fun(N) -> node_has_name(N, Name) end, Children) of + {value, #node{nref = Nref}} -> {ok, Nref}; + false -> not_found + end + end, + case graphdb_mgr:transaction(F) of + {ok, Result} -> Result; + {error, Reason} -> throw({error, Reason}) + end. +``` + +(Replaces the existing `F = fun() ... end,` *and* the `case`. The `lists:search` moves inside the fun.) + +- [ ] **Step 2: `do_create_attribute/3` — tagged success** + +Replace (currently `:562-565`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> {ok, Nref}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, Nref}; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 3: `do_create_relationship_attribute_pair/4` — tagged success** + +Replace (currently `:657-660`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> {ok, {FwdNref, RevNref}}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, {FwdNref, RevNref}}; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 4: `do_get_attribute/1` — projection at wrapper** + +Replace (currently `:685-690`): + +```erlang + case mnesia:transaction(fun() -> mnesia:read(nodes, Nref) end) of + {atomic, [#node{kind = attribute} = Node]} -> {ok, Node}; + {atomic, [_Other]} -> {error, not_an_attribute}; + {atomic, []} -> {error, not_found}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(fun() -> mnesia:read(nodes, Nref) end) of + {ok, [#node{kind = attribute} = Node]} -> {ok, Node}; + {ok, [_Other]} -> {error, not_an_attribute}; + {ok, []} -> {error, not_found}; + {error, Reason} -> {error, Reason} + end. +``` + +- [ ] **Step 5: `do_list_attributes/0` and `do_list_children/1` — identity** + +In each (currently `:700-703` and `:713-716`), replace: + +```erlang + case mnesia:transaction(F) of + {atomic, Nodes} -> {ok, Nodes}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + graphdb_mgr:transaction(F). +``` + +- [ ] **Step 6: `do_attribute_type_of/2` — projection at wrapper (nested)** + +Replace (currently `:755-764`): + +```erlang + case mnesia:transaction(F) of + {atomic, [#node{kind = attribute, attribute_value_pairs = AVPs}]} -> + case find_attribute_type_value(AtAttrNref, AVPs) of + {ok, Kind} -> {ok, Kind}; + not_found -> {error, no_attribute_type} + end; + {atomic, [_Other]} -> {error, not_an_attribute}; + {atomic, []} -> {error, not_found}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with (only the four arm heads change): + +```erlang + case graphdb_mgr:transaction(F) of + {ok, [#node{kind = attribute, attribute_value_pairs = AVPs}]} -> + case find_attribute_type_value(AtAttrNref, AVPs) of + {ok, Kind} -> {ok, Kind}; + not_found -> {error, no_attribute_type} + end; + {ok, [_Other]} -> {error, not_an_attribute}; + {ok, []} -> {error, not_found}; + {error, Reason} -> {error, Reason} + end. +``` + +- [ ] **Step 7: `retro_stamp_bootstrap_attribute_types/1` — collapse + throw** + +Replace (currently `:799-803`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> ok; + {atomic, _Other} -> ok; + {aborted, Reason} -> throw({error, Reason}) + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, _} -> ok; + {error, Reason} -> throw({error, Reason}) + end. +``` + +- [ ] **Step 8: `ensure_template_avp_marker/1` — unwrap-ok + throw** + +Replace (currently `:883-886`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> ok; + {aborted, Reason} -> throw({error, Reason}) + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {error, Reason} -> throw({error, Reason}) + end. +``` + +(The `throw({error, {template_avp_node_missing, ...}})` *inside* `Txn` is untouched; transaction/1 maps the resulting abort to `{error, Reason}` identically.) + +- [ ] **Step 9: Compile + grep + test + commit** + +```bash +./rebar3 compile +grep -n "mnesia:transaction" apps/graphdb/src/graphdb_attr.erl # expect: no hits +./rebar3 ct --suite apps/graphdb/test/graphdb_attr_SUITE +``` +Expected: compiles clean; grep empty; CT green; no test edits. + +```bash +git add apps/graphdb/src/graphdb_attr.erl +git commit -m "refactor(graphdb_attr): route txn sites through transaction/1" +``` + +--- + +## Task 3: `graphdb_class` (8 sites) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_class.erl` +- Test: existing `graphdb_class_SUITE`, `graphdb_class_tests` (no new tests — idempotency/`already_exists` branches already covered) + +**Interfaces:** +- Consumes: `graphdb_mgr:transaction/1`. +- Produces: behaviour-identical functions. **Trap 2** (`:832`) and **Trap 3** (`:704`) live here. + +- [ ] **Step 1: `do_create_class` — tagged success (txn value ignored)** + +Replace (currently `:499-503`): + +```erlang + case mnesia:transaction(Txn) of + %% Txn value is [] (abstract) or [ok,ok,ok] (template rows) + {atomic, _Writes} -> {ok, ClassNref}; + {aborted, Reason} -> {error, Reason} + end; +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + %% Txn value is [] (abstract) or [ok,ok,ok] (template rows) + {ok, _Writes} -> {ok, ClassNref}; + {error, _} = Err -> Err + end; +``` + +- [ ] **Step 2: site at `:624` — collapse idempotent** + +Replace: + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> ok; + {atomic, already_exists} -> ok; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {ok, already_exists} -> ok; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 3: `do_create_template`-style site at `:682` — tagged success** + +Replace: + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> {ok, TemplateNref}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, TemplateNref}; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 4: `do_find_template_by_name` at `:704` — projection + abort-swallow (Trap 3)** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, {value, #node{nref = Nref}}} -> {ok, Nref}; + {atomic, false} -> not_found; + {aborted, _} -> not_found + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, {value, #node{nref = Nref}}} -> {ok, Nref}; + {ok, false} -> not_found; + {error, _} -> not_found + end. +``` + +(The `{error, _} -> not_found` preserves the abort-swallow.) + +- [ ] **Step 5: identity list sites at `:738` and `:911`** + +In each, replace: + +```erlang + case mnesia:transaction(F) of + {atomic, Nodes} -> {ok, Nodes}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + graphdb_mgr:transaction(F). +``` + +- [ ] **Step 6: site at `:832` — collapse + fun-returns-`{error,_}` value (Trap 2)** + +Replace: + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> ok; + {atomic, already_exists} -> ok; + {atomic, {error, _} = E} -> E; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {ok, already_exists} -> ok; + {ok, {error, _} = E} -> E; + {error, Reason} -> {error, Reason} + end. +``` + +(**Trap 2:** the fun's `{error,_}` return rides `{ok, {error,_}}` and is unwrapped — it is NOT converted to `mnesia:abort`, so the partial work in `Txn` still commits exactly as today.) + +- [ ] **Step 7: `add_qualifying_characteristic`-style site at `:869` — unwrap-ok** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 8: Compile + grep + test + commit** + +```bash +./rebar3 compile +grep -n "mnesia:transaction" apps/graphdb/src/graphdb_class.erl # expect: no hits +./rebar3 ct --suite apps/graphdb/test/graphdb_class_SUITE +./rebar3 eunit --module graphdb_class_tests +``` +Expected: clean; grep empty; green; no test edits. + +```bash +git add apps/graphdb/src/graphdb_class.erl +git commit -m "refactor(graphdb_class): route txn sites through transaction/1" +``` + +--- + +## Task 4: `graphdb_language` (7 sites) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_language.erl` +- Test: existing `graphdb_language_SUITE`, `graphdb_language_tests` (no new tests) + +**Interfaces:** +- Consumes: `graphdb_mgr:transaction/1`. +- Produces: behaviour-identical functions. **Note:** this module indents with **spaces** at some sites (e.g. `:310`). Match the existing indentation at each site. + +- [ ] **Step 1: `set_labels` handle_call at `:310` — reply-in-handle_call** + +Replace (note 4-space indent and trailing `;`): + +```erlang + case mnesia:transaction(F) of + {atomic, ok} -> {reply, ok, State}; + {aborted, Reason} -> {reply, {error, Reason}, State} + end; +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, ok} -> {reply, ok, State}; + {error, Reason} -> {reply, {error, Reason}, State} + end; +``` + +- [ ] **Step 2: class-nref lookup at `:395` — projection + throw** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, {value, #node{nref = Nref}}} -> Nref; + {atomic, false} -> throw({error, {class_not_found, Name}}); + {aborted, R} -> throw({error, R}) + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, {value, #node{nref = Nref}}} -> Nref; + {ok, false} -> throw({error, {class_not_found, Name}}); + {error, R} -> throw({error, R}) + end. +``` + +- [ ] **Step 3: site at `:447` — tagged success + throw** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, ok} -> Nref; + {aborted, Reason} -> throw({error, Reason}) + end +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, ok} -> Nref; + {error, Reason} -> throw({error, Reason}) + end +``` + +- [ ] **Step 4: `build_lang_maps` at `:508` — tuple value + throw** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, {CM, DM}} -> {CM, DM}; + {aborted, Reason} -> throw({error, {build_lang_maps_failed, Reason}}) + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, {CM, DM}} -> {CM, DM}; + {error, Reason} -> throw({error, {build_lang_maps_failed, Reason}}) + end. +``` + +- [ ] **Step 5: `register_language` at `:629` — side-effects-after** + +Replace the arm heads only (the `{atomic, ok}` body — `ensure_overlay_table`, `NewState`, the reply — is unchanged): + +```erlang + case mnesia:transaction(F) of + {aborted, Reason} -> + {error, Reason}; + {atomic, ok} -> +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {error, Reason} -> + {error, Reason}; + {ok, ok} -> +``` + +- [ ] **Step 6: `register_dialect` at `:692` — side-effects-after** + +Same transformation as Step 5, at the `:692` site: + +```erlang + case mnesia:transaction(F) of + {aborted, Reason} -> + {error, Reason}; + {atomic, ok} -> +``` + +becomes: + +```erlang + case graphdb_mgr:transaction(F) of + {error, Reason} -> + {error, Reason}; + {ok, ok} -> +``` + +- [ ] **Step 7: lang-code lookup at `:734` — projection** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, not_found} -> not_found; + {atomic, Code} -> {ok, Code}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, not_found} -> not_found; + {ok, Code} -> {ok, Code}; + {error, Reason} -> {error, Reason} + end. +``` + +- [ ] **Step 8: Compile + grep + test + commit** + +```bash +./rebar3 compile +grep -n "mnesia:transaction" apps/graphdb/src/graphdb_language.erl # expect: no hits +./rebar3 ct --suite apps/graphdb/test/graphdb_language_SUITE +./rebar3 eunit --module graphdb_language_tests +``` +Expected: clean; grep empty; green; no test edits. + +```bash +git add apps/graphdb/src/graphdb_language.erl +git commit -m "refactor(graphdb_language): route txn sites through transaction/1" +``` + +--- + +## Task 5: `graphdb_rules` (3 sites) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_rules.erl` +- Test: existing `graphdb_rules_SUITE` (no new tests) + +**Interfaces:** +- Consumes: `graphdb_mgr:transaction/1`. +- Produces: behaviour-identical functions. + +- [ ] **Step 1: seed helper at `:608` — tagged success + throw** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, ok} -> Nref; + {aborted, Reason} -> throw({error, Reason}) + end +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, ok} -> Nref; + {error, Reason} -> throw({error, Reason}) + end +``` + +- [ ] **Step 2: class-by-name find at `:654` — projection + throw** + +Replace: + +```erlang + case mnesia:transaction(F) of + {atomic, {value, #node{nref = Nref}}} -> {ok, Nref}; + {atomic, false} -> not_found; + {aborted, Reason} -> throw({error, Reason}) + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, {value, #node{nref = Nref}}} -> {ok, Nref}; + {ok, false} -> not_found; + {error, Reason} -> throw({error, Reason}) + end. +``` + +- [ ] **Step 3: rule-create commit at `:857` — tagged success** + +Replace: + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> {ok, RuleNref}; + {aborted, Reason} -> {error, Reason} + end +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, RuleNref}; + {error, Reason} -> {error, Reason} + end +``` + +- [ ] **Step 4: Compile + grep + test + commit** + +```bash +./rebar3 compile +grep -n "mnesia:transaction" apps/graphdb/src/graphdb_rules.erl # expect: no hits +./rebar3 ct --suite apps/graphdb/test/graphdb_rules_SUITE +``` +Expected: clean; grep empty; green; no test edits. + +```bash +git add apps/graphdb/src/graphdb_rules.erl +git commit -m "refactor(graphdb_rules): route txn sites through transaction/1" +``` + +--- + +## Task 6: `graphdb_instance` (7 sites + the only 2 new tests) + +**Files:** +- Modify: `apps/graphdb/test/graphdb_instance_SUITE.erl` (add 2 coverage tests + register them) +- Modify: `apps/graphdb/src/graphdb_instance.erl` (7 conversion sites incl. the abort-relocation trap) +- Test: `graphdb_instance_SUITE`, `graphdb_instance_tests` + +**Interfaces:** +- Consumes: `graphdb_mgr:transaction/1`. +- Produces: behaviour-identical functions. **Trap 4** (`validate_arc_endpoints`) and **Trap 3** (`resolve_from_connected`) live here. + +**Why tests first:** `validate_arc_endpoints` is reshaped so its domain failures are produced via `mnesia:abort` instead of an outside-the-fun tuple match. Six of its eight error arms are already asserted (`source_not_found`, `target_not_found`, `characterization_not_an_attribute`, `reciprocal_not_an_attribute`, `target_kind_mismatch`, `endpoint_retired`). Two are not: `characterization_not_found` and `reciprocal_not_found`. Lock those two with characterization tests **before** reshaping the function. + +- [ ] **Step 1: Write the two coverage tests** + +In `apps/graphdb/test/graphdb_instance_SUITE.erl`, add these two functions immediately after `add_relationship_rejects_missing_target/1` (near suite line 794), matching the existing idiom: + +```erlang +%%----------------------------------------------------------------------------- +%% missing characterization nref is rejected. +%%----------------------------------------------------------------------------- +add_relationship_rejects_missing_characterization(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("Thing", 3), + {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), + ?assertEqual({error, {characterization_not_found, 99999}}, + graphdb_instance:add_relationship(A, 99999, B, Recip)). + +%%----------------------------------------------------------------------------- +%% missing reciprocal nref is rejected. +%%----------------------------------------------------------------------------- +add_relationship_rejects_missing_reciprocal(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("Thing", 3), + {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), + ?assertEqual({error, {reciprocal_not_found, 99999}}, + graphdb_instance:add_relationship(A, Char, B, 99999)). +``` + +- [ ] **Step 2: Register the two tests** + +Add both to the `-export([...])` block (next to `add_relationship_rejects_missing_target/1`, suite line ~84): + +```erlang + add_relationship_rejects_missing_characterization/1, + add_relationship_rejects_missing_reciprocal/1, +``` + +and to the test list returned by the group/`all` near the existing `add_relationship_rejects_*` entries (suite line ~227): + +```erlang + add_relationship_rejects_missing_characterization, + add_relationship_rejects_missing_reciprocal, +``` + +- [ ] **Step 3: Run the two new tests against UNCHANGED source — confirm they PASS** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case add_relationship_rejects_missing_characterization --case add_relationship_rejects_missing_reciprocal` +Expected: both PASS. (They characterize behaviour the current code already produces; they are the regression net for Step 5.) + +- [ ] **Step 4: Commit the coverage tests** + +```bash +git add apps/graphdb/test/graphdb_instance_SUITE.erl +git commit -m "test(graphdb_instance): cover add_relationship missing char/reciprocal arms" +``` + +- [ ] **Step 5: Convert `validate_arc_endpoints/6` — abort-relocation (Trap 4)** + +Replace the entire function body (the `F = fun() ... end,` and the trailing `case mnesia:transaction(F) of ... end`, currently `:1231-1270`) with — moving every domain decision inside the fun via `mnesia:abort(ExactReason)`: + +```erlang +validate_arc_endpoints(SourceNref, CharNref, TargetNref, ReciprocalNref, + TkAttr, RetAttr) -> + F = fun() -> + Source = mnesia:read(nodes, SourceNref), + Target = mnesia:read(nodes, TargetNref), + Char = mnesia:read(nodes, CharNref), + Recip = mnesia:read(nodes, ReciprocalNref), + case {Source, Target, Char, Recip} of + {[], _, _, _} -> + mnesia:abort({source_not_found, SourceNref}); + {_, [], _, _} -> + mnesia:abort({target_not_found, TargetNref}); + {_, _, [], _} -> + mnesia:abort({characterization_not_found, CharNref}); + {_, _, _, []} -> + mnesia:abort({reciprocal_not_found, ReciprocalNref}); + {[#node{attribute_value_pairs = SAVPs}], + [#node{kind = TKind, attribute_value_pairs = TAVPs}], + [#node{kind = CKind, attribute_value_pairs = CAVPs} = CharNode], + [#node{kind = RKind, attribute_value_pairs = RAVPs}]} -> + case first_retired([{SourceNref, SAVPs}, {TargetNref, TAVPs}, + {CharNref, CAVPs}, {ReciprocalNref, RAVPs}], + RetAttr) of + {retired, RNref} -> + mnesia:abort({endpoint_retired, RNref}); + none -> + case {CKind, RKind} of + {attribute, attribute} -> + case check_target_kind(CharNode, TKind, TkAttr) of + ok -> ok; + {error, Reason} -> mnesia:abort(Reason) + end; + {attribute, _} -> + mnesia:abort({reciprocal_not_an_attribute, + ReciprocalNref, RKind}); + {_, _} -> + mnesia:abort({characterization_not_an_attribute, + CharNref, CKind}) + end + end + end + end, + case graphdb_mgr:transaction(F) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. +``` + +Every abort term matches the original `{error, Reason}` term exactly, so the public error contract is unchanged. The fun is read-only → retry-safe. + +- [ ] **Step 6: Convert `execute/5` — result-building on both arms** + +Replace (currently `:579-588`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> + {ok, RootNref, + merge_reports(CompOutcomes, ConnReport), + InstPlan, AutoConnPlan}; + {aborted, R} -> + {error, R, + report_not_attempted(R, + #{plan_so_far => PlanTree, culprit => undefined})} + end; +``` + +with (only the two arm heads change): + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> + {ok, RootNref, + merge_reports(CompOutcomes, ConnReport), + InstPlan, AutoConnPlan}; + {error, R} -> + {error, R, + report_not_attempted(R, + #{plan_so_far => PlanTree, culprit => undefined})} + end; +``` + +- [ ] **Step 7: Convert `write_connection_arcs/6` — unwrap-ok** + +Replace (currently `:1394-1397`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 8: Convert `do_write_class_membership/2` — collapse idempotent** + +Replace (currently `:1453-1457`): + +```erlang + case mnesia:transaction(Txn) of + {atomic, ok} -> ok; + {atomic, already_exists} -> ok; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {ok, already_exists} -> ok; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 9: Convert `do_class_of/1` — projection at wrapper** + +Replace (currently `:1487-1492`): + +```erlang + case mnesia:transaction(F) of + {atomic, {value, #relationship{target_nref = ClassNref}}} -> + {ok, ClassNref}; + {atomic, false} -> not_found; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + case graphdb_mgr:transaction(F) of + {ok, {value, #relationship{target_nref = ClassNref}}} -> + {ok, ClassNref}; + {ok, false} -> not_found; + {error, Reason} -> {error, Reason} + end. +``` + +- [ ] **Step 10: Convert `do_children/1` — identity** + +Replace (currently `:1518-1521`): + +```erlang + case mnesia:transaction(F) of + {atomic, Nodes} -> {ok, Nodes}; + {aborted, Reason} -> {error, Reason} + end. +``` + +with: + +```erlang + graphdb_mgr:transaction(F). +``` + +- [ ] **Step 11: Convert `resolve_from_connected/2` — side-effects-after + abort-swallow (Trap 3)** + +Replace (currently `:1760-1768`): + +```erlang + case mnesia:transaction(F) of + {atomic, Rels} -> + TargetNrefs = lists:usort( + [R#relationship.target_nref + || R <- Rels, R#relationship.kind =:= connection]), + search_targets(TargetNrefs, AttrNref); + {aborted, _} -> + not_found + end. +``` + +with (only the two arm heads change): + +```erlang + case graphdb_mgr:transaction(F) of + {ok, Rels} -> + TargetNrefs = lists:usort( + [R#relationship.target_nref + || R <- Rels, R#relationship.kind =:= connection]), + search_targets(TargetNrefs, AttrNref); + {error, _} -> + not_found + end. +``` + +- [ ] **Step 12: Compile + grep** + +```bash +./rebar3 compile +grep -n "mnesia:transaction" apps/graphdb/src/graphdb_instance.erl # expect: no hits +``` +Expected: clean compile, no warnings; grep empty. + +- [ ] **Step 13: Run the instance suites — including the 2 new tests still green after reshaping** + +```bash +./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE +./rebar3 eunit --module graphdb_instance_tests +``` +Expected: all pass (the two new tests prove the relocated `characterization_not_found` / `reciprocal_not_found` arms behave identically post-reshape); no edits to existing expectations. + +- [ ] **Step 14: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "refactor(graphdb_instance): route txn sites through transaction/1" +``` + +--- + +## Task 7: Final verification + +**Files:** none modified (verification only). + +- [ ] **Step 1: Global grep gate** + +Run: `grep -rn "mnesia:transaction" apps/graphdb/src/` +Expected: exactly one line — the `transaction/1` definition in `graphdb_mgr.erl` (`case mnesia:transaction(Fun) of`). Any other hit is an unconverted site. + +- [ ] **Step 2: Full compile** + +Run: `./rebar3 compile` +Expected: clean, zero warnings. + +- [ ] **Step 3: Full test suite (the behaviour oracle)** + +```bash +make test-ct-parallel +./rebar3 eunit +``` +Expected: CT aggregate PASS (exit 0) at **434 CT** (432 prior + the 2 new instance CT cases); EUnit unchanged at `All 105 tests passed.` (the 2 new tests are CT, not EUnit). Total 434 + 105 = 539. **No existing test expectation was edited** — confirm via `git diff --stat ..HEAD` that the only test file touched is `graphdb_instance_SUITE.erl` and only by additions. + +- [ ] **Step 4: Update CLAUDE.md / docs if needed** + +No `docs/Architecture.md` update required: this is an internal refactor with no schema, supervision-tree, or public-contract change. Confirm no doc edit is needed and note it in the PR description. + +--- + +## Self-review notes (for the executor) + +- **Spec coverage:** all 40 inventory sites map to a task step (Task 1: 5; Task 2: 9; Task 3: 8; Task 4: 7; Task 5: 3; Task 6: 7 — total 39 conversion sites + the `transaction/1` definition itself which is left as the single mapping point = 40 `mnesia:transaction` occurrences). The four traps are each flagged at their site (Trap 2 → Task 3 Step 6; Trap 3 → Task 3 Step 4 and Task 6 Step 11; Trap 4 → Task 6 Step 5; Trap 1 → throw sites in Tasks 2/4/5 and the reply site in Task 4 Step 1). +- **Only 2 new tests**, both in Task 6, both with full code; every other task asserts "no test edits". +- **Type/return consistency:** every converted wrapper returns the same public shape as before its conversion (identity sites return `transaction/1`'s `{ok,_}|{error,_}` directly, which equals the old `{ok,_}|{error,_}`). From 79d953f91b0603ae5c023b70d3706194c2863e64 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:38:04 -0400 Subject: [PATCH 05/12] refactor(graphdb_mgr,bootstrap): route txn sites through transaction/1 --- apps/graphdb/src/graphdb_bootstrap.erl | 4 ++-- apps/graphdb/src/graphdb_mgr.erl | 28 ++++++++++---------------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/graphdb/src/graphdb_bootstrap.erl b/apps/graphdb/src/graphdb_bootstrap.erl index 273bce7..220868a 100644 --- a/apps/graphdb/src/graphdb_bootstrap.erl +++ b/apps/graphdb/src/graphdb_bootstrap.erl @@ -506,7 +506,7 @@ validate_no_unresolved_labels(Nodes, Rels) -> write_nodes(Nodes) -> lists:foreach(fun(NodeTerm) -> Record = term_to_node(NodeTerm), - {atomic, ok} = mnesia:transaction(fun() -> + {ok, ok} = graphdb_mgr:transaction(fun() -> ok = mnesia:write(nodes, Record, write) end) end, Nodes), @@ -543,7 +543,7 @@ term_to_node({node, Nref, Kind, {NameAttrNref, NameValue}, ExtraAVPs}) -> write_relationships(Rels) -> lists:foreach(fun(RelTerm) -> {Row1, Row2} = expand_relationship(RelTerm), - {atomic, ok} = mnesia:transaction(fun() -> + {ok, ok} = graphdb_mgr:transaction(fun() -> ok = mnesia:write(relationships, Row1, write), ok = mnesia:write(relationships, Row2, write) end) diff --git a/apps/graphdb/src/graphdb_mgr.erl b/apps/graphdb/src/graphdb_mgr.erl index 8d12ff2..e80dccb 100644 --- a/apps/graphdb/src/graphdb_mgr.erl +++ b/apps/graphdb/src/graphdb_mgr.erl @@ -314,10 +314,10 @@ verify_caches() -> Nrefs = mnesia:all_keys(nodes), lists:flatmap(fun verify_one/1, Nrefs) end, - case mnesia:transaction(Txn) of - {atomic, []} -> ok; - {atomic, Mismatches} -> {error, Mismatches}; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, []} -> ok; + {ok, Mismatches} -> {error, Mismatches}; + {error, _} = Err -> Err end. @@ -335,9 +335,9 @@ rebuild_caches() -> lists:foreach(fun rebuild_one/1, Nrefs), ok end, - case mnesia:transaction(Txn) of - {atomic, ok} -> ok; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {error, _} = Err -> Err end. @@ -499,19 +499,13 @@ do_get_node(Nref) -> %% both -- union of outgoing and incoming %%----------------------------------------------------------------------------- do_get_relationships(Nref, outgoing) -> - case mnesia:transaction(fun() -> + graphdb_mgr:transaction(fun() -> mnesia:index_read(relationships, Nref, #relationship.source_nref) - end) of - {atomic, Rels} -> {ok, Rels}; - {aborted, Reason} -> {error, Reason} - end; + end); do_get_relationships(Nref, incoming) -> - case mnesia:transaction(fun() -> + graphdb_mgr:transaction(fun() -> mnesia:index_read(relationships, Nref, #relationship.target_nref) - end) of - {atomic, Rels} -> {ok, Rels}; - {aborted, Reason} -> {error, Reason} - end; + end); do_get_relationships(Nref, both) -> case {do_get_relationships(Nref, outgoing), do_get_relationships(Nref, incoming)} of From b9e31e8de2cd32b7cb067a5c430250f1e992e2b3 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:42:40 -0400 Subject: [PATCH 06/12] refactor(graphdb_attr): route txn sites through transaction/1 --- apps/graphdb/src/graphdb_attr.erl | 67 ++++++++++++++----------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/apps/graphdb/src/graphdb_attr.erl b/apps/graphdb/src/graphdb_attr.erl index c8f0b42..c476069 100644 --- a/apps/graphdb/src/graphdb_attr.erl +++ b/apps/graphdb/src/graphdb_attr.erl @@ -494,12 +494,14 @@ find_attribute_by_name(ParentNref, Name) -> F = fun() -> Children = downward_children_by_arc(ParentNref, ?ARC_ATTR_CHILD, taxonomy), - lists:search(fun(N) -> node_has_name(N, Name) end, Children) + case lists:search(fun(N) -> node_has_name(N, Name) end, Children) of + {value, #node{nref = Nref}} -> {ok, Nref}; + false -> not_found + end end, - case mnesia:transaction(F) of - {atomic, {value, #node{nref = Nref}}} -> {ok, Nref}; - {atomic, false} -> not_found; - {aborted, Reason} -> throw({error, Reason}) + case graphdb_mgr:transaction(F) of + {ok, Result} -> Result; + {error, Reason} -> throw({error, Reason}) end. @@ -559,9 +561,9 @@ do_create_attribute(Name, ParentNref, ExtraAVPs) -> ok = mnesia:write(relationships, P2C, write), ok = mnesia:write(relationships, C2P, write) end, - case mnesia:transaction(Txn) of - {atomic, ok} -> {ok, Nref}; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, Nref}; + {error, _} = Err -> Err end. @@ -654,9 +656,9 @@ do_create_relationship_attribute_pair(FwdName, RevName, ExtraAVPs, ParentNref) - ok = mnesia:write(relationships, RevP2C, write), ok = mnesia:write(relationships, RevC2P, write) end, - case mnesia:transaction(Txn) of - {atomic, ok} -> {ok, {FwdNref, RevNref}}; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, {FwdNref, RevNref}}; + {error, _} = Err -> Err end. @@ -682,11 +684,11 @@ validate_parent(ParentNref) -> %% {ok, #node{}} | {error, not_found | not_an_attribute | term()} %%----------------------------------------------------------------------------- do_get_attribute(Nref) -> - case mnesia:transaction(fun() -> mnesia:read(nodes, Nref) end) of - {atomic, [#node{kind = attribute} = Node]} -> {ok, Node}; - {atomic, [_Other]} -> {error, not_an_attribute}; - {atomic, []} -> {error, not_found}; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(fun() -> mnesia:read(nodes, Nref) end) of + {ok, [#node{kind = attribute} = Node]} -> {ok, Node}; + {ok, [_Other]} -> {error, not_an_attribute}; + {ok, []} -> {error, not_found}; + {error, Reason} -> {error, Reason} end. @@ -697,10 +699,7 @@ do_list_attributes() -> F = fun() -> mnesia:match_object(nodes, #node{_ = '_', kind = attribute}, read) end, - case mnesia:transaction(F) of - {atomic, Nodes} -> {ok, Nodes}; - {aborted, Reason} -> {error, Reason} - end. + graphdb_mgr:transaction(F). %%----------------------------------------------------------------------------- @@ -710,10 +709,7 @@ do_list_children(ParentNref) -> F = fun() -> downward_children_by_arc(ParentNref, ?ARC_ATTR_CHILD, taxonomy) end, - case mnesia:transaction(F) of - {atomic, Nodes} -> {ok, Nodes}; - {aborted, Reason} -> {error, Reason} - end. + graphdb_mgr:transaction(F). %%----------------------------------------------------------------------------- @@ -752,15 +748,15 @@ attr_type_avp(Kind, #state{attribute_type_nref = AtAttr}) %%----------------------------------------------------------------------------- do_attribute_type_of(Nref, AtAttrNref) -> F = fun() -> mnesia:read(nodes, Nref) end, - case mnesia:transaction(F) of - {atomic, [#node{kind = attribute, attribute_value_pairs = AVPs}]} -> + case graphdb_mgr:transaction(F) of + {ok, [#node{kind = attribute, attribute_value_pairs = AVPs}]} -> case find_attribute_type_value(AtAttrNref, AVPs) of {ok, Kind} -> {ok, Kind}; not_found -> {error, no_attribute_type} end; - {atomic, [_Other]} -> {error, not_an_attribute}; - {atomic, []} -> {error, not_found}; - {aborted, Reason} -> {error, Reason} + {ok, [_Other]} -> {error, not_an_attribute}; + {ok, []} -> {error, not_found}; + {error, Reason} -> {error, Reason} end. find_attribute_type_value(_AtAttrNref, []) -> @@ -796,10 +792,9 @@ retro_stamp_bootstrap_attribute_types(AtAttrNref) -> fun(N) -> stamp_attribute_type_if_missing(N, AtAttrNref) end, Attrs) end, - case mnesia:transaction(Txn) of - {atomic, ok} -> ok; - {atomic, _Other} -> ok; - {aborted, Reason} -> throw({error, Reason}) + case graphdb_mgr:transaction(Txn) of + {ok, _} -> ok; + {error, Reason} -> throw({error, Reason}) end. stamp_attribute_type_if_missing(#node{nref = Nref, @@ -880,7 +875,7 @@ ensure_template_avp_marker(RelAvpAttrNref) -> throw({error, {template_avp_node_missing, ?ARC_TEMPLATE}}) end end, - case mnesia:transaction(Txn) of - {atomic, ok} -> ok; - {aborted, Reason} -> throw({error, Reason}) + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {error, Reason} -> throw({error, Reason}) end. From 5ad95fa2f9e799740310ad66e3dcdc79f09163b2 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:47:18 -0400 Subject: [PATCH 07/12] refactor(graphdb_class): route txn sites through transaction/1 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_class.erl | 54 +++++++++++++----------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/apps/graphdb/src/graphdb_class.erl b/apps/graphdb/src/graphdb_class.erl index 651746d..2c50dde 100644 --- a/apps/graphdb/src/graphdb_class.erl +++ b/apps/graphdb/src/graphdb_class.erl @@ -496,10 +496,10 @@ do_create_class(Name, ParentClassNref, AVPs, InstAttr) -> ok = mnesia:write(relationships, TaxC2P, write), [ ok = mnesia:write(T, R, write) || {T, R} <- TemplateRows ] end, - case mnesia:transaction(Txn) of + case graphdb_mgr:transaction(Txn) of %% Txn value is [] (abstract) or [ok,ok,ok] (template rows) - {atomic, _Writes} -> {ok, ClassNref}; - {aborted, Reason} -> {error, Reason} + {ok, _Writes} -> {ok, ClassNref}; + {error, _} = Err -> Err end; {error, _} = Err -> Err @@ -621,10 +621,10 @@ do_write_superclass(ClassNref, AdditionalParentNref) -> ok end end, - case mnesia:transaction(Txn) of - {atomic, ok} -> ok; - {atomic, already_exists} -> ok; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {ok, already_exists} -> ok; + {error, _} = Err -> Err end. @@ -679,9 +679,9 @@ do_write_template(ClassNref, Name) -> ok = mnesia:write(relationships, P2C, write), ok = mnesia:write(relationships, C2P, write) end, - case mnesia:transaction(Txn) of - {atomic, ok} -> {ok, TemplateNref}; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, TemplateNref}; + {error, _} = Err -> Err end. @@ -701,10 +701,10 @@ do_find_template_by_name(ClassNref, Name) -> (_) -> false end, Children) end, - case mnesia:transaction(F) of - {atomic, {value, #node{nref = Nref}}} -> {ok, Nref}; - {atomic, false} -> not_found; - {aborted, _} -> not_found + case graphdb_mgr:transaction(F) of + {ok, {value, #node{nref = Nref}}} -> {ok, Nref}; + {ok, false} -> not_found; + {error, _} -> not_found end. template_has_name(#node{attribute_value_pairs = AVPs}, Name) -> @@ -735,10 +735,7 @@ do_templates_for_class(ClassNref) -> composition), [N || N <- Children, N#node.kind =:= template] end, - case mnesia:transaction(F) of - {atomic, Nodes} -> {ok, Nodes}; - {aborted, Reason} -> {error, Reason} - end. + graphdb_mgr:transaction(F). %%----------------------------------------------------------------------------- @@ -829,11 +826,11 @@ do_add_qc(ClassNref, AttrNref) -> {error, not_found} end end, - case mnesia:transaction(Txn) of - {atomic, ok} -> ok; - {atomic, already_exists} -> ok; - {atomic, {error, _} = E} -> E; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {ok, already_exists} -> ok; + {ok, {error, _} = E} -> E; + {error, Reason} -> {error, Reason} end. @@ -866,9 +863,9 @@ do_bind_qc_value(ClassNref, AttrNref, Value) -> [] -> mnesia:abort(not_found) end end, - case mnesia:transaction(F) of - {atomic, ok} -> ok; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(F) of + {ok, ok} -> ok; + {error, _} = Err -> Err end. %%----------------------------------------------------------------------------- @@ -908,10 +905,7 @@ do_subclasses(ClassNref) -> taxonomy), [N || N <- Children, N#node.kind =:= class] end, - case mnesia:transaction(F) of - {atomic, Nodes} -> {ok, Nodes}; - {aborted, Reason} -> {error, Reason} - end. + graphdb_mgr:transaction(F). %%----------------------------------------------------------------------------- From dd6db217ab4a24e98ae5b163b3c54b961c701ecc Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:52:51 -0400 Subject: [PATCH 08/12] refactor(graphdb_language): route txn sites through transaction/1 --- apps/graphdb/src/graphdb_language.erl | 46 +++++++++++++-------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/graphdb/src/graphdb_language.erl b/apps/graphdb/src/graphdb_language.erl index f3ec186..98d280b 100644 --- a/apps/graphdb/src/graphdb_language.erl +++ b/apps/graphdb/src/graphdb_language.erl @@ -307,9 +307,9 @@ handle_call({set_labels, Nref, Code, NewAVPs}, _From, State) -> mnesia:write(Table, #language_node{nref = Nref, avps = Merged}, write) end, - case mnesia:transaction(F) of - {atomic, ok} -> {reply, ok, State}; - {aborted, Reason} -> {reply, {error, Reason}, State} + case graphdb_mgr:transaction(F) of + {ok, ok} -> {reply, ok, State}; + {error, Reason} -> {reply, {error, Reason}, State} end; handle_call({resolve_label, Nref, AttrNref, Chain, Scope}, _From, State) -> Reply = do_resolve_label(Nref, AttrNref, Chain, Scope), @@ -392,10 +392,10 @@ find_class_by_name(ParentNref, Name) -> taxonomy), lists:search(fun(N) -> class_has_name(N, Name) end, Children) end, - case mnesia:transaction(F) of - {atomic, {value, #node{nref = Nref}}} -> Nref; - {atomic, false} -> throw({error, {class_not_found, Name}}); - {aborted, R} -> throw({error, R}) + case graphdb_mgr:transaction(F) of + {ok, {value, #node{nref = Nref}}} -> Nref; + {ok, false} -> throw({error, {class_not_found, Name}}); + {error, R} -> throw({error, R}) end. @@ -444,9 +444,9 @@ ensure_literal_seed(Name, ParentNref) -> ok = mnesia:write(relationships, P2C, write), ok = mnesia:write(relationships, C2P, write) end, - case mnesia:transaction(F) of - {atomic, ok} -> Nref; - {aborted, Reason} -> throw({error, Reason}) + case graphdb_mgr:transaction(F) of + {ok, ok} -> Nref; + {error, Reason} -> throw({error, Reason}) end end. @@ -505,9 +505,9 @@ build_lang_maps(LangCodeNref, BaseLangNref, LangHumanNref) -> end, #{}, Nodes), {CM, DM} end, - case mnesia:transaction(F) of - {atomic, {CM, DM}} -> {CM, DM}; - {aborted, Reason} -> throw({error, {build_lang_maps_failed, Reason}}) + case graphdb_mgr:transaction(F) of + {ok, {CM, DM}} -> {CM, DM}; + {error, Reason} -> throw({error, {build_lang_maps_failed, Reason}}) end. @@ -626,10 +626,10 @@ do_register_language(Code, Name, State) -> ok = mnesia:write(relationships, I2C, write), ok = mnesia:write(relationships, C2I, write) end, - case mnesia:transaction(F) of - {aborted, Reason} -> + case graphdb_mgr:transaction(F) of + {error, Reason} -> {error, Reason}; - {atomic, ok} -> + {ok, ok} -> ok = ensure_overlay_table(overlay_table_name(Code, environment)), NewState = State#state{ lang_code_map = maps:put(Code, Nref, State#state.lang_code_map) @@ -689,10 +689,10 @@ do_register_dialect(Code, Name, BaseCode, State) -> ok = mnesia:write(relationships, I2C, write), ok = mnesia:write(relationships, C2I, write) end, - case mnesia:transaction(F) of - {aborted, Reason} -> + case graphdb_mgr:transaction(F) of + {error, Reason} -> {error, Reason}; - {atomic, ok} -> + {ok, ok} -> ok = ensure_overlay_table( overlay_table_name(Code, environment)), NewState = State#state{ @@ -731,10 +731,10 @@ do_project_language(ProjectRootNref, PLAttr, LCAttr) -> not_found end end, - case mnesia:transaction(F) of - {atomic, not_found} -> not_found; - {atomic, Code} -> {ok, Code}; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(F) of + {ok, not_found} -> not_found; + {ok, Code} -> {ok, Code}; + {error, Reason} -> {error, Reason} end. From c5a3b4f4c32ffecad10a6ca7481b7371eebacedb Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 10:57:57 -0400 Subject: [PATCH 09/12] refactor(graphdb_rules): route txn sites through transaction/1 --- apps/graphdb/src/graphdb_rules.erl | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/graphdb/src/graphdb_rules.erl b/apps/graphdb/src/graphdb_rules.erl index 2785d7f..9e9f343 100644 --- a/apps/graphdb/src/graphdb_rules.erl +++ b/apps/graphdb/src/graphdb_rules.erl @@ -605,9 +605,9 @@ ensure_seed(Name, ParentNref) -> ok = mnesia:write(relationships, P2C, write), ok = mnesia:write(relationships, C2P, write) end, - case mnesia:transaction(F) of - {atomic, ok} -> Nref; - {aborted, Reason} -> throw({error, Reason}) + case graphdb_mgr:transaction(F) of + {ok, ok} -> Nref; + {error, Reason} -> throw({error, Reason}) end end. @@ -651,10 +651,10 @@ find_subclass_by_name(ParentNref, Name) -> Nodes = lists:flatmap(fun(N) -> mnesia:read(nodes, N) end, Nrefs), lists:search(fun(N) -> class_has_name(N, Name) end, Nodes) end, - case mnesia:transaction(F) of - {atomic, {value, #node{nref = Nref}}} -> {ok, Nref}; - {atomic, false} -> not_found; - {aborted, Reason} -> throw({error, Reason}) + case graphdb_mgr:transaction(F) of + {ok, {value, #node{nref = Nref}}} -> {ok, Nref}; + {ok, false} -> not_found; + {error, Reason} -> throw({error, Reason}) end. class_has_name(#node{attribute_value_pairs = AVPs}, Name) -> @@ -854,9 +854,9 @@ do_create_rule(MetaClassNref, Name, OwningClass, ContentAVPs, Mode, Mult, ok = mnesia:write(relationships, AppliesTo, write), ok = mnesia:write(relationships, AppliedBy, write) end, - case mnesia:transaction(Txn) of - {atomic, ok} -> {ok, RuleNref}; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, RuleNref}; + {error, Reason} -> {error, Reason} end end. From 7e216407d295a76b0486964f23ca605b734609e8 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 11:03:31 -0400 Subject: [PATCH 10/12] test(graphdb_instance): cover add_relationship missing char/reciprocal arms --- apps/graphdb/test/graphdb_instance_SUITE.erl | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index cceb868..c03dc3b 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -80,6 +80,8 @@ add_relationship_no_default_after_delete/1, add_relationship_rejects_missing_source/1, add_relationship_rejects_missing_target/1, + add_relationship_rejects_missing_characterization/1, + add_relationship_rejects_missing_reciprocal/1, add_relationship_rejects_non_attribute_char/1, add_relationship_rejects_non_attribute_reciprocal/1, add_relationship_rejects_target_kind_mismatch/1, @@ -223,6 +225,8 @@ groups() -> add_relationship_no_default_after_delete, add_relationship_rejects_missing_source, add_relationship_rejects_missing_target, + add_relationship_rejects_missing_characterization, + add_relationship_rejects_missing_reciprocal, add_relationship_rejects_non_attribute_char, add_relationship_rejects_non_attribute_reciprocal, add_relationship_rejects_target_kind_mismatch, @@ -793,6 +797,30 @@ add_relationship_rejects_missing_target(_Config) -> ?assertEqual({error, {target_not_found, 99999}}, graphdb_instance:add_relationship(A, Char, 99999, Recip)). +%%----------------------------------------------------------------------------- +%% missing characterization nref is rejected. +%%----------------------------------------------------------------------------- +add_relationship_rejects_missing_characterization(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("Thing", 3), + {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), + ?assertEqual({error, {characterization_not_found, 99999}}, + graphdb_instance:add_relationship(A, 99999, B, Recip)). + +%%----------------------------------------------------------------------------- +%% missing reciprocal nref is rejected. +%%----------------------------------------------------------------------------- +add_relationship_rejects_missing_reciprocal(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("Thing", 3), + {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), + ?assertEqual({error, {reciprocal_not_found, 99999}}, + graphdb_instance:add_relationship(A, Char, B, 99999)). + %%----------------------------------------------------------------------------- %% characterization that is not kind=attribute is rejected. Uses %% the bootstrap Projects category (nref 5) as a non-attribute node. From 7ffeb59fc81b97d3c8e7d5b5fb00cc3bd01fbe8f Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 11:06:04 -0400 Subject: [PATCH 11/12] refactor(graphdb_instance): route txn sites through transaction/1 --- apps/graphdb/src/graphdb_instance.erl | 109 +++++++++++++------------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index d7ce795..a34a376 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -576,12 +576,12 @@ execute(RootName, _RootClass, RootParent, Ctx, PlanTree) -> fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, Writes ++ MandRows) end, - case mnesia:transaction(Txn) of - {atomic, ok} -> + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> {ok, RootNref, merge_reports(CompOutcomes, ConnReport), InstPlan, AutoConnPlan}; - {aborted, R} -> + {error, R} -> {error, R, report_not_attempted(R, #{plan_so_far => PlanTree, culprit => undefined})} @@ -1233,40 +1233,44 @@ validate_arc_endpoints(SourceNref, CharNref, TargetNref, ReciprocalNref, Target = mnesia:read(nodes, TargetNref), Char = mnesia:read(nodes, CharNref), Recip = mnesia:read(nodes, ReciprocalNref), - {Source, Target, Char, Recip} + case {Source, Target, Char, Recip} of + {[], _, _, _} -> + mnesia:abort({source_not_found, SourceNref}); + {_, [], _, _} -> + mnesia:abort({target_not_found, TargetNref}); + {_, _, [], _} -> + mnesia:abort({characterization_not_found, CharNref}); + {_, _, _, []} -> + mnesia:abort({reciprocal_not_found, ReciprocalNref}); + {[#node{attribute_value_pairs = SAVPs}], + [#node{kind = TKind, attribute_value_pairs = TAVPs}], + [#node{kind = CKind, attribute_value_pairs = CAVPs} = CharNode], + [#node{kind = RKind, attribute_value_pairs = RAVPs}]} -> + case first_retired([{SourceNref, SAVPs}, {TargetNref, TAVPs}, + {CharNref, CAVPs}, {ReciprocalNref, RAVPs}], + RetAttr) of + {retired, RNref} -> + mnesia:abort({endpoint_retired, RNref}); + none -> + case {CKind, RKind} of + {attribute, attribute} -> + case check_target_kind(CharNode, TKind, TkAttr) of + ok -> ok; + {error, Reason} -> mnesia:abort(Reason) + end; + {attribute, _} -> + mnesia:abort({reciprocal_not_an_attribute, + ReciprocalNref, RKind}); + {_, _} -> + mnesia:abort({characterization_not_an_attribute, + CharNref, CKind}) + end + end + end end, - case mnesia:transaction(F) of - {atomic, {[], _, _, _}} -> - {error, {source_not_found, SourceNref}}; - {atomic, {_, [], _, _}} -> - {error, {target_not_found, TargetNref}}; - {atomic, {_, _, [], _}} -> - {error, {characterization_not_found, CharNref}}; - {atomic, {_, _, _, []}} -> - {error, {reciprocal_not_found, ReciprocalNref}}; - {atomic, {[#node{attribute_value_pairs = SAVPs}], - [#node{kind = TKind, attribute_value_pairs = TAVPs}], - [#node{kind = CKind, attribute_value_pairs = CAVPs} = CharNode], - [#node{kind = RKind, attribute_value_pairs = RAVPs}]}} -> - case first_retired([{SourceNref, SAVPs}, {TargetNref, TAVPs}, - {CharNref, CAVPs}, {ReciprocalNref, RAVPs}], - RetAttr) of - {retired, RNref} -> - {error, {endpoint_retired, RNref}}; - none -> - case {CKind, RKind} of - {attribute, attribute} -> - check_target_kind(CharNode, TKind, TkAttr); - {attribute, _} -> - {error, {reciprocal_not_an_attribute, ReciprocalNref, - RKind}}; - {_, _} -> - {error, {characterization_not_an_attribute, CharNref, - CKind}} - end - end; - {aborted, Reason} -> - {error, Reason} + case graphdb_mgr:transaction(F) of + {ok, ok} -> ok; + {error, _} = Err -> Err end. %% first_retired([{Nref, AVPs}], RetAttr) -> {retired, Nref} | none @@ -1391,9 +1395,9 @@ write_connection_arcs(SourceNref, CharNref, TargetNref, ReciprocalNref, lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, Rows) end, - case mnesia:transaction(Txn) of - {atomic, ok} -> ok; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {error, _} = Err -> Err end. @@ -1450,10 +1454,10 @@ do_write_class_membership(InstanceNref, ClassNref) -> ok end end, - case mnesia:transaction(Txn) of - {atomic, ok} -> ok; - {atomic, already_exists} -> ok; - {aborted, Reason} -> {error, Reason} + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {ok, already_exists} -> ok; + {error, _} = Err -> Err end. @@ -1484,11 +1488,11 @@ do_class_of(InstanceNref) -> R#relationship.characterization =:= ?ARC_INST_TO_CLASS end, Rels) end, - case mnesia:transaction(F) of - {atomic, {value, #relationship{target_nref = ClassNref}}} -> + case graphdb_mgr:transaction(F) of + {ok, {value, #relationship{target_nref = ClassNref}}} -> {ok, ClassNref}; - {atomic, false} -> not_found; - {aborted, Reason} -> {error, Reason} + {ok, false} -> not_found; + {error, Reason} -> {error, Reason} end. @@ -1515,10 +1519,7 @@ do_children(Nref) -> composition), [N || N <- Children, N#node.kind =:= instance] end, - case mnesia:transaction(F) of - {atomic, Nodes} -> {ok, Nodes}; - {aborted, Reason} -> {error, Reason} - end. + graphdb_mgr:transaction(F). %%----------------------------------------------------------------------------- @@ -1757,13 +1758,13 @@ resolve_from_connected(InstNref, AttrNref) -> mnesia:index_read(relationships, InstNref, #relationship.source_nref) end, - case mnesia:transaction(F) of - {atomic, Rels} -> + case graphdb_mgr:transaction(F) of + {ok, Rels} -> TargetNrefs = lists:usort( [R#relationship.target_nref || R <- Rels, R#relationship.kind =:= connection]), search_targets(TargetNrefs, AttrNref); - {aborted, _} -> + {error, _} -> not_found end. From 7af9dc1191a89e34449b6261e508c3c6dc68df91 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 20 Jun 2026 11:18:00 -0400 Subject: [PATCH 12/12] docs: mark transaction-seam retrofit follow-up IMPLEMENTED in TASKS.md Co-Authored-By: Claude Opus 4.8 --- TASKS.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/TASKS.md b/TASKS.md index e46b8ec..0a1add7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -126,11 +126,13 @@ and `remove_relationship` adopt it as their first consumers. Tracked follow-ups (not in the seam spec): -- **Retrofit existing write ops** (`create_instance`, `add_relationship`, - the membership `do_*` ops) onto the primitive/wrapper layering — uniform - convention, no behaviour change. Design (full sweep of all 40 - `mnesia:transaction` sites across the six workers + bootstrap): - `docs/designs/transaction-seam-retrofit-design.md`. +- **Retrofit existing write ops** — IMPLEMENTED. Full sweep: all 40 + `mnesia:transaction` sites across the six workers + bootstrap now route + through `graphdb_mgr:transaction/1` (the single `{atomic,_}`/`{aborted,_}` + mapping point). Behaviour-preserving; existing tests unchanged, +2 new + instance CT cases (`characterization_not_found`/`reciprocal_not_found` + arms). Design `docs/designs/transaction-seam-retrofit-design.md`; plan + `docs/superpowers/plans/2026-06-20-transaction-seam-retrofit.md`. - **Atomic `add_relationship`** — collapse its four separate transactions (validate → resolve classes → resolve template → write) into one. Blocked on `graphdb_class` exposing tier-1 in-transaction read