diff --git a/TASKS.md b/TASKS.md index 39af6e7..8c2095b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -146,7 +146,18 @@ Tracked follow-ups (not in the seam spec): `target_has_no_class`). Design `docs/designs/atomic-add-relationship-design.md`; plan `docs/superpowers/plans/2026-06-21-atomic-add-relationship.md`. -- **Batch `mutate([Mutation])`** — the tier-3 entry point. +- **Batch `mutate([Mutation])`** — IMPLEMENTED. Tier-3 batch entry point + `graphdb_mgr:mutate/1`: applies an ordered list of `add_relationship` / + `retire_node` / `unretire_node` mutations atomically in one + `graphdb_mgr:transaction/1`, composing tier-1 primitives directly. Opaque + bare-reason contract (`{ok, [ok, ...]}` | `{error, Reason}`, whole-batch + rollback, `mutate([]) -> {ok, []}`). Phase 2 resolves the seeded attr + nrefs once and allocates one rel-id pair per `add_relationship` outside + the transaction; phase 3 folds the prepared list in order. Required one + behaviour-preserving extraction — + `graphdb_instance:add_relationship_in_txn/9`. Design + `docs/designs/batch-mutate-design.md`; plan + `docs/superpowers/plans/2026-06-24-batch-mutate.md`. - **Converge default-template name search** — IMPLEMENTED. The shared walk is now `graphdb_class:find_template_by_name_in_txn/2` (exported tier-1 in-transaction primitive). `default_template_in_txn/1` delegates to it with diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index 8b6e6a8..39beda6 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -278,6 +278,12 @@ Creates and manages instance nodes in the project (instance space). - `create_instance/3,4,5` (name, class_nref, compositional_parent_nref [, connection_resolver [, conflict_resolver]]) — atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30), then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/4` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/3` uses the built-in `report_only` (defer-all) connection resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. `/5` threads a B5 **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/3` and `/4` inject the built-in `graphdb_rules:default_conflict_resolver/0`, which shadows conflicting inherited rules (nearest-level winner by mode priority), merges multiplicity (nearest Min, greatest Max), and demotes both-real-template losers to `propose` (F4 B5). - `add_relationship/4,5,6` (source_nref, characterization_nref, target_nref, reciprocal_nref [, template_nref [, {FwdAVPs, RevAVPs}]]) — validates endpoints, resolves source/target class and template scope, and writes the two directed `kind=connection` rows in a **single** `graphdb_mgr:transaction/1` (TOCTOU-isolated). The rel-id pair is allocated up-front (outside the transaction) via `rel_id_server:get_id_pair/0`. `/4` uses the source class's default template; `/5` takes an explicit template nref; `/6` adds per-direction AVPs. +- `add_relationship_in_txn/9` (IdPair, S, C, T, R, TemplateSpec, AVPSpec, + TkAttr, RetAttr) — tier-1 **in-transaction** primitive (bare-mnesia twin + of `add_relationship`'s transaction body; aborts on failure, never opens + its own txn). The caller allocates the rel-id pair up-front. + `do_add_relationship/7` (tier-2) and `graphdb_mgr:mutate/1` (tier-3) both + compose it into their single transaction. - `add_class_membership/2` (instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9) - `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2` @@ -371,6 +377,13 @@ Single public entry point; delegates to the five specialized workers. - In `init/1`: checks if `nodes` table is empty; if so, calls `graphdb_bootstrap:load/0` - Rejects any runtime request to create, modify, or delete a `category` node with `{error, category_nodes_are_immutable}` - Sequences Nref allocation → record write → Nref confirmation +- `mutate/1` — tier-3 batch entry point. Applies an ordered list of + `add_relationship` / `retire_node` / `unretire_node` mutations atomically + in one `transaction/1` (all commit or none). Tagged-tuple grammar; opaque + bare-reason contract `{ok, [ok, ...]}` | `{error, Reason}` with whole-batch + rollback; `mutate([]) -> {ok, []}`. A **plain function**, not a + `gen_server:call` — it owns the transaction in the caller's process. See + `docs/designs/batch-mutate-design.md`. --- diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index 13c0982..4fd4bf2 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -126,6 +126,8 @@ add_relationship/5, add_relationship/6, add_class_membership/2, + %% Tier-1 in-transaction primitive (write-path seam) + add_relationship_in_txn/9, %% Lookups get_instance/1, children/1, @@ -1192,28 +1194,48 @@ do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, RetAttr = State#state.retired_nref, %% Allocate the rel-id pair up-front, OUTSIDE the transaction: get_id_pair %% is a gen_server call and must never run inside an mnesia fun. A - %% validation abort below orphans this pair -- harmless (allocate-outside- - %% transaction doctrine). + %% validation abort inside the primitive orphans this pair -- harmless + %% (allocate-outside-transaction doctrine). IdPair = rel_id_server:get_id_pair(), - Txn = fun() -> - ok = validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref, - ReciprocalNref, TkAttr, RetAttr), - {SourceClass, TargetClass} = - resolve_arc_classes_in_txn(SourceNref, TargetNref), - TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass), - ok = graphdb_class:validate_template_scope_in_txn(TemplateNref, - SourceClass, TargetClass), - Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref, - ReciprocalNref, TemplateNref, AVPSpec), - lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, - Rows) - end, - case graphdb_mgr:transaction(Txn) of + case graphdb_mgr:transaction(fun() -> + add_relationship_in_txn(IdPair, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) + end) of {ok, ok} -> ok; {error, _} = Err -> Err end. +%%----------------------------------------------------------------------------- +%% add_relationship_in_txn(IdPair, Source, Char, Target, Reciprocal, +%% TemplateSpec, AVPSpec, TkAttr, RetAttr) -> ok +%% +%% Tier-1 write-path primitive. Must run inside an active mnesia transaction; +%% never opens its own. Validates endpoints, resolves source/target class and +%% template scope, then writes the two directed connection rows -- all with +%% bare mnesia ops, signalling any domain failure via mnesia:abort/1. The +%% rel-id pair must be allocated by the caller (get_id_pair is a gen_server +%% call and must never run inside an mnesia fun). Composes into a caller's +%% single transaction (the write-path seam's tier-1 contract); used by both +%% do_add_relationship/7 (tier-2) and graphdb_mgr:mutate/1 (tier-3). +%% Phase order: validate endpoints -> resolve classes -> resolve template -> +%% validate scope -> write. +%%----------------------------------------------------------------------------- +add_relationship_in_txn({_Id1, _Id2} = IdPair, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) -> + ok = validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref, + ReciprocalNref, TkAttr, RetAttr), + {SourceClass, TargetClass} = + resolve_arc_classes_in_txn(SourceNref, TargetNref), + TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass), + ok = graphdb_class:validate_template_scope_in_txn(TemplateNref, + SourceClass, TargetClass), + Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec), + lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, + Rows). + + %%----------------------------------------------------------------------------- %% validate_arc_endpoints_in_txn(Source, Char, Target, Reciprocal, TkAttr, %% RetAttr) -> ok (aborts the enclosing transaction on any violation) diff --git a/apps/graphdb/src/graphdb_mgr.erl b/apps/graphdb/src/graphdb_mgr.erl index e80dccb..288381e 100644 --- a/apps/graphdb/src/graphdb_mgr.erl +++ b/apps/graphdb/src/graphdb_mgr.erl @@ -119,6 +119,8 @@ retire_node/1, unretire_node/1, update_node_avps/2, + %% Batch write (tier-3 entry point) + mutate/1, %% Transaction helper (write-path seam) transaction/1, %% Cache invariant audit / repair @@ -296,6 +298,119 @@ transaction(Fun) -> end. +%%----------------------------------------------------------------------------- +%% mutate([Mutation]) -> {ok, [Result]} | {error, Reason} +%% +%% Tier-3 batch write entry point: applies an ordered list of mutations +%% ATOMICALLY in one graphdb_mgr:transaction/1, composing the write-path +%% seam's tier-1 primitives directly. All commit or none do. +%% +%% Mutation grammar (tagged tuples mirroring the public arities): +%% {add_relationship, S, C, T, R} default template, no AVPs +%% {add_relationship, S, C, T, R, Template} explicit template nref +%% {add_relationship, S, C, T, R, Template, {Fwd, Rev}} + per-direction AVPs +%% {retire_node, Nref} +%% {unretire_node, Nref} +%% +%% Returns {ok, [Result]} -- one native success value per mutation in list +%% order (every op returns `ok` today, so {ok, [ok, ok, ...]}) -- or the bare +%% {error, Reason} of the first aborting mutation with the whole batch rolled +%% back. mutate([]) -> {ok, []} (no transaction opened). +%% +%% Three phases: (1) static validation -- tuple shape + the permanent-tier +%% guard, no DB, no allocation; (2) a resource pre-pass OUTSIDE the +%% transaction -- resolve the seeded attr nrefs once and allocate one rel-id +%% pair per add_relationship (gen_server calls); (3) one transaction folding +%% the prepared list in order, dispatching each to a tier-1 in-txn primitive. +%% +%% Plain function, not a gen_server:call -- mnesia:transaction/1 runs in the +%% calling process and phase 2 calls OTHER gen_servers, so routing mutate +%% through graphdb_mgr would needlessly serialise batches. +%% See docs/designs/batch-mutate-design.md. +%%----------------------------------------------------------------------------- +-spec mutate([tuple()]) -> {ok, [term()]} | {error, term()}. +mutate(Mutations) -> + case validate_mutations(Mutations) of + ok -> run_mutations(Mutations); + {error, _} = Err -> Err + end. + +%% Phase 1: static validation. No DB access, no allocation. A malformed term +%% -> {error, {bad_mutation, M}}; a permanent-tier retire/unretire -> +%% {error, permanent_node_immutable} (the same static guard set_retired/3 +%% applies in the solo path). +validate_mutations([]) -> + ok; +validate_mutations([M | Rest]) -> + case validate_mutation(M) of + ok -> validate_mutations(Rest); + {error, _} = Err -> Err + end. + +validate_mutation({add_relationship, _S, _C, _T, _R}) -> + ok; +validate_mutation({add_relationship, _S, _C, _T, _R, _Template}) -> + ok; +validate_mutation({add_relationship, _S, _C, _T, _R, _Template, {_Fwd, _Rev}}) -> + ok; +validate_mutation({retire_node, Nref}) when is_integer(Nref) -> + tier_guard(Nref); +validate_mutation({unretire_node, Nref}) when is_integer(Nref) -> + tier_guard(Nref); +validate_mutation(M) -> + {error, {bad_mutation, M}}. + +tier_guard(Nref) when Nref >= ?NREF_START -> ok; +tier_guard(_Nref) -> {error, permanent_node_immutable}. + +%% Phases 2 + 3. Precondition: Mutations already passed validate_mutations/1. +%% Empty batch short-circuits with no transaction. +run_mutations([]) -> + {ok, []}; +run_mutations(Mutations) -> + %% Phase 2 (outside the transaction): resolve the seeded attr nrefs once, + %% and allocate one rel-id pair per add_relationship. + {ok, #{target_kind := TkAttr, retired := RetAttr}} = + graphdb_attr:seeded_nrefs(), + Prepared = [prepare(M) || M <- Mutations], + %% Phase 3: one transaction folding the prepared list in order. + graphdb_mgr:transaction(fun() -> + [dispatch(P, TkAttr, RetAttr) || P <- Prepared] + end). + +%% Phase 2 per-mutation prep. Allocates one rel-id pair per add_relationship +%% via rel_id_server (a gen_server call -- MUST stay outside the transaction) +%% and normalises each add_relationship to the explicit +%% (TemplateSpec, AVPSpec) form. retire/unretire need no resources. +%% Prepared add_relationship shape: +%% {add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec} +prepare({add_relationship, S, C, T, R}) -> + {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, + default, {[], []}}; +prepare({add_relationship, S, C, T, R, Template}) -> + {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, + Template, {[], []}}; +prepare({add_relationship, S, C, T, R, Template, AVPSpec}) -> + {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, + Template, AVPSpec}; +prepare({retire_node, _Nref} = M) -> + M; +prepare({unretire_node, _Nref} = M) -> + M. + +%% Phase 3 dispatch. Runs INSIDE the transaction: no gen_server calls, no +%% transaction/1, no rel-id allocation here (all done in phase 2). Each +%% tier-1 primitive returns ok or calls mnesia:abort/1. +dispatch({add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec}, + TkAttr, RetAttr) -> + graphdb_instance:add_relationship_in_txn(IdPair, S, C, T, R, TemplateSpec, + AVPSpec, TkAttr, RetAttr); +dispatch({retire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Nref, true, RetAttr); +dispatch({unretire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Nref, false, RetAttr). + + %%----------------------------------------------------------------------------- %% verify_caches() -> ok | {error, [{Nref, Field, Expected, Actual}, ...]} %% diff --git a/apps/graphdb/test/graphdb_mgr_SUITE.erl b/apps/graphdb/test/graphdb_mgr_SUITE.erl index c397223..bf13ed9 100644 --- a/apps/graphdb/test/graphdb_mgr_SUITE.erl +++ b/apps/graphdb/test/graphdb_mgr_SUITE.erl @@ -101,7 +101,18 @@ retire_node_is_idempotent/1, retire_node_refuses_permanent_tier/1, retire_node_not_found/1, - get_node_hides_retired/1 + get_node_hides_retired/1, + %% Batch mutate + mutate_empty_batch/1, + mutate_single_add_relationship/1, + mutate_single_retire_and_unretire/1, + mutate_mixed_all_succeed/1, + mutate_atomic_rollback/1, + mutate_read_your_writes_rollback/1, + mutate_malformed_term/1, + mutate_permanent_tier_guard/1, + mutate_add_relationship_explicit_template/1, + mutate_add_relationship_with_avps/1 ]). @@ -116,7 +127,7 @@ all() -> [{group, init_tests}, {group, read_ops}, {group, category_guard}, {group, write_delegation}, {group, cache_audit}, {group, transaction_seam}, - {group, soft_retire}]. + {group, soft_retire}, {group, mutate}]. groups() -> [ @@ -170,6 +181,18 @@ groups() -> retire_node_refuses_permanent_tier, retire_node_not_found, get_node_hides_retired + ]}, + {mutate, [], [ + mutate_empty_batch, + mutate_single_add_relationship, + mutate_single_retire_and_unretire, + mutate_mixed_all_succeed, + mutate_atomic_rollback, + mutate_read_your_writes_rollback, + mutate_malformed_term, + mutate_permanent_tier_guard, + mutate_add_relationship_explicit_template, + mutate_add_relationship_with_avps ]} ]. @@ -228,7 +251,17 @@ init_per_testcase(TC, Config) when TC =:= retire_node_is_idempotent; TC =:= retire_node_refuses_permanent_tier; TC =:= retire_node_not_found; - TC =:= get_node_hides_retired -> + TC =:= get_node_hides_retired; + TC =:= mutate_empty_batch; + TC =:= mutate_single_add_relationship; + TC =:= mutate_single_retire_and_unretire; + TC =:= mutate_mixed_all_succeed; + TC =:= mutate_atomic_rollback; + TC =:= mutate_read_your_writes_rollback; + TC =:= mutate_malformed_term; + TC =:= mutate_permanent_tier_guard; + TC =:= mutate_add_relationship_explicit_template; + TC =:= mutate_add_relationship_with_avps -> Config1 = setup_isolated_env(Config), BootstrapFile = proplists:get_value(bootstrap_file, Config), application:set_env(seerstone_graph_db, bootstrap_file, BootstrapFile), @@ -907,3 +940,217 @@ get_node_hides_retired(_Config) -> ?assertEqual({error, retired}, graphdb_mgr:get_node(ClassNref)), ok = graphdb_mgr:unretire_node(ClassNref), {ok, #node{nref = ClassNref}} = graphdb_mgr:get_node(ClassNref). + + +%%============================================================================= +%% Batch mutate Tests +%% +%% mutate/1 applies an ordered list of mutations atomically in one +%% transaction. Workers are pre-started in init_per_testcase for this group. +%%============================================================================= + +%%----------------------------------------------------------------------------- +%% Empty batch is a no-op: {ok, []}, no transaction opened. +%%----------------------------------------------------------------------------- +mutate_empty_batch(_Config) -> + ?assertEqual({ok, []}, graphdb_mgr:mutate([])). + +%%----------------------------------------------------------------------------- +%% A single add_relationship returns {ok, [ok]} and writes the arc. +%%----------------------------------------------------------------------------- +mutate_single_add_relationship(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MClassAR", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MKnows", "MKnownBy", + instance), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate( + [{add_relationship, InstA, CharNref, InstB, RecipNref}])), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Targets = [R#relationship.target_nref || R <- Rels, + R#relationship.characterization =:= CharNref], + ?assertEqual([InstB], Targets). + +%%----------------------------------------------------------------------------- +%% A single retire_node sets the marker; a single unretire_node clears it. +%%----------------------------------------------------------------------------- +mutate_single_retire_and_unretire(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MRetire", 3), + ?assert(ClassNref >= ?NREF_START), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate([{retire_node, ClassNref}])), + [#node{attribute_value_pairs = AVPs1}] = + mnesia:dirty_read(nodes, ClassNref), + ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs1)), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate([{unretire_node, ClassNref}])), + [#node{attribute_value_pairs = AVPs2}] = + mnesia:dirty_read(nodes, ClassNref), + ?assertEqual(false, + lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs2)). + +%%----------------------------------------------------------------------------- +%% A mixed batch (two add_relationship + one retire) all succeeds: every +%% effect is present after commit. +%%----------------------------------------------------------------------------- +mutate_mixed_all_succeed(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MMixed", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MMA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MMB", ClassNref, 5), + {ok, InstC, _} = graphdb_instance:create_instance("MMC", ClassNref, 5), + {ok, {Ch1, Re1}} = + graphdb_attr:create_relationship_attribute_pair("MM1", "MM1r", instance), + {ok, {Ch2, Re2}} = + graphdb_attr:create_relationship_attribute_pair("MM2", "MM2r", instance), + Batch = [{add_relationship, InstA, Ch1, InstB, Re1}, + {add_relationship, InstA, Ch2, InstC, Re2}, + {retire_node, InstB}], + ?assertEqual({ok, [ok, ok, ok]}, graphdb_mgr:mutate(Batch)), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Chars = lists:sort([R#relationship.characterization || R <- Rels, + R#relationship.characterization =:= Ch1 orelse + R#relationship.characterization =:= Ch2]), + ?assertEqual(lists:sort([Ch1, Ch2]), Chars), + [#node{attribute_value_pairs = BAVPs}] = mnesia:dirty_read(nodes, InstB), + ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, BAVPs)). + +%%----------------------------------------------------------------------------- +%% Atomic rollback: a valid add_relationship followed by a retire of a +%% nonexistent node aborts with {error, not_found}, and the relationship the +%% first mutation wrote is absent (the whole batch rolled back). +%%----------------------------------------------------------------------------- +mutate_atomic_rollback(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MRollback", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MRA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MRB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MRKnows", "MRKnownBy", + instance), + BadNref = ?NREF_START + 999999, + Batch = [{add_relationship, InstA, CharNref, InstB, RecipNref}, + {retire_node, BadNref}], + ?assertEqual({error, not_found}, graphdb_mgr:mutate(Batch)), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Targets = [R#relationship.target_nref || R <- Rels, + R#relationship.characterization =:= CharNref], + ?assertEqual([], Targets). + +%%----------------------------------------------------------------------------- +%% Read-your-writes rollback: retire X, then relate from X in the same batch. +%% The relationship's endpoint validation sees X's uncommitted retired marker +%% and aborts {endpoint_retired, X}; both mutations roll back, so X is NOT +%% retired afterward. +%%----------------------------------------------------------------------------- +mutate_read_your_writes_rollback(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MRYW", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MRYWA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MRYWB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MRYWK", "MRYWKr", + instance), + Batch = [{retire_node, InstA}, + {add_relationship, InstA, CharNref, InstB, RecipNref}], + ?assertEqual({error, {endpoint_retired, InstA}}, + graphdb_mgr:mutate(Batch)), + [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, InstA), + ?assertEqual(false, + lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs)). + +%%----------------------------------------------------------------------------- +%% A malformed mutation term is rejected in phase 1 with +%% {error, {bad_mutation, M}}; the well-formed mutation preceding it in the +%% batch writes nothing (phase 1 rejects the whole batch before phase 2/3). +%%----------------------------------------------------------------------------- +mutate_malformed_term(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MBad", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MBadA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MBadB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MBadK", "MBadKr", + instance), + Bad = {frobnicate, 1, 2}, + Batch = [{add_relationship, InstA, CharNref, InstB, RecipNref}, Bad], + ?assertEqual({error, {bad_mutation, Bad}}, graphdb_mgr:mutate(Batch)), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Targets = [R#relationship.target_nref || R <- Rels, + R#relationship.characterization =:= CharNref], + ?assertEqual([], Targets). + +%%----------------------------------------------------------------------------- +%% The permanent-tier guard rejects retire/unretire of a node below +%% ?NREF_START with {error, permanent_node_immutable}, before any write. +%% Asserts the bootstrap node carries no retired marker afterward +%% (attribute-specific check -- node 27 may carry other AVPs). +%%----------------------------------------------------------------------------- +mutate_permanent_tier_guard(_Config) -> + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:mutate([{retire_node, 27}])), + {ok, #{retired := RetAttr}} = graphdb_attr:seeded_nrefs(), + [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, 27), + ?assertEqual(false, lists:any( + fun(#{attribute := A, value := true}) when A =:= RetAttr -> true; + (_) -> false end, AVPs)). + +%%----------------------------------------------------------------------------- +%% mutate accepts the 6-element add_relationship form with an explicit +%% template nref; the Template AVP on the written arc is that template. +%%----------------------------------------------------------------------------- +mutate_add_relationship_explicit_template(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MTmplClass", 3), + {ok, AltTmpl} = graphdb_class:add_template(ClassNref, "msocial"), + {ok, A, _} = graphdb_instance:create_instance("MTA", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance("MTB", ClassNref, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("MTKnows", "MTKnownBy", + instance), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate( + [{add_relationship, A, Char, B, Recip, AltTmpl}])), + {atomic, ARels} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + [Fwd] = [R || R <- ARels, + R#relationship.characterization =:= Char, + R#relationship.target_nref =:= B], + ?assert(lists:member(#{attribute => ?ARC_TEMPLATE, value => AltTmpl}, + Fwd#relationship.avps)). + +%%----------------------------------------------------------------------------- +%% mutate accepts the 7-element add_relationship form with per-direction +%% AVPs; the forward AVP lands on the forward arc only and the reverse AVP +%% on the reverse arc only. +%%----------------------------------------------------------------------------- +mutate_add_relationship_with_avps(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MAvpClass", 3), + {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), + {ok, A, _} = graphdb_instance:create_instance("MAvA", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance("MAvB", ClassNref, 5), + {ok, {Char, Recip}} = + graphdb_attr:create_relationship_attribute_pair("MAvKnows", "MAvKnownBy", + instance), + {ok, Source} = graphdb_attr:create_literal_attribute("msource", string), + {ok, Confidence} = graphdb_attr:create_literal_attribute("mconf", float), + FwdOnly = #{attribute => Source, value => "research-paper"}, + RevOnly = #{attribute => Confidence, value => 0.42}, + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate( + [{add_relationship, A, Char, B, Recip, DefaultTmpl, + {[FwdOnly], [RevOnly]}}])), + {atomic, ARels} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, A, #relationship.source_nref) + end), + [Fwd] = [R || R <- ARels, + R#relationship.characterization =:= Char, + R#relationship.target_nref =:= B], + ?assert(lists:member(FwdOnly, Fwd#relationship.avps)), + ?assertNot(lists:member(RevOnly, Fwd#relationship.avps)), + {atomic, BRels} = mnesia:transaction(fun() -> + mnesia:index_read(relationships, B, #relationship.source_nref) + end), + [Rev] = [R || R <- BRels, + R#relationship.characterization =:= Recip, + R#relationship.target_nref =:= A], + ?assert(lists:member(RevOnly, Rev#relationship.avps)), + ?assertNot(lists:member(FwdOnly, Rev#relationship.avps)). diff --git a/docs/Architecture.md b/docs/Architecture.md index b1cf0e5..e4f6c8f 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -263,6 +263,10 @@ maintains. `graphdb_mgr` is the public entry point and routes to the workers — read path and soft-retire implemented; remaining write-side routing is pending (see [`../TASKS.md`](../TASKS.md)). +The tier-3 batch entry point `graphdb_mgr:mutate/1` applies an ordered list +of `add_relationship` / `retire_node` / `unretire_node` mutations atomically +in one transaction, composing the tier-1 primitives directly. + --- ## 6. Ontology and Project (Instance Space) diff --git a/docs/designs/batch-mutate-design.md b/docs/designs/batch-mutate-design.md new file mode 100644 index 0000000..6708a0e --- /dev/null +++ b/docs/designs/batch-mutate-design.md @@ -0,0 +1,344 @@ + + +# Batch `mutate/1` — Tier-3 Entry Point — Design + +**Status:** Designed; not yet planned or implemented. + +**Context:** The last open follow-up of the write-path transaction-layering +seam (`docs/designs/write-path-transaction-seam-design.md`). The seam +defined three tiers; tiers 1 and 2 are built out (PRs #43–#47). This slice +delivers the **tier-3 batch entry point** the seam sketched: +`mutate([Mutation])` applies a list of mutations atomically in one Mnesia +transaction, composing tier-1 primitives directly. + +**Spec citation:** none. The knowledge-network spec +(`docs/TheKnowledgeNetwork.md`) is a data model and is silent on +transaction mechanics. This is infrastructural — it records how a batch of +write-path mutations composes over Mnesia. + +--- + +## 1. Scope + +### 1.1 What this slice delivers + +A single public function `graphdb_mgr:mutate/1` that applies an ordered +list of mutations **atomically** — all commit or none do — by folding the +seam's tier-1 primitives inside one `graphdb_mgr:transaction/1`. + +### 1.2 Mutation set + +The batch covers the three write operations that are fully implemented +today and already have (or cleanly yield) tier-1 primitives: + +| Mutation | Tier-1 primitive it dispatches to | +| ---------------- | -------------------------------------------------------- | +| `add_relationship` | `graphdb_instance:add_relationship_in_txn/9` (extracted, §4) | +| `retire_node` | `graphdb_mgr:set_retired_/3` (already exists) | +| `unretire_node` | `graphdb_mgr:set_retired_/3` (already exists) | + +### 1.3 Out of scope (deferred, with reasons) + +| Item | Why deferred | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `create_instance` / `create_class` / `create_attribute` | No tier-1 write primitives exist; creates allocate node nrefs through a gen_server (cannot run inside a txn) and `create_instance` is entangled with B2–B5 rule firing. A separate, larger slice. | +| `delete_node`, `update_node_avps` | Both return `{error, not_implemented}` today — nothing to batch. | +| Symbolic back-references between mutations (`create A; relate A→B`) | Requires the create primitives above plus a bootstrap-style symbol table. Out of scope until creates land. | +| Per-mutation indexed error reporting | Rejected on principle — see §3.3. | +| Named composites (e.g. "delete an instance with its parts") | A *separate* tier-3 shape — see §6. Not this slice. | + +--- + +## 2. Mutation grammar + +Mutations are **tagged tuples** mirroring the existing public arities of +the operations they batch (no maps — tuples are the codebase's write-API +idiom and pattern-match directly in the fold): + +```erlang +{add_relationship, S, C, T, R} %% default template, no AVPs +{add_relationship, S, C, T, R, Template} %% explicit template nref +{add_relationship, S, C, T, R, Template, {Fwd, Rev}} %% + per-direction AVPs +{retire_node, Nref} +{unretire_node, Nref} +``` + +The `add_relationship` arities map onto `do_add_relationship`'s +`(TemplateSpec, AVPSpec)` exactly as the public `add_relationship/4,5,6` +do: + +| Tuple | `TemplateSpec` | `AVPSpec` | +| ---------------------------------------------- | -------------- | ------------- | +| `{add_relationship, S, C, T, R}` | `default` | `{[], []}` | +| `{add_relationship, S, C, T, R, Template}` | `Template` | `{[], []}` | +| `{add_relationship, S, C, T, R, Template, AVP}`| `Template` | `AVP` | + +--- + +## 3. Contract + +### 3.1 Return shape + +```erlang +-spec mutate([mutation()]) -> {ok, [Result]} | {error, term()}. +``` + +- **Success:** `{ok, [Result]}` — one element per mutation, in list order, + each the underlying operation's **native success value**. All three + operations return `ok` today (`do_add_relationship` returns `ok`, not the + id pair), so a successful batch is `{ok, [ok, ok, …]}`. The list length + confirms every mutation applied. +- **Failure:** `{error, Reason}` — the **bare** domain reason of the first + mutation that aborts (`not_found`, `{endpoint_retired, X}`, + `permanent_node_immutable`, + …). The **whole batch is rolled back** (atomicity); no partial effects + survive. +- **Empty batch:** `mutate([]) -> {ok, []}` (vacuous, no transaction + opened). + +### 3.2 Why a list, and why opaque + +A generic batch cannot return each operation's bespoke solo shape because +those shapes are heterogeneous. The aggregate `{ok, [Result]}` is the +minimum a batch can be; the *elements* are each op's native value, so a +direct caller sees no surprises inside the list. + +The error is **bare**, not `{error, {Index, Reason}}`, by design. Every +solo operation returns `{error, Reason}` with a bare domain reason. Wrapping +the reason in an index would change the reason *structure* — a caller that +matches `{error, not_found}` today would have to rewrite to +`{error, {_, not_found}}`. That destructuring is itself a form of wrapping. +Keeping the reason bare makes `mutate/1` **drop-in compatible** with the +error-handling callers already write for the solo operations — which is the +whole point of a directly-usable tier-3 entry (it should not require a +mandatory adapter). + +### 3.3 Indexed errors: considered and rejected + +An earlier draft proposed `{error, {Index, Reason}}` to name *which* +mutation failed. It was rejected for two independent reasons: + +1. **Contract (decisive).** Per §3.2 it breaks drop-in compatibility with + existing `{error, Reason}` handling. +2. **Mechanism (confirms it).** `add_relationship`'s failures abort with a + *bare* reason deep inside `validate_arc_endpoints_in_txn` / + `validate_template_scope_in_txn` (shipped in PR #45), nowhere near the + fold that knows the index. `mnesia:abort(Reason)` is + `exit({aborted, Reason})` (OTP 28.5 `mnesia.erl:700`), and mnesia's + **deadlock-restart signal uses the same shape** — + `exit({aborted, #cyclic{}})` / `{node_not_running,_}` / `{bad_commit,_}` + (`mnesia_tm.erl:908–913`). A `catch exit:{aborted, R}` at the fold to + attach the index would also swallow the restart signal, converting a + retryable deadlock into a hard failure. Avoiding that means either + re-raising mnesia's internal restart records (couples to `mnesia.hrl` + internals) or tracking the index in the process dictionary. Neither is + warranted once the contract argument has already settled the question. + +Callers who need to localise a failure keep batches short or bisect. + +### 3.4 Read-your-writes ordering + +Mutations apply in **list order** inside one transaction, so each sees the +*uncommitted* writes of those before it. Concretely, +`[{retire_node, X}, {add_relationship, X, C, T, R}]` aborts with +`{endpoint_retired, X}` (the relationship's endpoint validation — +`validate_arc_endpoints_in_txn` at `graphdb_instance.erl:1248` — sees `X` +already carrying the uncommitted `retired` marker) and rolls back **both** +mutations — `X` is not retired after the call. This is the +correct, predictable semantics and is covered by a test (§7). + +--- + +## 4. The one refactor: extract `add_relationship_in_txn/9` + +`retire`/`unretire` already dispatch to a tier-1 primitive +(`graphdb_mgr:set_retired_/3` — bare mnesia, aborts on failure, takes the +`retired` attr nref as a parameter). `add_relationship` does not yet: its +in-transaction body is inline in `do_add_relationship/7`, and it reads two +seeded attr nrefs (`target_kind_avp_nref`, `retired_nref`) from the +`graphdb_instance` gen_server state. + +Apply the **"add, don't rewrap"** pattern from PRs #44/#45: extract the +transaction body verbatim into an exported tier-1 primitive that takes the +seeds and the pre-allocated id pair as parameters: + +```erlang +%% Tier-1. Must run inside an active mnesia transaction. Aborts on failure. +-spec add_relationship_in_txn(IdPair, S, C, T, R, TemplateSpec, AVPSpec, + TkAttr, RetAttr) -> ok. +add_relationship_in_txn({_Id1, _Id2} = IdPair, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) -> + ok = validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref, + ReciprocalNref, TkAttr, RetAttr), + {SourceClass, TargetClass} = + resolve_arc_classes_in_txn(SourceNref, TargetNref), + TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass), + ok = graphdb_class:validate_template_scope_in_txn(TemplateNref, + SourceClass, TargetClass), + Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec), + lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, + Rows). +``` + +`do_add_relationship/7` then becomes a thin tier-2 wrapper that is +**byte-for-byte behaviour-identical** to today — it reads `TkAttr`/`RetAttr` +from `State`, allocates the id pair up-front (outside the txn, as now), and +runs the primitive through `transaction/1`: + +```erlang +do_add_relationship(S, C, T, R, TemplateSpec, AVPSpec, State) -> + TkAttr = State#state.target_kind_avp_nref, + RetAttr = State#state.retired_nref, + IdPair = rel_id_server:get_id_pair(), + case graphdb_mgr:transaction(fun() -> + add_relationship_in_txn(IdPair, S, C, T, R, TemplateSpec, + AVPSpec, TkAttr, RetAttr) + end) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. +``` + +No phase logic changes; the existing `add_relationship` suite must stay +green unchanged, which is the proof the extraction preserves behaviour. + +--- + +## 5. Architecture — three phases + +`graphdb_mgr:mutate/1` is a **plain exported function**, not a +`gen_server:call` — exactly like `transaction/1`. `mnesia:transaction/1` +runs in the *calling* process, and the pre-pass makes gen_server calls to +*other* servers (`graphdb_attr`, `rel_id_server`), so routing `mutate` +itself through the `graphdb_mgr` process would needlessly serialise batches +and risk deadlock. + +### Phase 1 — static validation (no DB, no allocation) + +Walk the list once. For each element: + +- check tuple shape/arity against the §2 grammar; a malformed term → + `{error, {bad_mutation, M}}`; +- for `retire_node`/`unretire_node`, apply the permanent-tier arithmetic + guard (`Nref >= ?NREF_START`, else `{error, permanent_node_immutable}`) — + the same static guard `set_retired/3` applies in the solo path. + +This fails fast **before** any nref or rel-id is allocated, so a malformed +batch wastes no resources. `mutate([])` short-circuits to `{ok, []}` here. + +### Phase 2 — resource pre-pass (gen_server calls, outside the txn) + +For a non-empty, validated batch: + +- resolve `{TkAttr, RetAttr}` **once** via `graphdb_attr:seeded_nrefs/0` + (the identical source `graphdb_instance:init/1` reads its + `target_kind`/`retired` seeds from); +- allocate **one rel-id pair per `add_relationship`** via + `rel_id_server:get_id_pair/0`. + +Produces a list of *prepared* mutations, each term paired with the +resources its tier-1 primitive needs. As with the solo `add_relationship` +path, a later abort orphans any rel-id pairs already allocated — harmless, +per the allocate-outside-transaction doctrine (PR #45). + +### Phase 3 — one transaction + +`graphdb_mgr:transaction(fun() -> [dispatch(P) || P <- Prepared] end)`, +folding the prepared list **in order**: + +| Prepared mutation | Dispatch | +| ----------------- | ------------------------------------------------------------------------ | +| add_relationship | `graphdb_instance:add_relationship_in_txn(IdPair, S,C,T,R, TemplateSpec, AVPSpec, TkAttr, RetAttr)` | +| retire_node | `set_retired_(Nref, true, RetAttr)` | +| unretire_node | `set_retired_(Nref, false, RetAttr)` | + +Each primitive returns `ok` or calls `mnesia:abort(Reason)`. The first +abort unwinds the whole transaction; `transaction/1` maps `{aborted, Reason}` +→ `{error, Reason}` (the bare reason, §3.1). On success the list comprehension +yields `[ok, ok, …]`, and `transaction/1` maps `{atomic, L}` → `{ok, L}`. + +`set_retired_/3` is module-local to `graphdb_mgr`, so `mutate/1` (same +module) calls it directly. `add_relationship_in_txn/9` is a plain exported +function on `graphdb_instance`, called directly inside the transaction +fun — the seam's intended cross-module tier-1 composition. + +--- + +## 6. Relationship to named composites + +The seam lists two tier-3 shapes: this generic `mutate([Mutation])` **and** +named composites such as "delete an instance with its parts." They are +distinct and both legitimate: + +- **Generic `mutate/1`** — ad-hoc, list-driven, returns the generic + aggregate contract (§3). Directly usable; no mandatory wrapper. +- **A named composite `F`** — a specific recurring sequence with a *bespoke* + return matching its domain (e.g. `ok | {error, Reason}`). It composes the + tier-1 primitives **directly** inside one `transaction/1` and returns its + own shape. It does **not** route through generic `mutate/1`, precisely + because it wants a return that the generic aggregate cannot provide. + +This slice builds only the generic `mutate/1`. Named composites, if any are +needed later, are separate slices that reuse the same tier-1 primitives. + +--- + +## 7. Testing + +New `mutate` cases (CT, against the real `nodes`/`relationships` scratch +tables, per the suite's per-case isolation): + +1. **Empty batch** — `mutate([])` → `{ok, []}`; no transaction side effects. +2. **Single `add_relationship`** — `{ok, [ok]}`; both directed rows present. +3. **Single `retire_node`** / **`unretire_node`** — `{ok, [ok]}`; marker + set / cleared. +4. **Mixed all-succeed** — e.g. two `add_relationship` + one `retire_node` + → `{ok, [ok, ok, ok]}`; every effect present after commit. +5. **Atomic rollback** — a valid `add_relationship` followed by + `{retire_node, NonexistentNref}` → `{error, not_found}`, **and** the + relationship rows the first mutation wrote are **absent** (the batch + rolled back). This is the core atomicity guarantee. +6. **Read-your-writes rollback** — + `[{retire_node, X}, {add_relationship, X, C, T, R}]` → + `{error, {endpoint_retired, X}}`, and `X` is **not** retired afterward + (§3.4). +7. **Malformed term** — a batch containing a malformed tuple → + `{error, {bad_mutation, M}}`, and the well-formed mutation that preceded + it in the same batch left **no rows written** (phase 1 rejects the whole + batch before phase 2/3 run). The "no rel-id allocated" property is real + but not asserted: `rel_id_server` exposes no non-consuming peek, and + orphaned rel-ids are harmless by design (allocate-outside-transaction + doctrine), so the test asserts the *contract* — error reason + no rows — + not the internal allocation count. +8. **Permanent-tier guard** — `{retire_node, NrefBelowStart}` → + `{error, permanent_node_immutable}`; nothing written. + +Plus **behaviour preservation:** the existing `add_relationship` CT suite +must pass unchanged, proving the §4 extraction is byte-identical. A direct +test of `add_relationship_in_txn/9` via `transaction/1` is optional (the +solo suite already exercises every branch through `do_add_relationship/7`). + +--- + +## 8. Files touched + +| File | Change | +| ------------------------------------------ | ------------------------------------------------------------------- | +| `apps/graphdb/src/graphdb_instance.erl` | Extract + export tier-1 `add_relationship_in_txn/9`; `do_add_relationship/7` delegates (§4) | +| `apps/graphdb/src/graphdb_mgr.erl` | Add exported `mutate/1` + phase-1/phase-2 helpers; reuse `set_retired_/3` | +| `apps/graphdb/test/graphdb_mgr_SUITE.erl` | New `mutate` test group (§7) | +| `apps/graphdb/CLAUDE.md` | `graphdb_mgr` API blurb: `mutate/1`; `graphdb_instance` tier-1 list: `add_relationship_in_txn/9` | +| `docs/Architecture.md` | One line noting the tier-3 `mutate/1` public entry on the write path | +| `TASKS.md` | Flip "Batch `mutate([Mutation])`" to IMPLEMENTED | + +--- + +## 9. Open items + +None. Scope, grammar, contract (opaque, bare-reason), the three-phase +architecture, the single behaviour-preserving extraction, and the test plan +are fixed. The name `mutate/1` is confirmed. diff --git a/docs/superpowers/plans/2026-06-24-batch-mutate.md b/docs/superpowers/plans/2026-06-24-batch-mutate.md new file mode 100644 index 0000000..5d7b5e1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-batch-mutate.md @@ -0,0 +1,739 @@ + + +# Batch `mutate/1` 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 a tier-3 batch write entry point `graphdb_mgr:mutate/1` that +applies an ordered list of `add_relationship` / `retire_node` / +`unretire_node` mutations atomically in one Mnesia transaction. + +**Architecture:** `mutate/1` is a plain exported function (not a +`gen_server:call`), mirroring `graphdb_mgr:transaction/1`. It runs three +phases: (1) static validation (no DB, no allocation), (2) a resource +pre-pass that resolves the seeded attr nrefs once and allocates one rel-id +pair per `add_relationship` — all **outside** the transaction, and (3) one +`graphdb_mgr:transaction/1` that folds the prepared mutations in order, +dispatching each to a tier-1 in-transaction primitive. To make +`add_relationship` composable inside that fold, its existing in-transaction +body is extracted verbatim into a new exported tier-1 primitive +`graphdb_instance:add_relationship_in_txn/9` (the "add, don't rewrap" +pattern from PRs #44/#45). + +**Tech Stack:** Erlang/OTP 28.5, Mnesia, rebar3 3.27 (invoke as repo-local +`./rebar3` — PATH/kerl are preset; do **not** prefix with `source ~/.bashrc`). +Common Test for integration tests. + +**Design:** `docs/designs/batch-mutate-design.md` (approved). Read it first. + +## Global Constraints + +- Source files use **hard tabs** for indentation (match the surrounding + file exactly — never expand tabs to spaces). +- Every module keeps its existing header (copyright block, author, revision + history, `-module`, attributes, NYI/UEM macros, explicit `-export`). +- `mutate/1` is a **plain exported function**, never a `gen_server:call` / + `handle_call`. Within `graphdb_mgr` it calls the transaction runner + fully-qualified as `graphdb_mgr:transaction(...)` (matching the existing + `set_retired/3` style). +- **The central invariant (load-bearing):** `rel_id_server:get_id_pair/0` + and `graphdb_attr:seeded_nrefs/0` are gen_server calls and run **only** in + phase 2, **outside** the transaction. Phase 3's fold calls the `_in_txn` + primitives **directly** — never `transaction/1`, never `get_id_pair/0`, + never `seeded_nrefs/0` inside the transaction fun. +- Return contract is **opaque, bare-reason**: `{ok, [Result]}` on success + (every op returns `ok`, so `{ok, [ok, ...]}`), `{error, Reason}` with the + **bare** domain reason of the first aborting mutation, whole batch rolled + back. `mutate([]) -> {ok, []}` with no transaction opened. No index in the + error. +- Behaviour preservation: the §4 extraction must leave the existing + `add_relationship` behaviour byte-identical — the existing + `graphdb_instance` and `graphdb_mgr` add_relationship tests must pass + unchanged. +- Build/test from the project root. Compile: `./rebar3 compile` (must be + warning-free). CT for one suite: + `./rebar3 ct --suite apps/graphdb/test/` (see each task for the + exact command). + +--- + +### Task 1: Extract `add_relationship_in_txn/9` (behaviour-preserving) + +Lift the in-transaction body of `do_add_relationship/7` into a new exported +tier-1 primitive on `graphdb_instance`, and make `do_add_relationship/7` a +thin wrapper that allocates the rel-id pair, reads its two seeded nrefs from +`State`, and runs the primitive through `graphdb_mgr:transaction/1`. No +behaviour changes — the proof is that the existing add_relationship tests +pass unchanged. + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` — export list at + `apps/graphdb/src/graphdb_instance.erl:119-137`; `do_add_relationship/7` + at `apps/graphdb/src/graphdb_instance.erl:1189-1214`. + +**Interfaces:** +- Consumes: existing private helpers `validate_arc_endpoints_in_txn/6`, + `resolve_arc_classes_in_txn/2`, `resolve_template_in_txn/2`, + `build_connection_rows/7`, and `graphdb_class:validate_template_scope_in_txn/3` + (all unchanged). +- Produces: exported + `graphdb_instance:add_relationship_in_txn(IdPair, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) -> ok` + where `IdPair :: {integer(), integer()}`, `TemplateSpec :: default | + integer()`, `AVPSpec :: {[map()], [map()]}`, `TkAttr`/`RetAttr :: + integer()`. Must run inside an active Mnesia transaction; aborts via + `mnesia:abort/1` on any domain failure. Consumed by Task 2's `mutate/1`. + +- [ ] **Step 1: Add `add_relationship_in_txn/9` to the export list** + +In the public `-export([...])` block (`graphdb_instance.erl:119-137`), add +the primitive right after `add_class_membership/2`, under a new comment line: + +```erlang + add_class_membership/2, + %% Tier-1 in-transaction primitive (write-path seam) + add_relationship_in_txn/9, +``` + +- [ ] **Step 2: Extract the primitive and rewrite `do_add_relationship/7`** + +Replace the whole of `do_add_relationship/7` +(`apps/graphdb/src/graphdb_instance.erl:1189-1214`) — keep its existing +doc-comment header (lines 1178-1188) above it — with the thin wrapper below, +**followed immediately** by the new primitive (note the body of the +primitive is the verbatim lift of the old `Txn` fun): + +```erlang +do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateSpec, AVPSpec, State) -> + TkAttr = State#state.target_kind_avp_nref, + RetAttr = State#state.retired_nref, + %% Allocate the rel-id pair up-front, OUTSIDE the transaction: get_id_pair + %% is a gen_server call and must never run inside an mnesia fun. A + %% validation abort inside the primitive orphans this pair -- harmless + %% (allocate-outside-transaction doctrine). + IdPair = rel_id_server:get_id_pair(), + case graphdb_mgr:transaction(fun() -> + add_relationship_in_txn(IdPair, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) + end) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. + + +%%----------------------------------------------------------------------------- +%% add_relationship_in_txn(IdPair, Source, Char, Target, Reciprocal, +%% TemplateSpec, AVPSpec, TkAttr, RetAttr) -> ok +%% +%% Tier-1 write-path primitive. Must run inside an active mnesia transaction; +%% never opens its own. Validates endpoints, resolves source/target class and +%% template scope, then writes the two directed connection rows -- all with +%% bare mnesia ops, signalling any domain failure via mnesia:abort/1. The +%% rel-id pair must be allocated by the caller (get_id_pair is a gen_server +%% call and must never run inside an mnesia fun). Composes into a caller's +%% single transaction (the write-path seam's tier-1 contract); used by both +%% do_add_relationship/7 (tier-2) and graphdb_mgr:mutate/1 (tier-3). +%% Phase order: validate endpoints -> resolve classes -> resolve template -> +%% validate scope -> write. +%%----------------------------------------------------------------------------- +add_relationship_in_txn({_Id1, _Id2} = IdPair, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) -> + ok = validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref, + ReciprocalNref, TkAttr, RetAttr), + {SourceClass, TargetClass} = + resolve_arc_classes_in_txn(SourceNref, TargetNref), + TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass), + ok = graphdb_class:validate_template_scope_in_txn(TemplateNref, + SourceClass, TargetClass), + Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec), + lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, + Rows). +``` + +- [ ] **Step 3: Compile** + +Run: `./rebar3 compile` +Expected: compiles with **zero warnings** (no unused-function or +unbound-variable warnings). + +- [ ] **Step 4: Prove behaviour is preserved — run the add_relationship tests** + +The extraction is byte-identical, so the existing tests must pass unchanged. + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE` +Expected: PASS (all cases green — these exercise every +`add_relationship/4,5,6` branch through `do_add_relationship/7`). + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE --group write_delegation` +Expected: PASS (includes `add_relationship_delegates`). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "Extract add_relationship_in_txn/9 tier-1 primitive" +``` + +--- + +### Task 2: Implement `graphdb_mgr:mutate/1` + test group + +Add the tier-3 batch entry point and its three-phase helpers to +`graphdb_mgr`, export it, and add an 8-case CT group to `graphdb_mgr_SUITE`. + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` — export list at + `apps/graphdb/src/graphdb_mgr.erl:107-127`; add the new functions after + `transaction/1` (`apps/graphdb/src/graphdb_mgr.erl:296`). `?NREF_START` is + already in scope via the include at + `apps/graphdb/src/graphdb_mgr.erl:51`; `set_retired_/3` already exists at + `apps/graphdb/src/graphdb_mgr.erl:575`. +- Modify/Test: `apps/graphdb/test/graphdb_mgr_SUITE.erl` — `all/0` at + `apps/graphdb/test/graphdb_mgr_SUITE.erl:115-119`; `groups/0` at + `:121-174`; the test-case `-export` block at `:59-105`; + `init_per_testcase/2` worker-startup guard at `:217-248`; new test bodies + appended to the file. + +**Interfaces:** +- Consumes: `graphdb_instance:add_relationship_in_txn/9` (Task 1); + `graphdb_mgr:transaction/1`; module-local `set_retired_/3`; + `graphdb_attr:seeded_nrefs/0` returning + `{ok, #{target_kind := integer(), retired := integer(), ...}}`; + `rel_id_server:get_id_pair/0` returning `{integer(), integer()}`. +- Produces: exported `graphdb_mgr:mutate([mutation()]) -> {ok, [term()]} | + {error, term()}`. + +- [ ] **Step 1: Add the test-case names to the suite `-export` block** + +In `apps/graphdb/test/graphdb_mgr_SUITE.erl`, inside the test-case +`-export([...])` block (`:59-105`), add (place after +`get_node_hides_retired/1`, keeping the trailing entry comma-correct): + +```erlang + get_node_hides_retired/1, + %% Batch mutate + mutate_empty_batch/1, + mutate_single_add_relationship/1, + mutate_single_retire_and_unretire/1, + mutate_mixed_all_succeed/1, + mutate_atomic_rollback/1, + mutate_read_your_writes_rollback/1, + mutate_malformed_term/1, + mutate_permanent_tier_guard/1 +``` + +- [ ] **Step 2: Register the `mutate` group in `all/0` and `groups/0`** + +In `all/0` (`:115-119`), append `{group, mutate}`: + +```erlang +all() -> + [{group, init_tests}, {group, read_ops}, + {group, category_guard}, {group, write_delegation}, + {group, cache_audit}, {group, transaction_seam}, + {group, soft_retire}, {group, mutate}]. +``` + +In `groups/0` (`:121-174`), add the new group after the `soft_retire` +group (add a comma after the `soft_retire` group's closing `}`): + +```erlang + {soft_retire, [], [ + retire_node_sets_and_clears_marker, + retire_node_is_idempotent, + retire_node_refuses_permanent_tier, + retire_node_not_found, + get_node_hides_retired + ]}, + {mutate, [], [ + mutate_empty_batch, + mutate_single_add_relationship, + mutate_single_retire_and_unretire, + mutate_mixed_all_succeed, + mutate_atomic_rollback, + mutate_read_your_writes_rollback, + mutate_malformed_term, + mutate_permanent_tier_guard + ]} +``` + +- [ ] **Step 3: Give the `mutate` cases the full-worker environment** + +These tests need the workers started and the runtime-tier flip (same as the +`write_delegation` / `soft_retire` groups). In `init_per_testcase/2`, extend +the guard clause that starts the full worker set (`:217-231`) by adding the +eight case names to the `when` list: + +```erlang + TC =:= retire_node_not_found; + TC =:= get_node_hides_retired; + TC =:= mutate_empty_batch; + TC =:= mutate_single_add_relationship; + TC =:= mutate_single_retire_and_unretire; + TC =:= mutate_mixed_all_succeed; + TC =:= mutate_atomic_rollback; + TC =:= mutate_read_your_writes_rollback; + TC =:= mutate_malformed_term; + TC =:= mutate_permanent_tier_guard -> +``` + +- [ ] **Step 4: Write the failing tests** + +Append the following section to the end of +`apps/graphdb/test/graphdb_mgr_SUITE.erl`, **before** the +`%% Internal Helpers` section (`:694`). (Indent with hard tabs to match the +file.) + +```erlang +%%============================================================================= +%% Batch mutate Tests +%% +%% mutate/1 applies an ordered list of mutations atomically in one +%% transaction. Workers are pre-started in init_per_testcase for this group. +%%============================================================================= + +%%----------------------------------------------------------------------------- +%% Empty batch is a no-op: {ok, []}, no transaction opened. +%%----------------------------------------------------------------------------- +mutate_empty_batch(_Config) -> + ?assertEqual({ok, []}, graphdb_mgr:mutate([])). + +%%----------------------------------------------------------------------------- +%% A single add_relationship returns {ok, [ok]} and writes the arc. +%%----------------------------------------------------------------------------- +mutate_single_add_relationship(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MClassAR", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MKnows", "MKnownBy", + instance), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate( + [{add_relationship, InstA, CharNref, InstB, RecipNref}])), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Targets = [R#relationship.target_nref || R <- Rels, + R#relationship.characterization =:= CharNref], + ?assertEqual([InstB], Targets). + +%%----------------------------------------------------------------------------- +%% A single retire_node sets the marker; a single unretire_node clears it. +%%----------------------------------------------------------------------------- +mutate_single_retire_and_unretire(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MRetire", 3), + ?assert(ClassNref >= ?NREF_START), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate([{retire_node, ClassNref}])), + [#node{attribute_value_pairs = AVPs1}] = + mnesia:dirty_read(nodes, ClassNref), + ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs1)), + ?assertEqual({ok, [ok]}, + graphdb_mgr:mutate([{unretire_node, ClassNref}])), + [#node{attribute_value_pairs = AVPs2}] = + mnesia:dirty_read(nodes, ClassNref), + ?assertEqual(false, + lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs2)). + +%%----------------------------------------------------------------------------- +%% A mixed batch (two add_relationship + one retire) all succeeds: every +%% effect is present after commit. +%%----------------------------------------------------------------------------- +mutate_mixed_all_succeed(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MMixed", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MMA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MMB", ClassNref, 5), + {ok, InstC, _} = graphdb_instance:create_instance("MMC", ClassNref, 5), + {ok, {Ch1, Re1}} = + graphdb_attr:create_relationship_attribute_pair("MM1", "MM1r", instance), + {ok, {Ch2, Re2}} = + graphdb_attr:create_relationship_attribute_pair("MM2", "MM2r", instance), + Batch = [{add_relationship, InstA, Ch1, InstB, Re1}, + {add_relationship, InstA, Ch2, InstC, Re2}, + {retire_node, InstB}], + ?assertEqual({ok, [ok, ok, ok]}, graphdb_mgr:mutate(Batch)), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Chars = lists:sort([R#relationship.characterization || R <- Rels, + R#relationship.characterization =:= Ch1 orelse + R#relationship.characterization =:= Ch2]), + ?assertEqual(lists:sort([Ch1, Ch2]), Chars), + [#node{attribute_value_pairs = BAVPs}] = mnesia:dirty_read(nodes, InstB), + ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, BAVPs)). + +%%----------------------------------------------------------------------------- +%% Atomic rollback: a valid add_relationship followed by a retire of a +%% nonexistent node aborts with {error, not_found}, and the relationship the +%% first mutation wrote is absent (the whole batch rolled back). +%%----------------------------------------------------------------------------- +mutate_atomic_rollback(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MRollback", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MRA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MRB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MRKnows", "MRKnownBy", + instance), + BadNref = ?NREF_START + 999999, + Batch = [{add_relationship, InstA, CharNref, InstB, RecipNref}, + {retire_node, BadNref}], + ?assertEqual({error, not_found}, graphdb_mgr:mutate(Batch)), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Targets = [R#relationship.target_nref || R <- Rels, + R#relationship.characterization =:= CharNref], + ?assertEqual([], Targets). + +%%----------------------------------------------------------------------------- +%% Read-your-writes rollback: retire X, then relate from X in the same batch. +%% The relationship's endpoint validation sees X's uncommitted retired marker +%% and aborts {endpoint_retired, X}; both mutations roll back, so X is NOT +%% retired afterward. +%%----------------------------------------------------------------------------- +mutate_read_your_writes_rollback(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MRYW", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MRYWA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MRYWB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MRYWK", "MRYWKr", + instance), + Batch = [{retire_node, InstA}, + {add_relationship, InstA, CharNref, InstB, RecipNref}], + ?assertEqual({error, {endpoint_retired, InstA}}, + graphdb_mgr:mutate(Batch)), + [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, InstA), + ?assertEqual(false, + lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs)). + +%%----------------------------------------------------------------------------- +%% A malformed mutation term is rejected in phase 1 with +%% {error, {bad_mutation, M}}; the well-formed mutation preceding it in the +%% batch writes nothing (phase 1 rejects the whole batch before phase 2/3). +%%----------------------------------------------------------------------------- +mutate_malformed_term(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("MBad", 3), + {ok, InstA, _} = graphdb_instance:create_instance("MBadA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance("MBadB", ClassNref, 5), + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("MBadK", "MBadKr", + instance), + Bad = {frobnicate, 1, 2}, + Batch = [{add_relationship, InstA, CharNref, InstB, RecipNref}, Bad], + ?assertEqual({error, {bad_mutation, Bad}}, graphdb_mgr:mutate(Batch)), + {ok, Rels} = graphdb_mgr:get_relationships(InstA), + Targets = [R#relationship.target_nref || R <- Rels, + R#relationship.characterization =:= CharNref], + ?assertEqual([], Targets). + +%%----------------------------------------------------------------------------- +%% The permanent-tier guard rejects retire/unretire of a node below +%% ?NREF_START with {error, permanent_node_immutable}, before any write. +%% Asserts the bootstrap node carries no retired marker afterward +%% (attribute-specific check -- node 27 may carry other AVPs). +%%----------------------------------------------------------------------------- +mutate_permanent_tier_guard(_Config) -> + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:mutate([{retire_node, 27}])), + {ok, #{retired := RetAttr}} = graphdb_attr:seeded_nrefs(), + [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, 27), + ?assertEqual(false, lists:any( + fun(#{attribute := A, value := true}) when A =:= RetAttr -> true; + (_) -> false end, AVPs)). +``` + +- [ ] **Step 5: Run the new group to verify it fails** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE --group mutate` +Expected: FAIL — `mutate_empty_batch` (and the rest) fail because +`graphdb_mgr:mutate/1` is undefined (`undef` / `function mutate/1 +undefined`). + +- [ ] **Step 6: Add `mutate/1` to the `graphdb_mgr` export list** + +In the public `-export([...])` block (`apps/graphdb/src/graphdb_mgr.erl:107-127`), +add `mutate/1` to the write-operations group (after `update_node_avps/2`): + +```erlang + update_node_avps/2, + %% Batch write (tier-3 entry point) + mutate/1, + %% Transaction helper (write-path seam) + transaction/1, +``` + +- [ ] **Step 7: Implement `mutate/1` and its phase helpers** + +Insert the following immediately after the `transaction/1` definition +(after `apps/graphdb/src/graphdb_mgr.erl:296`): + +```erlang +%%----------------------------------------------------------------------------- +%% mutate([Mutation]) -> {ok, [Result]} | {error, Reason} +%% +%% Tier-3 batch write entry point: applies an ordered list of mutations +%% ATOMICALLY in one graphdb_mgr:transaction/1, composing the write-path +%% seam's tier-1 primitives directly. All commit or none do. +%% +%% Mutation grammar (tagged tuples mirroring the public arities): +%% {add_relationship, S, C, T, R} default template, no AVPs +%% {add_relationship, S, C, T, R, Template} explicit template nref +%% {add_relationship, S, C, T, R, Template, {Fwd, Rev}} + per-direction AVPs +%% {retire_node, Nref} +%% {unretire_node, Nref} +%% +%% Returns {ok, [Result]} -- one native success value per mutation in list +%% order (every op returns `ok` today, so {ok, [ok, ok, ...]}) -- or the bare +%% {error, Reason} of the first aborting mutation with the whole batch rolled +%% back. mutate([]) -> {ok, []} (no transaction opened). +%% +%% Three phases: (1) static validation -- tuple shape + the permanent-tier +%% guard, no DB, no allocation; (2) a resource pre-pass OUTSIDE the +%% transaction -- resolve the seeded attr nrefs once and allocate one rel-id +%% pair per add_relationship (gen_server calls); (3) one transaction folding +%% the prepared list in order, dispatching each to a tier-1 in-txn primitive. +%% +%% Plain function, not a gen_server:call -- mnesia:transaction/1 runs in the +%% calling process and phase 2 calls OTHER gen_servers, so routing mutate +%% through graphdb_mgr would needlessly serialise batches. +%% See docs/designs/batch-mutate-design.md. +%%----------------------------------------------------------------------------- +-spec mutate([tuple()]) -> {ok, [term()]} | {error, term()}. +mutate(Mutations) -> + case validate_mutations(Mutations) of + ok -> run_mutations(Mutations); + {error, _} = Err -> Err + end. + +%% Phase 1: static validation. No DB access, no allocation. A malformed term +%% -> {error, {bad_mutation, M}}; a permanent-tier retire/unretire -> +%% {error, permanent_node_immutable} (the same static guard set_retired/3 +%% applies in the solo path). +validate_mutations([]) -> + ok; +validate_mutations([M | Rest]) -> + case validate_mutation(M) of + ok -> validate_mutations(Rest); + {error, _} = Err -> Err + end. + +validate_mutation({add_relationship, _S, _C, _T, _R}) -> + ok; +validate_mutation({add_relationship, _S, _C, _T, _R, _Template}) -> + ok; +validate_mutation({add_relationship, _S, _C, _T, _R, _Template, {_Fwd, _Rev}}) -> + ok; +validate_mutation({retire_node, Nref}) when is_integer(Nref) -> + tier_guard(Nref); +validate_mutation({unretire_node, Nref}) when is_integer(Nref) -> + tier_guard(Nref); +validate_mutation(M) -> + {error, {bad_mutation, M}}. + +tier_guard(Nref) when Nref >= ?NREF_START -> ok; +tier_guard(_Nref) -> {error, permanent_node_immutable}. + +%% Phases 2 + 3. Precondition: Mutations already passed validate_mutations/1. +%% Empty batch short-circuits with no transaction. +run_mutations([]) -> + {ok, []}; +run_mutations(Mutations) -> + %% Phase 2 (outside the transaction): resolve the seeded attr nrefs once, + %% and allocate one rel-id pair per add_relationship. + {ok, #{target_kind := TkAttr, retired := RetAttr}} = + graphdb_attr:seeded_nrefs(), + Prepared = [prepare(M) || M <- Mutations], + %% Phase 3: one transaction folding the prepared list in order. + graphdb_mgr:transaction(fun() -> + [dispatch(P, TkAttr, RetAttr) || P <- Prepared] + end). + +%% Phase 2 per-mutation prep. Allocates one rel-id pair per add_relationship +%% via rel_id_server (a gen_server call -- MUST stay outside the transaction) +%% and normalises each add_relationship to the explicit +%% (TemplateSpec, AVPSpec) form. retire/unretire need no resources. +%% Prepared add_relationship shape: +%% {add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec} +prepare({add_relationship, S, C, T, R}) -> + {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, + default, {[], []}}; +prepare({add_relationship, S, C, T, R, Template}) -> + {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, + Template, {[], []}}; +prepare({add_relationship, S, C, T, R, Template, AVPSpec}) -> + {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, + Template, AVPSpec}; +prepare({retire_node, _Nref} = M) -> + M; +prepare({unretire_node, _Nref} = M) -> + M. + +%% Phase 3 dispatch. Runs INSIDE the transaction: no gen_server calls, no +%% transaction/1, no rel-id allocation here (all done in phase 2). Each +%% tier-1 primitive returns ok or calls mnesia:abort/1. +dispatch({add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec}, + TkAttr, RetAttr) -> + graphdb_instance:add_relationship_in_txn(IdPair, S, C, T, R, TemplateSpec, + AVPSpec, TkAttr, RetAttr); +dispatch({retire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Nref, true, RetAttr); +dispatch({unretire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Nref, false, RetAttr). +``` + +- [ ] **Step 8: Compile** + +Run: `./rebar3 compile` +Expected: compiles with **zero warnings**. + +- [ ] **Step 9: Run the new group to verify it passes** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE --group mutate` +Expected: PASS — all 8 cases green. + +- [ ] **Step 10: Run the whole `graphdb_mgr` suite (no regressions)** + +Run: `./rebar3 ct --suite apps/graphdb/test/graphdb_mgr_SUITE` +Expected: PASS — every group green (init_tests, read_ops, category_guard, +write_delegation, cache_audit, transaction_seam, soft_retire, mutate). + +- [ ] **Step 11: Commit** + +```bash +git add apps/graphdb/src/graphdb_mgr.erl apps/graphdb/test/graphdb_mgr_SUITE.erl +git commit -m "Add graphdb_mgr:mutate/1 tier-3 batch entry point" +``` + +--- + +### Task 3: Documentation + +Record the new tier-3 entry point in the worker guide, the architecture +doc, and the task tracker. No code changes. + +**Files:** +- Modify: `apps/graphdb/CLAUDE.md` — `graphdb_mgr` worker section and the + `graphdb_instance` worker section. +- Modify: `docs/Architecture.md` — the write-path / transaction-seam area. +- Modify: `TASKS.md` — the "Batch `mutate([Mutation])`" bullet at + `TASKS.md:149`. + +**Interfaces:** +- Consumes: nothing (docs only). +- Produces: nothing. + +- [ ] **Step 1: Flip the TASKS.md tracker entry to IMPLEMENTED** + +In `TASKS.md`, replace the single-line bullet at `TASKS.md:149`: + +```markdown +- **Batch `mutate([Mutation])`** — the tier-3 entry point. +``` + +with: + +```markdown +- **Batch `mutate([Mutation])`** — IMPLEMENTED. Tier-3 batch entry point + `graphdb_mgr:mutate/1`: applies an ordered list of `add_relationship` / + `retire_node` / `unretire_node` mutations atomically in one + `graphdb_mgr:transaction/1`, composing tier-1 primitives directly. Opaque + bare-reason contract (`{ok, [ok, ...]}` | `{error, Reason}`, whole-batch + rollback, `mutate([]) -> {ok, []}`). Phase 2 resolves the seeded attr + nrefs once and allocates one rel-id pair per `add_relationship` outside + the transaction; phase 3 folds the prepared list in order. Required one + behaviour-preserving extraction — + `graphdb_instance:add_relationship_in_txn/9`. Design + `docs/designs/batch-mutate-design.md`; plan + `docs/superpowers/plans/2026-06-24-batch-mutate.md`. +``` + +- [ ] **Step 2: Add the `mutate/1` blurb to the `graphdb_mgr` worker section** + +In `apps/graphdb/CLAUDE.md`, in the `### graphdb_mgr — Primary Coordinator` +section, add a bullet to its API list: + +```markdown +- `mutate/1` — tier-3 batch entry point. Applies an ordered list of + `add_relationship` / `retire_node` / `unretire_node` mutations atomically + in one `transaction/1` (all commit or none). Tagged-tuple grammar; opaque + bare-reason contract `{ok, [ok, ...]}` | `{error, Reason}` with whole-batch + rollback; `mutate([]) -> {ok, []}`. A **plain function**, not a + `gen_server:call` — it owns the transaction in the caller's process. See + `docs/designs/batch-mutate-design.md`. +``` + +- [ ] **Step 3: Add `add_relationship_in_txn/9` to the `graphdb_instance` tier-1 list** + +In `apps/graphdb/CLAUDE.md`, in the `### graphdb_instance` section, append a +sentence to the `add_relationship/4,5,6` bullet (or add a dedicated bullet) +noting the extracted primitive: + +```markdown +- `add_relationship_in_txn/9` (IdPair, S, C, T, R, TemplateSpec, AVPSpec, + TkAttr, RetAttr) — tier-1 **in-transaction** primitive (bare-mnesia twin + of `add_relationship`'s transaction body; aborts on failure, never opens + its own txn). The caller allocates the rel-id pair up-front. + `do_add_relationship/7` (tier-2) and `graphdb_mgr:mutate/1` (tier-3) both + compose it into their single transaction. +``` + +- [ ] **Step 4: Note the tier-3 entry on the write path in Architecture.md** + +In `docs/Architecture.md`, in the section describing the write-path +transaction seam / tiers, add one sentence noting the tier-3 batch entry +point now exists: + +```markdown +The tier-3 batch entry point `graphdb_mgr:mutate/1` applies an ordered list +of `add_relationship` / `retire_node` / `unretire_node` mutations atomically +in one transaction, composing the tier-1 primitives directly. +``` + +(Place it adjacent to the existing transaction-seam / `transaction/1` +description. If no such section exists, add the sentence where the +`graphdb_mgr` write API is described — keep it at architectural altitude, +one sentence.) + +- [ ] **Step 5: Verify the docs render and commit** + +Confirm the three files read cleanly (tables aligned, fenced code blocks +tagged). No build step. + +```bash +git add apps/graphdb/CLAUDE.md docs/Architecture.md TASKS.md +git commit -m "Docs: batch mutate/1 tier-3 entry point" +``` + +--- + +## Self-Review + +**Spec coverage** (against `docs/designs/batch-mutate-design.md`): + +- §1 scope (3 ops; creates/delete/update deferred) — Task 2 grammar + Task 1 + primitive cover exactly `add_relationship`, `retire_node`, `unretire_node`. ✓ +- §2 tagged-tuple grammar (5 shapes) — `validate_mutation/1` + `prepare/1` + clauses, Task 2 Step 7. ✓ +- §3.1 opaque bare-reason contract; `mutate([]) -> {ok, []}` — `mutate/1` + + `run_mutations/1`; tests 1, 5, 6, 8. ✓ +- §3.3 no indexed error — the contract is bare `{error, Reason}`; test 5/6/7 + assert bare reasons. ✓ +- §3.4 read-your-writes rollback (`{endpoint_retired, X}`) — test 6. ✓ +- §4 extract `add_relationship_in_txn/9`, `do_add_relationship/7` delegates, + behaviour-preserving — Task 1 (proof = existing suites Step 4). ✓ +- §5 three phases; plain function; allocation/seeds outside txn, primitives + inside — Task 2 Step 7 (`run_mutations`/`prepare`/`dispatch`). ✓ +- §7 eight test cases + behaviour preservation — Task 2 Step 4 (8 cases), + Task 1 Step 4 (preservation). ✓ +- §8 files touched — Task 1 (graphdb_instance), Task 2 (graphdb_mgr + + SUITE), Task 3 (CLAUDE.md, Architecture.md, TASKS.md). ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; every +run step gives the exact command and expected result. ✓ + +**Type consistency:** `add_relationship_in_txn/9` signature is identical in +Task 1 (definition), Task 2 Step 7 (`dispatch/3` call site), and Task 3 +(doc). Prepared add_relationship tuple +`{add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec}` is produced +by `prepare/1` and matched by `dispatch/3` — same 8-element shape. `mutate/1` +return `{ok, [term()]} | {error, term()}` matches the tests' assertions. ✓