diff --git a/TASKS.md b/TASKS.md index 0a1add7..3750d23 100644 --- a/TASKS.md +++ b/TASKS.md @@ -134,12 +134,14 @@ Tracked follow-ups (not in the seam spec): 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 - 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. + (validate → resolve classes → resolve template → write) into one. The + prerequisite tier-1 `graphdb_class` read primitives + (`get_template_in_txn/1`, `class_in_ancestry_in_txn/2`, + `default_template_in_txn/1`) have landed (PR 1, + `docs/designs/atomic-add-relationship-primitives-design.md`). PR 2 swaps + `add_relationship` onto them, converts the `source_has_no_class` / + `target_has_no_class` arms to `mnesia:abort/1`, and allocates the rel-id pair + up-front. Sequence with / before `mutate/1`, which wants those primitives too. - **Batch `mutate([Mutation])`** — the tier-3 entry point. ### Node deletion (slice A) — IMPLEMENTED diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index 5ae42c7..eb14d77 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -247,6 +247,13 @@ Manages the "is a" hierarchy of class nodes in the ontology. - `add_qualifying_characteristic/2` (class_nref, attribute_nref) - `is_instantiable/1` (class_nref) — `false` iff the class carries the `instantiable => false` marker - `get_class/1`, `subclasses/1`, `ancestors/1`, `inherited_qcs/1` +- `get_template_in_txn/1`, `class_in_ancestry_in_txn/2`, + `default_template_in_txn/1` — tier-1 **in-transaction** read primitives + (bare-mnesia twins of `get_template`/`class_in_ancestry`/`default_template`); + must be called inside an Mnesia activity. They compose into a caller's single + transaction (the seam's tier-1 contract) and are the prerequisite for atomic + `add_relationship` / `mutate/1`. See + `docs/designs/atomic-add-relationship-primitives-design.md`. ### `graphdb_instance` — Instance & Compositional Hierarchy diff --git a/apps/graphdb/src/graphdb_class.erl b/apps/graphdb/src/graphdb_class.erl index 2c50dde..ce7ac62 100644 --- a/apps/graphdb/src/graphdb_class.erl +++ b/apps/graphdb/src/graphdb_class.erl @@ -119,12 +119,15 @@ subclasses/1, ancestors/1, get_template/1, + get_template_in_txn/1, templates_for_class/1, default_template/1, + default_template_in_txn/1, is_instantiable/1, %% Class-of resolution helper (used by graphdb_instance to validate %% Template AVP class scope on Connection arcs) class_in_ancestry/2, + class_in_ancestry_in_txn/2, %% Inheritance inherited_qcs/1 ]). @@ -713,6 +716,26 @@ template_has_name(#node{attribute_value_pairs = AVPs}, Name) -> (_) -> false end, AVPs). +%%----------------------------------------------------------------------------- +%% default_template_in_txn(ClassNref) -> {ok, Nref} | not_found +%% +%% Tier-1 in-transaction twin of default_template/1. Assumes it runs inside an +%% active mnesia activity; reuses the bare-mnesia downward_children_by_arc/3 and +%% template_has_name/2. Returns not_found when ClassNref has no template named +%% ?DEFAULT_TEMPLATE_NAME (e.g. an abstract class). +%%----------------------------------------------------------------------------- +default_template_in_txn(ClassNref) -> + Children = downward_children_by_arc(ClassNref, ?ARC_CLS_CHILD, composition), + case lists:search(fun + (#node{kind = template} = N) -> + template_has_name(N, ?DEFAULT_TEMPLATE_NAME); + (_) -> + false + end, Children) of + {value, #node{nref = Nref}} -> {ok, Nref}; + false -> not_found + end. + %%----------------------------------------------------------------------------- %% do_get_template(Nref) -> @@ -725,6 +748,21 @@ do_get_template(Nref) -> [] -> {error, not_found} end. +%%----------------------------------------------------------------------------- +%% get_template_in_txn(Nref) -> +%% {ok, #node{}} | {error, not_a_template | not_found} +%% +%% Tier-1 in-transaction twin of do_get_template/1. Assumes it runs inside an +%% active mnesia activity; uses a bare mnesia:read. See +%% docs/designs/atomic-add-relationship-primitives-design.md. +%%----------------------------------------------------------------------------- +get_template_in_txn(Nref) -> + case mnesia:read(nodes, Nref) of + [#node{kind = template} = Node] -> {ok, Node}; + [_Other] -> {error, not_a_template}; + [] -> {error, not_found} + end. + %%----------------------------------------------------------------------------- %% do_templates_for_class(ClassNref) -> {ok, [#node{}]} | {error, term()} @@ -766,6 +804,56 @@ do_class_in_ancestry(CandidateNref, ClassNref) -> end. +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn(CandidateNref, ClassNref) -> boolean() +%% +%% Tier-1 in-transaction twin of do_class_in_ancestry/2. Assumes it runs +%% inside an active mnesia activity; walks the taxonomic ancestry with bare +%% mnesia:read. Returns false on any lookup error. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn(CandidateNref, CandidateNref) -> + true; +class_in_ancestry_in_txn(CandidateNref, ClassNref) -> + case ancestors_in_txn(ClassNref) of + {ok, Ancestors} -> + lists:any(fun(#node{nref = N}) -> N =:= CandidateNref end, Ancestors); + _ -> + false + end. + +%%----------------------------------------------------------------------------- +%% ancestors_in_txn(ClassNref) -> {ok, [#node{}]} | {error, term()} +%% +%% Tier-1 in-transaction twin of do_ancestors/1: BFS over the multi-parent +%% taxonomic DAG with bare mnesia:read, nearest-first, each ancestor once, +%% the Classes category (nref 3) filtered out. +%%----------------------------------------------------------------------------- +ancestors_in_txn(ClassNref) -> + case mnesia:read(nodes, ClassNref) of + [#node{kind = class, parents = Parents}] -> + Initial = [P || P <- Parents, P =/= ?NREF_CLASSES], + walk_ancestors_in_txn(Initial, sets:from_list(Initial), []); + [_] -> + {error, not_a_class}; + [] -> + {error, not_found} + end. + +walk_ancestors_in_txn([], _Visited, Acc) -> + {ok, lists:reverse(Acc)}; +walk_ancestors_in_txn([Nref | Rest], Visited, Acc) -> + case mnesia:read(nodes, Nref) of + [#node{kind = class, parents = Parents} = Node] -> + New = [P || P <- Parents, + P =/= ?NREF_CLASSES, + not sets:is_element(P, Visited)], + NewVisited = lists:foldl(fun sets:add_element/2, Visited, New), + walk_ancestors_in_txn(Rest ++ New, NewVisited, [Node | Acc]); + _ -> + walk_ancestors_in_txn(Rest, Visited, Acc) + end. + + %%----------------------------------------------------------------------------- %% do_validate_parent(ParentNref) -> ok | {error, term()} %% diff --git a/apps/graphdb/test/graphdb_class_SUITE.erl b/apps/graphdb/test/graphdb_class_SUITE.erl index 040e02a..843bb24 100644 --- a/apps/graphdb/test/graphdb_class_SUITE.erl +++ b/apps/graphdb/test/graphdb_class_SUITE.erl @@ -75,12 +75,22 @@ add_template_rejects_non_class/1, get_template_returns_node/1, get_template_rejects_non_template/1, + get_template_in_txn_returns_node/1, + get_template_in_txn_rejects_non_template/1, + get_template_in_txn_not_found/1, templates_for_class_lists_all/1, default_template_returns_default/1, default_template_not_found_after_delete/1, + default_template_in_txn_returns_default/1, + default_template_in_txn_abstract_not_found/1, + default_template_in_txn_not_found_after_delete/1, class_in_ancestry_self/1, class_in_ancestry_ancestor/1, class_in_ancestry_unrelated/1, + class_in_ancestry_in_txn_self/1, + class_in_ancestry_in_txn_ancestor/1, + class_in_ancestry_in_txn_unrelated/1, + class_in_ancestry_in_txn_diamond/1, %% Qualifying characteristics add_qc_basic/1, add_qc_idempotent/1, @@ -149,12 +159,22 @@ groups() -> add_template_rejects_non_class, get_template_returns_node, get_template_rejects_non_template, + get_template_in_txn_returns_node, + get_template_in_txn_rejects_non_template, + get_template_in_txn_not_found, templates_for_class_lists_all, default_template_returns_default, default_template_not_found_after_delete, + default_template_in_txn_returns_default, + default_template_in_txn_abstract_not_found, + default_template_in_txn_not_found_after_delete, class_in_ancestry_self, class_in_ancestry_ancestor, - class_in_ancestry_unrelated + class_in_ancestry_unrelated, + class_in_ancestry_in_txn_self, + class_in_ancestry_in_txn_ancestor, + class_in_ancestry_in_txn_unrelated, + class_in_ancestry_in_txn_diamond ]}, {qualifying, [], [ add_qc_basic, @@ -493,6 +513,38 @@ get_template_rejects_non_template(_Config) -> ?assertEqual({error, not_a_template}, graphdb_class:get_template(ClassNref)). +%%----------------------------------------------------------------------------- +%% get_template_in_txn returns the template node (in-transaction twin). +%%----------------------------------------------------------------------------- +get_template_in_txn_returns_node(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + {ok, TmplNref} = graphdb_class:default_template(ClassNref), + {ok, {ok, Node}} = graphdb_mgr:transaction(fun() -> + graphdb_class:get_template_in_txn(TmplNref) + end), + ?assertEqual(TmplNref, Node#node.nref), + ?assertEqual(template, Node#node.kind). + +%%----------------------------------------------------------------------------- +%% get_template_in_txn rejects a class nref (kind mismatch). +%%----------------------------------------------------------------------------- +get_template_in_txn_rejects_non_template(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + ?assertEqual({ok, {error, not_a_template}}, graphdb_mgr:transaction(fun() -> + graphdb_class:get_template_in_txn(ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% get_template_in_txn returns not_found for an unused nref. +%%----------------------------------------------------------------------------- +get_template_in_txn_not_found(_Config) -> + {ok, _} = graphdb_class:start_link(), + ?assertEqual({ok, {error, not_found}}, graphdb_mgr:transaction(fun() -> + graphdb_class:get_template_in_txn(999999) + end)). + %%----------------------------------------------------------------------------- %% templates_for_class returns all templates (default plus any added). %%----------------------------------------------------------------------------- @@ -532,6 +584,45 @@ default_template_not_found_after_delete(_Config) -> end), ?assertEqual(not_found, graphdb_class:default_template(ClassNref)). +%%----------------------------------------------------------------------------- +%% default_template_in_txn returns the default template nref (in-tx twin). +%%----------------------------------------------------------------------------- +default_template_in_txn_returns_default(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + {ok, Expected} = graphdb_class:default_template(ClassNref), + ?assertEqual({ok, {ok, Expected}}, graphdb_mgr:transaction(fun() -> + graphdb_class:default_template_in_txn(ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% default_template_in_txn returns not_found for an abstract class (born +%% without a default template). +%%----------------------------------------------------------------------------- +default_template_in_txn_abstract_not_found(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, #{instantiable := Inst}} = graphdb_attr:seeded_nrefs(), + Marker = #{attribute => Inst, value => false}, + {ok, ClassNref} = graphdb_class:create_class("Abstract", 3, [Marker]), + ?assertEqual({ok, not_found}, graphdb_mgr:transaction(fun() -> + graphdb_class:default_template_in_txn(ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% default_template_in_txn returns not_found after the default template node +%% is deleted. +%%----------------------------------------------------------------------------- +default_template_in_txn_not_found_after_delete(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + {ok, TmplNref} = graphdb_class:default_template(ClassNref), + {atomic, ok} = mnesia:transaction(fun() -> + mnesia:delete({nodes, TmplNref}) + end), + ?assertEqual({ok, not_found}, graphdb_mgr:transaction(fun() -> + graphdb_class:default_template_in_txn(ClassNref) + end)). + %%----------------------------------------------------------------------------- %% class_in_ancestry returns true when the candidate equals the class. %%----------------------------------------------------------------------------- @@ -560,6 +651,56 @@ class_in_ancestry_unrelated(_Config) -> {ok, VehicleNref} = graphdb_class:create_class("Vehicle", 3), ?assertNot(graphdb_class:class_in_ancestry(VehicleNref, AnimalNref)). +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: self is in its own ancestry (in-transaction twin). +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_self(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(ClassNref, ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: true for direct and transitive ancestors. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_ancestor(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, AnimalNref} = graphdb_class:create_class("Animal", 3), + {ok, MammalNref} = graphdb_class:create_class("Mammal", AnimalNref), + {ok, WhaleNref} = graphdb_class:create_class("Whale", MammalNref), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(AnimalNref, WhaleNref) + end)), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(MammalNref, WhaleNref) + end)). + +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: false for unrelated classes. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_unrelated(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, AnimalNref} = graphdb_class:create_class("Animal", 3), + {ok, VehicleNref} = graphdb_class:create_class("Vehicle", 3), + ?assertEqual({ok, false}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(VehicleNref, AnimalNref) + end)). + +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: true for a diamond ancestor reached via two paths. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_diamond(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, A} = graphdb_class:create_class("A", 3), + {ok, B} = graphdb_class:create_class("B", A), + {ok, C} = graphdb_class:create_class("C", A), + {ok, D} = graphdb_class:create_class("D", B), + ok = graphdb_class:add_superclass(D, C), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(A, D) + end)). + %%============================================================================= %% Qualifying Characteristic Tests diff --git a/docs/designs/atomic-add-relationship-primitives-design.md b/docs/designs/atomic-add-relationship-primitives-design.md new file mode 100644 index 0000000..6c74021 --- /dev/null +++ b/docs/designs/atomic-add-relationship-primitives-design.md @@ -0,0 +1,161 @@ + + +# Tier-1 Class-Read Primitives — Design + +**Status:** Approved (design) — not yet planned/implemented +**Date:** 2026-06-20 +**Author:** David W. Thomas (with Claude) +**Slice:** Atomic `add_relationship`, PR 1 of 2 (primitives-only) + +## Background + +The write-path transaction-layering seam shipped in PR #41 (`81b2962`, +`docs/designs/write-path-transaction-seam-design.md`) and was swept across +all 40 hand-rolled transaction sites in PR #43 (`6d48d80`, +`docs/designs/transaction-seam-retrofit-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). + +`TASKS.md` tracks two seam follow-ups still open: **Atomic `add_relationship`** +and **Batch `mutate/1`**. Both are blocked on the same thing — `graphdb_class` +does not expose its reads (`default_template`, `get_template`, +`class_in_ancestry`) as tier-1 primitives. Today they are `gen_server:call`s, +which cannot run inside an Mnesia transaction owned by another process. + +## The honest reframe + +`graphdb_instance:do_add_relationship/7` runs four sequential phases: + +1. `validate_arc_endpoints` — read the four endpoint nodes (its own txn) +2. `resolve_arc_classes` — `do_class_of/1` ×2 (two txns) +3. `resolve_template` — `graphdb_class:default_template/1` (gen_server txn) +4. `validate_template_scope` — `graphdb_class:get_template/1` + + `class_in_ancestry/2` ×2 (gen_server reads) + +…then `write_connection_arcs` writes the two directed rows in a **fifth** +transaction. **Only that last transaction writes.** Phases 1–4 are all +read-only, so a failure in any of them never reaches the write — **there is no +partial-write bug today.** + +Collapsing the phases into one transaction therefore does not fix a bug. It +buys two things: + +- **TOCTOU isolation** — validation and the write share one consistent + snapshot, closing the window where another process retires an endpoint, + deletes a class, or changes a template *between* validation and write. +- **The tier-1 read-primitive library** — the real deliverable, and what + `mutate/1` needs too. + +This design covers **only the primitive library** (PR 1). The +`add_relationship` collapse is PR 2 (see Non-goals). + +## Goal + +Add three exported, unit-tested, in-transaction read functions to +`graphdb_class` that return results identical to the existing gen_server +reads. **This PR is purely additive** — no existing code path changes; the +539 existing tests are untouched and all new tests are additive. + +## The three primitives + +Naming convention — this is the first cross-module tier-1 *read* library, so it +sets the pattern: **the `_in_txn` suffix.** Each function assumes it is already +running inside an Mnesia activity and uses bare `mnesia:read` / +`mnesia:index_read` — never `dirty_*`, and never opens its own transaction. + +| Primitive | Return contract (identical to gen_server twin) | +| ------------------------------------------- | ------------------------------------------------------- | +| `default_template_in_txn(ClassNref)` | `{ok, Nref} \| not_found` | +| `get_template_in_txn(Nref)` | `{ok, #node{}} \| {error, not_a_template \| not_found}` | +| `class_in_ancestry_in_txn(Cand, ClassNref)` | `boolean()` | + +Behavioural notes carried over verbatim from the gen_server twins: + +- `class_in_ancestry_in_txn(C, C)` is `true` (self is in its own ancestry); + the ancestor walk is the BFS over the multi-parent taxonomic DAG, the + `Classes` category (nref 3) is filtered out, and any lookup error yields + `false`. +- `default_template_in_txn` looks up the template-kind child of `ClassNref` + whose class-name AVP (`?NAME_ATTR_CLASS`, nref 19) matches + `?DEFAULT_TEMPLATE_NAME`; absent → `not_found`. +- `get_template_in_txn` returns `{error, not_a_template}` for a node that + exists but is not `kind = template`, `{error, not_found}` for a missing nref. + +## Add, don't rewrap (the load-bearing decision) + +The existing gen_server reads — `do_default_template/1`, `do_get_template/1`, +`do_class_in_ancestry/2` and their `handle_call` clauses — stay **untouched** +for all three. The primitives are **new** functions. + +The reason this is not just conservatism: `get_template` and +`class_in_ancestry` use `dirty_read` today, and that is load-bearing. +`graphdb_rules:default_conflict_resolver/0` calls `class_in_ancestry` and is +documented deadlock-safe *because* those reads are dirty. The B5 conflict +resolver runs during `plan_composition_firing` — **outside** any transaction, +before `create_instance`'s write transaction opens. Converting those gen_server +reads to transactional reads would risk a blocking/nested transaction on that +path. So they remain dirty. + +`default_template`'s gen_server path is already transactional +(`do_find_template_by_name` wraps a txn), so rewrapping *it* would have been +behaviour-preserving. We considered it (it would remove one duplication) but +chose **uniform add-don't-rewrap** for all three: lowest blast radius, no +existing path touched, and the small duplication is already sanctioned by +project precedent (`is_marked_non_instantiable/2` and `downward_children_by_arc` +are both intentionally duplicated across modules). + +Consequence: the default-template name-search walk now exists in two copies — +the gen_server's `do_find_template_by_name` and `default_template_in_txn`. This +duplication is accepted and is **not** converged by PR 2. + +## Testing + +The existing 539 tests are untouched (nothing they cover changes). New CT cases +go in `graphdb_class_SUITE` — CT, not EUnit, because the primitives must run +inside an Mnesia activity against the bootstrapped schema. Each primitive is +invoked via `graphdb_mgr:transaction(fun() -> graphdb_class:(...) end)`, +deliberately mirroring the existing gen_server-twin assertions so equivalence +between primitive and gen_server result is demonstrated: + +- `default_template_in_txn`: class with a default template → `{ok, _}`; class + without → `not_found`; abstract (non-instantiable, born without a template) + class → `not_found`. +- `get_template_in_txn`: a template nref → `{ok, #node{}}`; a non-template node + (e.g. a class nref) → `{error, not_a_template}`; an unused nref → + `{error, not_found}`. +- `class_in_ancestry_in_txn`: self → `true`; a direct parent and a transitive + ancestor → `true`; an unrelated class → `false`; a diamond ancestor → `true`. + +## Non-goals (deferred to PR 2) + +PR 2 swaps `add_relationship` onto these primitives and collapses its phases +into one transaction. Specifically deferred: + +- Collapsing `validate_arc_endpoints` + `resolve_arc_classes` + + `resolve_template` + `validate_template_scope` + the row write into one + `graphdb_mgr:transaction/1` fun. +- Converting the `resolve_arc_classes` arms (`source_has_no_class`, + `target_has_no_class`) from `{error, _}` return values to `mnesia:abort/1` + with byte-identical Reason terms — and adding their two new tests (those two + atoms are currently uncovered in `graphdb_instance_SUITE`). +- Allocating the relationship-id pair up-front (outside the single + transaction), accepting that a validation abort now orphans an id pair — + harmless under the allocate-outside-transaction doctrine. + +`mutate/1` (the tier-3 batch entry point) remains a separate, later slice that +also consumes these primitives. + +## Relationship to other follow-ups + +This is the unblocking prerequisite shared by both open seam follow-ups in +`TASKS.md`. After this PR, **Atomic `add_relationship` (PR 2)** can proceed +immediately; **Batch `mutate/1`** can reuse the same primitives when it is +taken up. diff --git a/docs/superpowers/plans/2026-06-20-tier1-class-read-primitives.md b/docs/superpowers/plans/2026-06-20-tier1-class-read-primitives.md new file mode 100644 index 0000000..9fdb65a --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-tier1-class-read-primitives.md @@ -0,0 +1,524 @@ + + +# Tier-1 Class-Read Primitives 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 three exported, in-transaction (bare-mnesia) read primitives to `graphdb_class` — `get_template_in_txn/1`, `class_in_ancestry_in_txn/2`, `default_template_in_txn/1` — each returning results identical to its existing gen_server twin, with new CT coverage. + +**Architecture:** Purely additive. The primitives are new exported functions that assume they already run inside an Mnesia activity and use bare `mnesia:read` / `mnesia:index_read` (never `dirty_*`, never opening their own transaction). They reuse the module-private `downward_children_by_arc/3` and `template_has_name/2` where possible; the ancestry walk is duplicated with bare reads (the gen_server twins keep their load-bearing `dirty_read`s untouched). No existing code path changes. + +**Tech Stack:** Erlang/OTP 28.5, rebar3 3.27 (invoked as repo-local `./rebar3`), Mnesia, Common Test. + +## Global Constraints + +- **Design contract:** `docs/designs/atomic-add-relationship-primitives-design.md`. This is PR 1 of 2; the `add_relationship` collapse is PR 2 and is **out of scope**. +- **Purely additive:** touch no existing function body, no existing `handle_call` clause, no existing test. The existing 539 tests must stay byte-for-byte unchanged and green. +- **Add, don't rewrap:** do NOT make `do_default_template/1`, `do_get_template/1`, or `do_class_in_ancestry/2` delegate to the new primitives. The gen_server `dirty_read`s on `get_template`/`class_in_ancestry` are load-bearing for `graphdb_rules:default_conflict_resolver/0` deadlock-safety. +- **Naming convention:** the new functions carry the `_in_txn` suffix. +- **Indentation:** `graphdb_class.erl` and `graphdb_class_SUITE.erl` use **hard tabs**. Match exactly. +- **Module header pattern:** do not add a copyright/revision block to functions; just add the functions in the existing Lookups region and their names to the existing `-export` list. +- **Relevant macros (already included via `graphdb_nrefs.hrl`):** `?NREF_CLASSES` = 3, `?NAME_ATTR_CLASS` = 19, `?ARC_CLS_CHILD` = 26. Local macro in `graphdb_class.erl`: `?DEFAULT_TEMPLATE_NAME` = `"default"`. The `composition` / `taxonomy` arc kinds are bare atoms. +- **Test harness:** every CT case starts `graphdb_class:start_link()` in its body; `init_per_testcase` already starts `nref`, `rel_id_server`, `graphdb_nref`, `graphdb_mgr` (bootstrap), and `graphdb_attr`. So `graphdb_mgr:transaction/1` is available inside test cases. +- **`graphdb_mgr:transaction/1` return shape:** maps `{atomic, R}` → `{ok, R}`. So a primitive returning `{ok, Node}` surfaces as `{ok, {ok, Node}}`; one returning `true` surfaces as `{ok, true}`; one returning `not_found` surfaces as `{ok, not_found}`. +- **Single-suite test run (per step):** `scripts/test-ct-parallel.sh class` (runs only `graphdb_class_SUITE`, isolated). Full verification run: `make test-ct-parallel` and `./rebar3 eunit`. + +--- + +### Task 1: `get_template_in_txn/1` + +The simplest primitive: the in-transaction twin of `do_get_template/1` (which uses `dirty_read`). Establishes the export-list edit pattern for the later tasks. + +**Files:** +- Modify: `apps/graphdb/src/graphdb_class.erl` (add to `-export` Lookups region near line 121; add function body near the existing `do_get_template/1` around line 718–726) +- Test: `apps/graphdb/test/graphdb_class_SUITE.erl` (new cases + register in `-export` and `all/0`) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `graphdb_class:get_template_in_txn(Nref) -> {ok, #node{}} | {error, not_a_template | not_found}`. Must be called inside an Mnesia activity. + +- [ ] **Step 1: Write the failing tests** + +Add these three cases to `apps/graphdb/test/graphdb_class_SUITE.erl`, in the Template Tests section (after `get_template_rejects_non_template/1`, around line 494): + +```erlang +%%----------------------------------------------------------------------------- +%% get_template_in_txn returns the template node (in-transaction twin). +%%----------------------------------------------------------------------------- +get_template_in_txn_returns_node(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + {ok, TmplNref} = graphdb_class:default_template(ClassNref), + {ok, {ok, Node}} = graphdb_mgr:transaction(fun() -> + graphdb_class:get_template_in_txn(TmplNref) + end), + ?assertEqual(TmplNref, Node#node.nref), + ?assertEqual(template, Node#node.kind). + +%%----------------------------------------------------------------------------- +%% get_template_in_txn rejects a class nref (kind mismatch). +%%----------------------------------------------------------------------------- +get_template_in_txn_rejects_non_template(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + ?assertEqual({ok, {error, not_a_template}}, graphdb_mgr:transaction(fun() -> + graphdb_class:get_template_in_txn(ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% get_template_in_txn returns not_found for an unused nref. +%%----------------------------------------------------------------------------- +get_template_in_txn_not_found(_Config) -> + {ok, _} = graphdb_class:start_link(), + ?assertEqual({ok, {error, not_found}}, graphdb_mgr:transaction(fun() -> + graphdb_class:get_template_in_txn(999999) + end)). +``` + +Register them in the `-export` test-case block (near line 76) and in `all/0` (near line 150), each adjacent to `get_template_rejects_non_template`: + +```erlang + get_template_returns_node/1, + get_template_rejects_non_template/1, + get_template_in_txn_returns_node/1, + get_template_in_txn_rejects_non_template/1, + get_template_in_txn_not_found/1, +``` + +```erlang + get_template_returns_node, + get_template_rejects_non_template, + get_template_in_txn_returns_node, + get_template_in_txn_rejects_non_template, + get_template_in_txn_not_found, +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `scripts/test-ct-parallel.sh class` +Expected: FAIL — `graphdb_class:get_template_in_txn/1` is undefined (the three new cases error). + +- [ ] **Step 3: Add to the export list** + +In `apps/graphdb/src/graphdb_class.erl`, in the Lookups region of the public `-export` list (the block ending around line 130), add `get_template_in_txn/1` after `get_template/1`: + +```erlang + get_template/1, + get_template_in_txn/1, +``` + +- [ ] **Step 4: Implement the primitive** + +Add this function immediately after `do_get_template/1` (after line 726). It is the in-transaction twin: `mnesia:read` instead of `mnesia:dirty_read`. + +```erlang +%%----------------------------------------------------------------------------- +%% get_template_in_txn(Nref) -> +%% {ok, #node{}} | {error, not_a_template | not_found} +%% +%% Tier-1 in-transaction twin of do_get_template/1. Assumes it runs inside an +%% active mnesia activity; uses a bare mnesia:read. See +%% docs/designs/atomic-add-relationship-primitives-design.md. +%%----------------------------------------------------------------------------- +get_template_in_txn(Nref) -> + case mnesia:read(nodes, Nref) of + [#node{kind = template} = Node] -> {ok, Node}; + [_Other] -> {error, not_a_template}; + [] -> {error, not_found} + end. +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `scripts/test-ct-parallel.sh class` +Expected: PASS — all `graphdb_class_SUITE` cases green (existing + 3 new). + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_class.erl apps/graphdb/test/graphdb_class_SUITE.erl +git commit -m "feat(graphdb_class): add get_template_in_txn/1 tier-1 read primitive" +``` + +--- + +### Task 2: `class_in_ancestry_in_txn/2` + +The in-transaction twin of `do_class_in_ancestry/2`. Needs a bare-read ancestry walk (`ancestors_in_txn/1` + `walk_ancestors_in_txn/3`) duplicating `do_ancestors/1` + `do_walk_ancestors/3` with `mnesia:read` in place of `dirty_read`. This duplication is intentional (the design's "add, don't rewrap"). + +**Files:** +- Modify: `apps/graphdb/src/graphdb_class.erl` (add to `-export`; add three functions near `do_class_in_ancestry/2` ~line 758 and `do_ancestors/1` ~line 922) +- Test: `apps/graphdb/test/graphdb_class_SUITE.erl` + +**Interfaces:** +- Consumes: nothing. +- Produces: `graphdb_class:class_in_ancestry_in_txn(CandidateNref, ClassNref) -> boolean()`. Must be called inside an Mnesia activity. `class_in_ancestry_in_txn(C, C)` is `true`; any lookup error yields `false`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/graphdb/test/graphdb_class_SUITE.erl` after `class_in_ancestry_unrelated/1` (around line 561): + +```erlang +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: self is in its own ancestry (in-transaction twin). +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_self(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(ClassNref, ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: true for direct and transitive ancestors. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_ancestor(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, AnimalNref} = graphdb_class:create_class("Animal", 3), + {ok, MammalNref} = graphdb_class:create_class("Mammal", AnimalNref), + {ok, WhaleNref} = graphdb_class:create_class("Whale", MammalNref), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(AnimalNref, WhaleNref) + end)), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(MammalNref, WhaleNref) + end)). + +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: false for unrelated classes. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_unrelated(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, AnimalNref} = graphdb_class:create_class("Animal", 3), + {ok, VehicleNref} = graphdb_class:create_class("Vehicle", 3), + ?assertEqual({ok, false}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(VehicleNref, AnimalNref) + end)). + +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn: true for a diamond ancestor reached via two paths. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn_diamond(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, A} = graphdb_class:create_class("A", 3), + {ok, B} = graphdb_class:create_class("B", A), + {ok, C} = graphdb_class:create_class("C", A), + {ok, D} = graphdb_class:create_class("D", B), + ok = graphdb_class:add_superclass(D, C), + ?assertEqual({ok, true}, graphdb_mgr:transaction(fun() -> + graphdb_class:class_in_ancestry_in_txn(A, D) + end)). +``` + +Register in the `-export` test-case block (after `class_in_ancestry_unrelated/1`, near line 83) and in `all/0` (after `class_in_ancestry_unrelated`, near line 157): + +```erlang + class_in_ancestry_self/1, + class_in_ancestry_ancestor/1, + class_in_ancestry_unrelated/1, + class_in_ancestry_in_txn_self/1, + class_in_ancestry_in_txn_ancestor/1, + class_in_ancestry_in_txn_unrelated/1, + class_in_ancestry_in_txn_diamond/1, +``` + +```erlang + class_in_ancestry_self, + class_in_ancestry_ancestor, + class_in_ancestry_unrelated, + class_in_ancestry_in_txn_self, + class_in_ancestry_in_txn_ancestor, + class_in_ancestry_in_txn_unrelated, + class_in_ancestry_in_txn_diamond +``` + +NOTE: `class_in_ancestry_unrelated` is the last entry in its `all/0` group (followed by `]` or a group boundary) — preserve the existing trailing-comma/terminator layout when inserting; do not introduce a stray comma before a closing bracket. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `scripts/test-ct-parallel.sh class` +Expected: FAIL — `graphdb_class:class_in_ancestry_in_txn/2` is undefined. + +- [ ] **Step 3: Add to the export list** + +In `apps/graphdb/src/graphdb_class.erl`, add `class_in_ancestry_in_txn/2` after `class_in_ancestry/2` in the public `-export` list (near line 127): + +```erlang + class_in_ancestry/2, + class_in_ancestry_in_txn/2, +``` + +- [ ] **Step 4: Implement the primitive and its bare-read walk** + +Add `class_in_ancestry_in_txn/2` immediately after `do_class_in_ancestry/2` (after line 766): + +```erlang +%%----------------------------------------------------------------------------- +%% class_in_ancestry_in_txn(CandidateNref, ClassNref) -> boolean() +%% +%% Tier-1 in-transaction twin of do_class_in_ancestry/2. Assumes it runs +%% inside an active mnesia activity; walks the taxonomic ancestry with bare +%% mnesia:read. Returns false on any lookup error. +%%----------------------------------------------------------------------------- +class_in_ancestry_in_txn(CandidateNref, CandidateNref) -> + true; +class_in_ancestry_in_txn(CandidateNref, ClassNref) -> + case ancestors_in_txn(ClassNref) of + {ok, Ancestors} -> + lists:any(fun(#node{nref = N}) -> N =:= CandidateNref end, Ancestors); + _ -> + false + end. + +%%----------------------------------------------------------------------------- +%% ancestors_in_txn(ClassNref) -> {ok, [#node{}]} | {error, term()} +%% +%% Tier-1 in-transaction twin of do_ancestors/1: BFS over the multi-parent +%% taxonomic DAG with bare mnesia:read, nearest-first, each ancestor once, +%% the Classes category (nref 3) filtered out. +%%----------------------------------------------------------------------------- +ancestors_in_txn(ClassNref) -> + case mnesia:read(nodes, ClassNref) of + [#node{kind = class, parents = Parents}] -> + Initial = [P || P <- Parents, P =/= ?NREF_CLASSES], + walk_ancestors_in_txn(Initial, sets:from_list(Initial), []); + [_] -> + {error, not_a_class}; + [] -> + {error, not_found} + end. + +walk_ancestors_in_txn([], _Visited, Acc) -> + {ok, lists:reverse(Acc)}; +walk_ancestors_in_txn([Nref | Rest], Visited, Acc) -> + case mnesia:read(nodes, Nref) of + [#node{kind = class, parents = Parents} = Node] -> + New = [P || P <- Parents, + P =/= ?NREF_CLASSES, + not sets:is_element(P, Visited)], + NewVisited = lists:foldl(fun sets:add_element/2, Visited, New), + walk_ancestors_in_txn(Rest ++ New, NewVisited, [Node | Acc]); + _ -> + walk_ancestors_in_txn(Rest, Visited, Acc) + end. +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `scripts/test-ct-parallel.sh class` +Expected: PASS — all `graphdb_class_SUITE` cases green. + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_class.erl apps/graphdb/test/graphdb_class_SUITE.erl +git commit -m "feat(graphdb_class): add class_in_ancestry_in_txn/2 tier-1 read primitive" +``` + +--- + +### Task 3: `default_template_in_txn/1` + +The in-transaction twin of `default_template/1`. Reuses the module-private `downward_children_by_arc/3` (already bare-mnesia, "must run inside a transaction") and `template_has_name/2`, so only the search/return logic is added. + +**Files:** +- Modify: `apps/graphdb/src/graphdb_class.erl` (add to `-export`; add function near `do_default_template/1` ~line 745 / `do_find_template_by_name/1` ~line 695) +- Test: `apps/graphdb/test/graphdb_class_SUITE.erl` + +**Interfaces:** +- Consumes: nothing. +- Produces: `graphdb_class:default_template_in_txn(ClassNref) -> {ok, Nref} | not_found`. Must be called inside an Mnesia activity. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/graphdb/test/graphdb_class_SUITE.erl` after `default_template_not_found_after_delete/1` (around line 533): + +```erlang +%%----------------------------------------------------------------------------- +%% default_template_in_txn returns the default template nref (in-tx twin). +%%----------------------------------------------------------------------------- +default_template_in_txn_returns_default(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + {ok, Expected} = graphdb_class:default_template(ClassNref), + ?assertEqual({ok, {ok, Expected}}, graphdb_mgr:transaction(fun() -> + graphdb_class:default_template_in_txn(ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% default_template_in_txn returns not_found for an abstract class (born +%% without a default template). +%%----------------------------------------------------------------------------- +default_template_in_txn_abstract_not_found(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, #{instantiable := Inst}} = graphdb_attr:seeded_nrefs(), + Marker = #{attribute => Inst, value => false}, + {ok, ClassNref} = graphdb_class:create_class("Abstract", 3, [Marker]), + ?assertEqual({ok, not_found}, graphdb_mgr:transaction(fun() -> + graphdb_class:default_template_in_txn(ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% default_template_in_txn returns not_found after the default template node +%% is deleted. +%%----------------------------------------------------------------------------- +default_template_in_txn_not_found_after_delete(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + {ok, TmplNref} = graphdb_class:default_template(ClassNref), + {atomic, ok} = mnesia:transaction(fun() -> + mnesia:delete({nodes, TmplNref}) + end), + ?assertEqual({ok, not_found}, graphdb_mgr:transaction(fun() -> + graphdb_class:default_template_in_txn(ClassNref) + end)). +``` + +Register in the `-export` test-case block (after `default_template_not_found_after_delete/1`, near line 80) and in `all/0` (after `default_template_not_found_after_delete`, near line 154): + +```erlang + default_template_returns_default/1, + default_template_not_found_after_delete/1, + default_template_in_txn_returns_default/1, + default_template_in_txn_abstract_not_found/1, + default_template_in_txn_not_found_after_delete/1, +``` + +```erlang + default_template_returns_default, + default_template_not_found_after_delete, + default_template_in_txn_returns_default, + default_template_in_txn_abstract_not_found, + default_template_in_txn_not_found_after_delete, +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `scripts/test-ct-parallel.sh class` +Expected: FAIL — `graphdb_class:default_template_in_txn/1` is undefined. + +- [ ] **Step 3: Add to the export list** + +In `apps/graphdb/src/graphdb_class.erl`, add `default_template_in_txn/1` after `default_template/1` (near line 123): + +```erlang + default_template/1, + default_template_in_txn/1, +``` + +- [ ] **Step 4: Implement the primitive** + +Add this function immediately after `do_find_template_by_name/2` (after `template_has_name/2`, around line 714). It mirrors `do_find_template_by_name/2`'s search but assumes it is already inside a transaction (no `graphdb_mgr:transaction/1` wrapper) and returns the default-template result shape directly: + +```erlang +%%----------------------------------------------------------------------------- +%% default_template_in_txn(ClassNref) -> {ok, Nref} | not_found +%% +%% Tier-1 in-transaction twin of default_template/1. Assumes it runs inside an +%% active mnesia activity; reuses the bare-mnesia downward_children_by_arc/3 and +%% template_has_name/2. Returns not_found when ClassNref has no template named +%% ?DEFAULT_TEMPLATE_NAME (e.g. an abstract class). +%%----------------------------------------------------------------------------- +default_template_in_txn(ClassNref) -> + Children = downward_children_by_arc(ClassNref, ?ARC_CLS_CHILD, composition), + case lists:search(fun + (#node{kind = template} = N) -> + template_has_name(N, ?DEFAULT_TEMPLATE_NAME); + (_) -> + false + end, Children) of + {value, #node{nref = Nref}} -> {ok, Nref}; + false -> not_found + end. +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `scripts/test-ct-parallel.sh class` +Expected: PASS — all `graphdb_class_SUITE` cases green. + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_class.erl apps/graphdb/test/graphdb_class_SUITE.erl +git commit -m "feat(graphdb_class): add default_template_in_txn/1 tier-1 read primitive" +``` + +--- + +### Task 4: Documentation and full-suite verification + +Record the new primitives in the worker API docs and the tracked follow-up, and run the full test suite to confirm nothing regressed. + +**Files:** +- Modify: `apps/graphdb/CLAUDE.md` (graphdb_class API bullet) +- Modify: `TASKS.md` (Atomic `add_relationship` follow-up) + +- [ ] **Step 1: Update the graphdb_class API bullet in `apps/graphdb/CLAUDE.md`** + +In the `### graphdb_class — Taxonomic Hierarchy` section, add a line documenting the three new tier-1 primitives after the `get_class/1, subclasses/1, ancestors/1, inherited_qcs/1` bullet: + +```markdown +- `get_template_in_txn/1`, `class_in_ancestry_in_txn/2`, + `default_template_in_txn/1` — tier-1 **in-transaction** read primitives + (bare-mnesia twins of `get_template`/`class_in_ancestry`/`default_template`); + must be called inside an Mnesia activity. They compose into a caller's single + transaction (the seam's tier-1 contract) and are the prerequisite for atomic + `add_relationship` / `mutate/1`. See + `docs/designs/atomic-add-relationship-primitives-design.md`. +``` + +- [ ] **Step 2: Update the Atomic `add_relationship` follow-up in `TASKS.md`** + +In the "Tracked follow-ups" list under "Transaction-layering seam", replace the **Atomic `add_relationship`** bullet's "Blocked on …" sentence to record that the prerequisite primitives have landed. Change the bullet to read: + +```markdown +- **Atomic `add_relationship`** — collapse its four separate transactions + (validate → resolve classes → resolve template → write) into one. The + prerequisite tier-1 `graphdb_class` read primitives + (`get_template_in_txn/1`, `class_in_ancestry_in_txn/2`, + `default_template_in_txn/1`) have landed (PR 1, + `docs/designs/atomic-add-relationship-primitives-design.md`). PR 2 swaps + `add_relationship` onto them, converts the `source_has_no_class` / + `target_has_no_class` arms to `mnesia:abort/1`, and allocates the rel-id pair + up-front. Sequence with / before `mutate/1`, which wants those primitives too. +``` + +- [ ] **Step 3: Run the full Common Test suite** + +Run: `make test-ct-parallel` +Expected: PASS — all suites green; `graphdb_class_SUITE` reports +10 cases vs the prior run. See the count note below. + +NOTE on counts: this plan adds **10 new CT cases** to `graphdb_class_SUITE` (3 + 4 + 3). The prior project total is 434 CT (post-PR-43). After this plan: **444 CT**. Confirm the runner reports the higher total with zero failures; the exact per-suite number matters less than "all green, +10 in graphdb_class_SUITE". + +- [ ] **Step 4: Run the full EUnit suite** + +Run: `./rebar3 eunit` +Expected: PASS — 105 EUnit tests, all green (unchanged; this plan adds no EUnit). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/CLAUDE.md TASKS.md +git commit -m "docs(graphdb_class): record tier-1 read primitives + add_relationship follow-up" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Three primitives (design §"The three primitives") → Tasks 1, 2, 3. ✓ +- `_in_txn` naming convention → Global Constraints + every task. ✓ +- Add-don't-rewrap, gen_server reads untouched (design §"Add, don't rewrap") → Global Constraints; no task modifies an existing function body. ✓ +- Purely additive, 539 existing tests untouched → Global Constraints; tasks only append. ✓ +- Tests: CT in `graphdb_class_SUITE`, invoked via `graphdb_mgr:transaction/1`, mirroring gen_server-twin assertions, with the exact scenarios listed (design §"Testing") → Tasks 1–3 cover default(with/without/abstract), get_template(template/non-template/missing), ancestry(self/ancestor/unrelated/diamond). ✓ +- Non-goals (add_relationship collapse, abort conversions, up-front alloc) excluded → Global Constraints; not in any task. ✓ +- Docs updates (worker API contract addition) → Task 4. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; every command shows expected output. ✓ + +**Type consistency:** `get_template_in_txn/1` → `{ok,#node{}}|{error,not_a_template|not_found}`; `class_in_ancestry_in_txn/2` → `boolean()`; `default_template_in_txn/1` → `{ok,Nref}|not_found`. Test expectations account for the `graphdb_mgr:transaction/1` `{ok, _}` wrap consistently (`{ok,{ok,Node}}`, `{ok,true}`, `{ok,not_found}`). ✓