From 12f2f1a6cc03eb91b4bd5c6754f17f19161774bb Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sat, 8 Aug 2026 21:24:40 -0400 Subject: [PATCH 1/9] Design: query-traversal Home routing for arc-discovered nrefs Specs the fix for the SP2 defect filed in PR #53: graphdb_query's BFS re-guesses which store each frontier node lives in, so a project with >=6 instances shadows bootstrap nref 6 and an environment-only path returns {ok, no_path}. Two halves. (A) arc-discovered nrefs route through a new pure graphdb_ns:arc_target_namespace(Home, Kind, Char) keyed on #relationship.kind, with the 29/30 membership pair distinguished by characterization. (B) the BFS frontier, visited set, and target comparison become Home-qualified -- without B, project-6 and environment-6 collapse into one visited entry and the bare-nref target test reports a false found. Corrects the fix direction recorded in the PR #53 comment and in TASKS.md, which said to route via graphdb_ns:target_namespace/2 on the arc's target_kind. Bootstrap arc labels 21-30 carry no target_kind AVP, and arcs 23/24 are the decisive hops in the repro, so that lookup fails exactly where routing matters most. Design only -- no code changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- TASKS.md | 24 +- .../query-traversal-home-routing-design.md | 316 ++++++++++++++++++ 2 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 docs/designs/query-traversal-home-routing-design.md diff --git a/TASKS.md b/TASKS.md index ef2370b..e9ef68e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -552,12 +552,24 @@ shadows nref 6 and any with ≥35 shadows the whole bootstrap scaffold, so this is high-likelihood, not theoretical. The result is silently wrong: no error, only a collision warning in the log. -Fix needs a design decision, not a local patch: arc-discovered nrefs *do* -have characterization context, so they should route via -`graphdb_ns:target_namespace/2` on the arc's `target_kind` rather than -guessing — with the 29/30 class↔instance membership pair as an explicit -exception, since its source and target deliberately live in different -Homes. Deferred out of SP2 as a scoped design task. +**Designed — see `docs/designs/query-traversal-home-routing-design.md`.** +Arc-discovered nrefs *do* have context, so the guess is replaced by a +deterministic lookup. Two halves: (A) a new pure +`graphdb_ns:arc_target_namespace(Home, Kind, Char)` routes on +`#relationship.kind`, with the 29/30 membership pair distinguished by +characterization; (B) the BFS frontier, visited set, and target comparison +become Home-qualified — without B, project-6 and environment-6 collapse into +one visited entry and a bare-nref target test reports a *false* `found`. +`resolve_home/2` and `session_read_arcs/4` are untouched; only +`bfs_step/5` changes caller, to the existing `session_read_arcs_home/5`. + +> **Superseded fix direction.** This entry previously said to route via +> `graphdb_ns:target_namespace/2` on the arc's `target_kind`. That does not +> work: bootstrap arc labels 21–30 carry **no `target_kind` AVP** +> (`bootstrap.terms:122-131` creates them with `[]`; `graphdb_attr:init/1` +> retro-stamps only `attribute_type`), and arcs 23/24 are the decisive hops +> in the repro above. Only runtime-created pairs carry `target_kind`. See +> the design doc's correction note. **Open defect (Important, pre-existing, unrelated to SP2) — `rel_id_server:seed_from_mnesia/0` calls a nonexistent function.** diff --git a/docs/designs/query-traversal-home-routing-design.md b/docs/designs/query-traversal-home-routing-design.md new file mode 100644 index 0000000..7b8d03d --- /dev/null +++ b/docs/designs/query-traversal-home-routing-design.md @@ -0,0 +1,316 @@ + + +# Query Traversal — Home Routing for Arc-Discovered Nrefs — Design + +## Goal + +Close the SP2 defect in which `graphdb_query`'s bounded BFS silently returns +the wrong answer under a project-bound session. `#q_find_path{}` returns +`{ok, no_path}` for a path that exists, because mid-traversal it re-guesses +which store each frontier node lives in and crosses into the project's tables +by accident. + +Filed in `TASKS.md` → *Multi-project sessions* and analysed at length in +[PR #53 comment 5212210271](https://github.com/davidwt-com/SeerStoneGraphDb/pull/53#issuecomment-5212210271). + +## The defect + +`resolve_home/2` (`graphdb_query.erl:357`) resolves a bare nref by trying the +session's bound project first and falling back to the environment. That is +correct and deliberate for **entry points** — `#q_get_node{}`, `#q_get_arcs{}`, +`#q_describe{}`, and `#q_find_path{}`'s two endpoints — where the query +language genuinely has no characterization context to route on. + +`session_read_arcs/4` (`:416`) and `is_scaffold_node/2` (`:943`) apply that +same guess to **arc-discovered** nrefs, re-deciding Home for every node BFS +reaches. + +Reproduced — two runtime-tier environment attributes whose only route runs +through bootstrap nref 6 (`Names`): + +``` +A1 = 1000003, A2 = 1000004 (neither is shadowed) +#q_find_path{from = A1, to = A2, max_depth = 4, arc_kinds = [taxonomy]} + +environment session => {ok, [A1 -23-> 6, 6 -24-> A2]} +project, 1 instance => {ok, [A1 -23-> 6, 6 -24-> A2]} +project, 21 instances => {ok, no_path} <-- silently wrong +``` + +Step by step under the 21-instance project: BFS reaches `6` legitimately (the +environment arc `A1 -23-> 6` is read from the environment's `relationships`). +Expanding `6`, `session_read_arcs/4` calls `resolve_home/2`, which finds a +*project instance* also numbered 6 and returns the project handle. The walk +then reads `relationships_` for source 6, finds no `taxonomy` arcs +there, and the frontier empties. + +Because project allocators start at 1, **any project with ≥6 instances shadows +nref 6** and ≥35 shadows the whole bootstrap scaffold. This is the steady state +of a real project, not a contrived setup. No error is raised; the only signal +is a collision warning in the log. + +> **Correction to the fix direction recorded in the PR comment and in +> `TASKS.md`.** Both say arc-discovered nrefs should route via +> `graphdb_ns:target_namespace/2` on the arc's `target_kind`. That does not +> work. Bootstrap arc labels 21–30 carry **no `target_kind` AVP** — +> `bootstrap.terms:122-131` creates all ten with `[]`, and `graphdb_attr:init/1` +> retro-stamps only `attribute_type`. `graphdb_instance:check_target_kind/3` +> already has an explicit "no target_kind AVP — legacy; skip the check" arm for +> exactly this. Only runtime-created pairs +> (`graphdb_attr:create_relationship_attribute_pair/4`) carry it. Arcs 23/24 are +> the attribute-taxonomy labels — the decisive hops in the repro — so a +> `target_kind` lookup returns `not_found` precisely where routing matters most. +> This design routes on `#relationship.kind` instead. `TASKS.md` is corrected in +> the same commit as this document. + +### A second failure the PR comment missed + +Fixing the store lookup alone is not sufficient. BFS's traversal state is keyed +by bare nref: + +- `Visited` is `#{integer() => true}` (`:913`, `:918`), so project-6 and + environment-6 collapse into one entry and visiting either suppresses the + other. +- The target test is `case T of To` (`:910`), comparing bare integers. With + `to = 6` under a shadowing project, a walk that reaches *environment*-6 + reports `{found, Path}` for a path that claims to have reached the *project + instance* — a fabricated result, arguably worse than the filed `no_path`. + +Both are addressed here as Half B. + +## Scope + +**In scope — the shadowing defect only:** + +- **Half A** — arc-discovered nrefs resolve Home deterministically from the arc + they arrived on, never by guessing. +- **Half B** — BFS frontier, visited set, and target comparison become + Home-qualified. + +**Out of scope, filed as follow-ups:** + +- **Membership traversal.** BFS at an environment-homed node reads only the + environment relationship table, so an environment class still cannot reach + its project instances across arc 30 (`?ARC_CLASS_TO_INST`), whose rows live + in the *project's* table despite an environment source. + `#q_instances_of{}` keeps its `session_read_arcs_home/5` special case. +- **`target_kind` backfill** onto arc labels 21–30, which would let + `graphdb_instance:check_target_kind/3` drop its permissive legacy arm. +- **`#q_get_arcs{}` / `#q_describe{}`** keep entry-point guessing, by design. + +## What does not change + +`resolve_home/2`, `session_read_arcs/4`, and `session_read_node/2` are +**untouched**. They serve genuine entry points, and their intent-following +behaviour is documented, deliberate, and covered by +`resolve_home_prefers_project_and_logs_on_collision`. Item 4 of the PR comment +("keep `resolve_home/2`'s contract unchanged") is satisfied by not modifying +them at all. + +Exactly one caller changes: `bfs_step/5:893` switches from +`session_read_arcs/4` to the existing `session_read_arcs_home/5` (`:443`), +passing a Home the traversal already knows. That function's 5-tuple cache key +`{arcs, Home, Nref, Dir, Kinds}` was built during the SP2 T14 follow-up +specifically so it cannot collide with `session_read_arcs/4`'s 4-tuple key; it +accepts this second caller unmodified. + +## Half A — deterministic Home from the arc + +New pure function in `graphdb_ns`, beside `namespace_of/2`: + +```erlang +%% arc_target_namespace(Home, ArcKind, Characterization) -> environment | Home +%% +%% Home is the store the arc row was READ from. Total over the four +%% #relationship.kind values; both fields are present on every row. +arc_target_namespace(_Home, taxonomy, _C) -> environment; +arc_target_namespace( Home, composition, _C) -> Home; +arc_target_namespace( Home, connection, _C) -> Home; +arc_target_namespace(_Home, instantiation, ?ARC_INST_TO_CLASS) -> environment; +arc_target_namespace( Home, instantiation, ?ARC_CLASS_TO_INST) -> Home; +arc_target_namespace( Home, Kind, Char) -> + logger:warning("graphdb_ns: unroutable arc kind ~p (char ~p) -- " + "defaulting to same store ~p", [Kind, Char, Home]), + Home. +``` + +Justification per clause: + +| `kind` | Target store | Why | +| --- | --- | --- | +| `taxonomy` | `environment` | Class and attribute refinement; projects hold only instances, so a taxonomy arc never targets a project node. | +| `composition` | `Home` | Home-relative: category and class composition are environment↔environment; instance composition is project↔project. | +| `connection` | `Home` | Connections are instance↔instance within one store. | +| `instantiation` + char 29 | `environment` | The instance→class direction; classes always live in the environment. | +| `instantiation` + char 30 | `Home` | The class→instance direction; the row lives in the project and targets a project instance. | + +This is the arc-row analogue of a rule `graphdb_ns` already states for the +`parents` cache: `namespace_of(_Home, taxonomy_parent) -> environment` and +`namespace_of(Home, compositional_parent) -> Home`. The 29/30 pair — the +"explicit exception" the PR comment anticipated as a modelling problem — turns +out to be two pattern-match clauses, because the *direction* of the membership +pair is exactly what the characterization encodes. + +`graphdb_ns` gains `graphdb_nrefs.hrl` for the two `?ARC_*` macros. It stays +pure: no module dependencies. + +The catch-all clause exists because `graphdb_query` is a singleton gen_server. +A `function_clause` on one malformed `kind` value would kill the query server +for every session; degrading to same-store (today's behaviour for that row) +with a logged warning is the same trade review wave C made for +`label_chain/1`. + +### `is_scaffold_node` needs no policy decision + +Item 3 of the PR comment asked what scaffold-ness should mean under a +project-bound session. With Home resolved deterministically the question +dissolves: `is_scaffold_node/2` becomes `is_scaffold_node(Home, Nref)`, reading +`graphdb_ns:node_table(Home)`. A project-homed node reads from the project's +table, finds `kind = instance`, and yields `false` — the right answer, reached +structurally rather than by special case. + +## Half B — Home-qualified traversal state + +One normalized notion of "which store", used for visited keys, continuation +state, and the public result alike: + +```erlang +-type home_id() :: environment | {project, integer()}. + +home_id(environment) -> environment; +home_id(#{anchor := Anchor}) -> {project, Anchor}. +``` + +`home_id/1` deliberately does **not** carry the full `Project` handle: that +handle holds physical table atoms (`nodes_`, `relationships_`, +`counters_`) which have no business inside continuation state or query +results. `home_of_id/2` maps a `home_id()` back to a real Home for +`graphdb_ns:node_table/1` / `rel_table/1`. + +Entry points resolve once; Home is then carried, never re-derived: + +```erlang +dispatch(#q_find_path{from = From, to = To, max_depth = D, arc_kinds = Kinds}, + Session) -> + FromId = home_id(resolve_home(Session, From)), %% entry point: guess is right + ToId = home_id(resolve_home(Session, To)), + bfs(maps:get(snapshot_at, Session), {ToId, To}, D, D, Kinds, + #{{FromId, From} => true}, %% visited :: #{{home_id(), nref()} => true} + [{FromId, From, []}], %% frontier :: [{home_id(), nref(), path()}] + Session); +``` + +`expand_arcs/8` becomes `/10`, gaining the source's `home_id()` and full Home: + +```erlang +TargetHome = graphdb_ns:arc_target_namespace(FromHome, K, C), +TargetId = home_id(TargetHome), +Key = {TargetId, T}, +... +case Key of + ToKey -> {Acc, V, {found, NewPath}, S}; + _ -> + case maps:is_key(Key, V) orelse is_scaffold_node(TargetHome, T) of +``` + +`bfs_step/5` correspondingly resolves `Home = home_of_id(S, HomeId)` and calls +`session_read_arcs_home(S, Home, Nref, outgoing, Kinds)`. + +Three defects die together: the arc table is the one the arc actually lives in; +`{project, A}`-6 and `environment`-6 are distinct visited entries; and reaching +project-6 when the target is environment-6 no longer reports a false `found`. + +### Continuation record + +`graphdb_query.hrl`'s `#cont_path{}` field types change: + +```erlang +target :: {home_id(), integer()}, %% was integer() +visited :: #{{home_id(), integer()} => true}, %% was #{integer() => true} +frontier :: [{home_id(), integer(), [map()]}] %% was [{integer(), [map()]}] +``` + +The record is opaque to callers — only `resume/2` reads it — so this is an +internal shape change, not an API break. + +## Result shape + +An edge gains a `home` key **iff** the target's Home differs from the Home the +arc row was read from: + +```erlang +Edge = case TargetId =:= FromId of + true -> Edge0; + false -> Edge0#{home => TargetId} + end, +``` + +The value is a `home_id()` — `environment` or `{project, Anchor}` — never the +raw handle. + +Because each edge's `from` is the previous edge's `to`, a consumer reconstructs +the full Home sequence by carrying the last disclosed value forward: an absent +`home` key means "same store as the previous hop", and the first edge's store is +the one the caller named in `from`. + +Under this scope the only crossing reachable is project→environment via arc 29 +(a project instance's outgoing membership row targets an environment class), so +`environment` is the only value emitted today. The `{project, _}` form is +reserved for when membership traversal lands. + +> This shape varies by content, which is harder to pattern-match than a key +> that is always present. The rule above is stated precisely so the variation +> stays predictable, and the alternative — emitting `home` on every edge — +> would have churned every existing `find_path` assertion for information that +> is constant on environment-only paths. + +## Error handling + +Two singleton-crash hazards, both handled the way review wave C handled +`label_chain/1` — degrade and log rather than `function_clause` inside a +gen_server that every session shares: + +1. **`arc_target_namespace/3`** — logged catch-all returning `Home` + (conservative same-store), shown above. +2. **`home_of_id/2`** — would `function_clause` on a continuation carrying + `{project, A}` for a project the session is not bound to. Rather than crash + mid-BFS, `resume/2` validates the continuation's Home ids against the + session up front and returns `{error, session_project_mismatch}`. + Unreachable today (a session's project cannot change, and `snapshot_at` + already guards refresh), but the singleton is exactly where "unreachable" + should not be load-bearing. + +## Testing + +Test-driven: every test written first and shown failing **for the right +reason** before the fix lands. + +| ID | Test | Asserts | +| --- | --- | --- | +| T1 | The filed invariant | One environment-only taxonomy path returns **identically** under an environment session, a 1-instance project session, and a ≥6-instance project session. Today the third returns `{ok, no_path}`. | +| T2 | False `found` | `from` = environment attribute, `to` = 6, project has ≥6 instances. Pre-fix the bare-nref target test reports a bogus path; post-fix `{environment, 6}` ≠ `{{project, A}, 6}` and `no_path` is correct. | +| T3 | Visited collision | A hop through environment-6 is not suppressed by having already visited project-6. | +| T4 | `arc_target_namespace/3` | EUnit in `graphdb_ns_tests.erl`: all four `kind` values, both instantiation directions, and the logged catch-all. | +| T5 | Result shape | Environment-only path carries no `home` key on any edge; a project→environment crossing via arc 29 carries `home => environment` on exactly that edge. `home_id/1` unit-tested against a project handle for the `{project, Anchor}` form. | +| T6 | Continuation round-trip | A depth-bounded partial in a project-bound session, resumed via `resume/2`, matches an unbounded run — proving the new frontier/visited shapes survive `#cont_path{}`. | +| T7 | Non-regression | `resolve_home_prefers_project_and_logs_on_collision` and the whole existing `q6_find_path` group stay green **unmodified** — the executable proof that entry-point behaviour is untouched. | + +T1–T3 and T5–T7 land in `graphdb_query_SUITE.erl` (space-indented — the +documented 0-tab exception; it must stay at 0 tabs). T4 lands in +`graphdb_ns_tests.erl`. + +**Caveat carried forward:** T2 and T3 are reasoned-constructible but unbuilt at +design time. If either proves unreachable under this scope, the implementation +plan must say so explicitly rather than substitute a weaker test. + +Verification gate: `./rebar3 compile` clean, `./rebar3 xref` clean, +`make test-ct-parallel` and `./rebar3 eunit` green, zero warnings. + +## Delivery + +Own branch off `develop`, own PR — deliberately not folded into PR #53, so a +change to BFS traversal semantics and a public result shape gets an isolated +diff and its own review rather than being lost inside a 23-file branch. From 9a336731c3518bf8f1a1b374398ea656a026ea6a Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 06:22:24 -0400 Subject: [PATCH 2/9] Plan: query-traversal Home routing implementation Five tasks, TDD throughout. T1 graphdb_ns:arc_target_namespace/3 + EUnit; T2 Home-qualified BFS (the headline invariant + the false-found case); T3 home key on cross-store edges; T4 resume/2 continuation-Home gate; T5 docs + verification gate. Records one deliberate deviation from the design: its T3 (visiting project-6 must not suppress environment-6) is not constructible under this scope, since reaching both keys in one walk needs a store crossing and the only one available lands on a class, from which the environment attribute subtree is unreachable by taxonomy. Replaced by T3', which asserts the same Half-B property observably via #cont_path{} state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- ...2026-08-09-query-traversal-home-routing.md | 1007 +++++++++++++++++ 1 file changed, 1007 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-09-query-traversal-home-routing.md diff --git a/docs/superpowers/plans/2026-08-09-query-traversal-home-routing.md b/docs/superpowers/plans/2026-08-09-query-traversal-home-routing.md new file mode 100644 index 0000000..b7bead4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-query-traversal-home-routing.md @@ -0,0 +1,1007 @@ +# Query Traversal — Home Routing for Arc-Discovered Nrefs — 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:** Stop `graphdb_query`'s bounded BFS from re-guessing which store each frontier node lives in, so an environment-only path stops silently returning `{ok, no_path}` under a project-bound session. + +**Architecture:** Two halves. **Half A** — a new pure `graphdb_ns:arc_target_namespace(Home, Kind, Char)` derives the target's store from the arc the traversal arrived on, keyed on `#relationship.kind` with the 29/30 membership pair distinguished by characterization. **Half B** — the BFS frontier, visited set, and target comparison become Home-qualified via a compact `home_id() :: environment | {project, Anchor}`. `resolve_home/2` is called only for the two `#q_find_path{}` endpoints and is otherwise untouched. + +**Tech Stack:** Erlang/OTP 28.5, Mnesia, rebar3 3.27 (repo-local `./rebar3`), Common Test + EUnit. + +**Design source:** `docs/designs/query-traversal-home-routing-design.md` + +## Global Constraints + +- **Branch:** `query-traversal-home-routing` (already created, off `develop` at `12f2f1a`). Do not merge, push, or open a PR — that is the user's call. +- **Indentation is per-file and non-negotiable:** + - `apps/graphdb/src/graphdb_ns.erl` — **HARD TABS** (currently 8 tab-bearing lines). + - `apps/graphdb/test/graphdb_ns_tests.erl` — **HARD TABS** (currently 18). + - `apps/graphdb/src/graphdb_query.erl` — **SPACES, 4-wide. Must stay at 0 tabs.** + - `apps/graphdb/test/graphdb_query_SUITE.erl` — **SPACES, 4-wide. Must stay at 0 tabs.** + - `apps/graphdb/include/graphdb_query.hrl` — **SPACES, 4-wide.** + - Verify after every edit with `grep -cP '\t' ` (note: `grep -c` exits 1 when the count is 0, so run it alone, never in an `&&` chain). +- **Header include style:** `-include_lib("graphdb/include/graphdb_nrefs.hrl").` — never a bare `-include`. +- **Never `git add` anything under `.wolf/` or `.superpowers/`.** +- **Erlang does not compile-check cross-module arity.** A clean `./rebar3 compile` proves nothing about cross-module calls. `./rebar3 xref` is the gate — run it after every task. +- **`graphdb_query` is a singleton gen_server.** A `function_clause` inside it kills the query server for every session. Every new function reachable from `dispatch/2` needs a total match or a caller-side gate. +- Commit after every task. Do not squash. + +--- + +## File Structure + +| File | Responsibility | Change | +| --- | --- | --- | +| `apps/graphdb/src/graphdb_ns.erl` | Pure namespace resolution | **Modify** — add `arc_target_namespace/3`, add the `graphdb_nrefs.hrl` include | +| `apps/graphdb/test/graphdb_ns_tests.erl` | EUnit for the pure module | **Modify** — add T4 | +| `apps/graphdb/include/graphdb_query.hrl` | Query AST + continuation records | **Modify** — add `home_id()` type, re-type three `#cont_path{}` fields | +| `apps/graphdb/src/graphdb_query.erl` | Query language gen_server | **Modify** — `home_id/1`, `home_of_id/2`, `validate_cont_homes/2`, BFS threading, `is_scaffold_node/2` signature, `make_edge/6` | +| `apps/graphdb/test/graphdb_query_SUITE.erl` | Query CT suite | **Modify** — new `sp2_traversal_home_routing` group (T1, T2, T3, T5, T6) | +| `TASKS.md` | Outstanding work tracker | **Modify** — flip the open defect to resolved | +| `docs/Architecture.md` | Architectural altitude reference | **Modify** — §6 routing table + `graphdb_query` row | +| `apps/graphdb/CLAUDE.md` | Per-app guide | **Modify** — `graphdb_ns` and `graphdb_query` sections | + +--- + +### Task 1: `graphdb_ns:arc_target_namespace/3` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_ns.erl` +- Test: `apps/graphdb/test/graphdb_ns_tests.erl` + +**Interfaces:** +- Consumes: nothing (pure, first task). +- Produces: `graphdb_ns:arc_target_namespace(Home, ArcKind, Characterization) -> environment | Home`, where `Home :: environment | #{anchor := integer(), nodes := atom(), rels := atom(), counters := atom()}`, `ArcKind :: taxonomy | composition | connection | instantiation`, `Characterization :: integer()`. Task 2 calls this from `expand_arcs/10`. + +**HARD TABS in both files.** + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/graphdb/test/graphdb_ns_tests.erl` (tabs): + +```erlang +%%--------------------------------------------------------------------- +%% arc_target_namespace/3 -- the arc-row analogue of namespace_of/2. +%% Home is the store the arc row was READ from. +%%--------------------------------------------------------------------- +arc_target_namespace_taxonomy_is_always_environment_test() -> + %% Taxonomy is class/attribute refinement; projects hold only + %% instances, so a taxonomy arc never targets a project node. + [ ?assertEqual(environment, + graphdb_ns:arc_target_namespace(Home, taxonomy, C)) + || Home <- [environment, ?PROJECT], + C <- [?ARC_ATTR_PARENT, ?ARC_ATTR_CHILD, 9999] ]. + +arc_target_namespace_composition_is_home_relative_test() -> + [ ?assertEqual(Home, + graphdb_ns:arc_target_namespace(Home, composition, C)) + || Home <- [environment, ?PROJECT], + C <- [?ARC_INST_PARENT, ?ARC_INST_CHILD, 9999] ]. + +arc_target_namespace_connection_is_home_relative_test() -> + [ ?assertEqual(Home, + graphdb_ns:arc_target_namespace(Home, connection, 9999)) + || Home <- [environment, ?PROJECT] ]. + +arc_target_namespace_membership_splits_on_characterization_test() -> + %% The 29/30 pair is the one arc shape whose two directions + %% deliberately live in different Homes: both rows sit in the + %% PROJECT's table, but 29 targets an environment class and 30 + %% targets a project instance. + ?assertEqual(environment, + graphdb_ns:arc_target_namespace(?PROJECT, instantiation, + ?ARC_INST_TO_CLASS)), + ?assertEqual(?PROJECT, + graphdb_ns:arc_target_namespace(?PROJECT, instantiation, + ?ARC_CLASS_TO_INST)). + +arc_target_namespace_unknown_kind_defaults_to_home_test() -> + %% Deliberately NOT a function_clause: this is reached from inside + %% the graphdb_query singleton, where a crash would take the query + %% server down for every session over one malformed row. + ?assertEqual(environment, + graphdb_ns:arc_target_namespace(environment, bogus_kind, 1)), + ?assertEqual(?PROJECT, + graphdb_ns:arc_target_namespace(?PROJECT, bogus_kind, 1)). + +arc_target_namespace_unknown_instantiation_char_defaults_to_home_test() -> + %% instantiation with a characterization outside the 29/30 pair is + %% not a shape this codebase writes; fall through to the catch-all + %% rather than crash. + ?assertEqual(?PROJECT, + graphdb_ns:arc_target_namespace(?PROJECT, instantiation, 9999)). +``` + +Add the include at the top of the file, immediately after the existing `-include_lib("eunit/include/eunit.hrl").`: + +```erlang +-include_lib("graphdb/include/graphdb_nrefs.hrl"). +``` + +- [ ] **Step 2: Confirm the arc macros exist before relying on them** + +Run: `grep -nE 'ARC_(ATTR|INST)_(PARENT|CHILD)|ARC_INST_TO_CLASS|ARC_CLASS_TO_INST' apps/graphdb/include/graphdb_nrefs.hrl` + +Expected: six defines — `?ARC_ATTR_PARENT` 23, `?ARC_ATTR_CHILD` 24, `?ARC_INST_PARENT` 27, `?ARC_INST_CHILD` 28, `?ARC_INST_TO_CLASS` 29, `?ARC_CLASS_TO_INST` 30. If any name differs, use the real name — do not invent one. + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `./rebar3 eunit --module=graphdb_ns_tests` +Expected: FAIL — `undefined function graphdb_ns:arc_target_namespace/3`. + +- [ ] **Step 4: Implement `arc_target_namespace/3`** + +In `apps/graphdb/src/graphdb_ns.erl` (tabs), add the include after the `-module(graphdb_ns).` line: + +```erlang +-include_lib("graphdb/include/graphdb_nrefs.hrl"). +``` + +Extend the export: + +```erlang +-export([namespace_of/2, target_namespace/2, arc_target_namespace/3, + node_table/1, rel_table/1]). +``` + +Append the function after `target_namespace/2`: + +```erlang +%%--------------------------------------------------------------------- +%% arc_target_namespace(Home, ArcKind, Characterization) +%% -> environment | Home +%% +%% The arc-row analogue of namespace_of/2, for a traversal that has an +%% arc in hand. `Home` is the store the arc row was READ from. +%% +%% Unlike a bare nref, an nref reached BY TRAVERSING AN ARC is not +%% ambiguous: #relationship.kind and .characterization are present on +%% every row and together determine the target's store outright. This +%% is what lets graphdb_query stop guessing mid-traversal. +%% +%% taxonomy -- class/attribute refinement; projects hold only +%% instances, so the target is always environment. +%% composition -- home-relative: category/class composition is +%% environment<->environment, instance composition is +%% project<->project. +%% connection -- instance<->instance inside one store. +%% instantiation -- the 29/30 membership pair. BOTH rows live in the +%% project's table, but 29 (instance->class) targets +%% an environment class while 30 (class->instance) +%% targets a project instance. This is the one arc +%% shape whose ends deliberately differ in Home, and +%% the characterization is exactly what says which +%% direction we are walking. +%% +%% Deliberately total. A caller of this module is the graphdb_query +%% singleton; a function_clause here would take the query server down +%% for every session over one malformed row, so an unrecognised shape +%% logs and degrades to same-store (which is what that row did before +%% this function existed). +%%--------------------------------------------------------------------- +arc_target_namespace(_Home, taxonomy, _Char) -> environment; +arc_target_namespace(Home, composition, _Char) -> Home; +arc_target_namespace(Home, connection, _Char) -> Home; +arc_target_namespace(_Home, instantiation, ?ARC_INST_TO_CLASS) -> environment; +arc_target_namespace(Home, instantiation, ?ARC_CLASS_TO_INST) -> Home; +arc_target_namespace(Home, Kind, Char) -> + logger:warning( + "graphdb_ns: unroutable arc kind ~p (characterization ~p) -- " + "defaulting to same store ~p", + [Kind, Char, Home]), + Home. +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `./rebar3 eunit --module=graphdb_ns_tests` +Expected: PASS, all tests. Two `logger:warning` lines printed by the catch-all tests are expected output, not failures. + +- [ ] **Step 6: Verify tabs and cross-module integrity** + +Run these three separately (not chained — `grep -c` exits 1 on a zero count): +```bash +grep -cP '\t' apps/graphdb/src/graphdb_ns.erl +grep -cP '\t' apps/graphdb/test/graphdb_ns_tests.erl +``` +Expected: both non-zero (tabs preserved). + +Run: `./rebar3 xref` +Expected: clean, no `undefined_function_calls`. + +- [ ] **Step 7: Commit** + +```bash +git add apps/graphdb/src/graphdb_ns.erl apps/graphdb/test/graphdb_ns_tests.erl +git commit -m "Half A: graphdb_ns:arc_target_namespace/3 routes arc-discovered nrefs" +``` + +--- + +### Task 2: Home-qualified BFS + +**Files:** +- Modify: `apps/graphdb/include/graphdb_query.hrl:59-67` (the `#cont_path{}` record) +- Modify: `apps/graphdb/src/graphdb_query.erl:321-330` (`dispatch(#q_find_path{}, _)`), `:860-923` (`bfs/8`, `bfs_step/5`, `expand_arcs/8`), `:925-948` (`is_scaffold_node/2`) +- Test: `apps/graphdb/test/graphdb_query_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_ns:arc_target_namespace/3` (Task 1); the **existing** `session_read_arcs_home(Session, Home, Nref, Dir, Kinds) -> {[#relationship{}], Session1}` at `graphdb_query.erl:443`. +- Produces: `home_id(Home) -> environment | {project, integer()}` and `home_of_id(Session, HomeId) -> environment | Project`, both private to `graphdb_query`. Task 3 calls `home_id/1`; Task 4 calls neither but mirrors `home_id/1`'s shape in `validate_cont_homes/2`. + +**SPACES in all three files. All must end at 0 tabs.** + +- [ ] **Step 1: Write the two failing tests** + +Add to `apps/graphdb/test/graphdb_query_SUITE.erl` (spaces, 4-wide). Place the function bodies after `resume_rejects_bad_session_project/1` and before the `proj()` helper block: + +```erlang +%%===================================================================== +%% SP2 follow-up — Home routing for arc-discovered nrefs +%% (docs/designs/query-traversal-home-routing-design.md) +%%===================================================================== + +%%--------------------------------------------------------------------- +%% T1 -- the invariant that broke. An environment-only path must be +%% returned IDENTICALLY under an environment session, under a project +%% that does not shadow the intermediate hop, and under a project that +%% does. Before the fix the third case returned {ok, no_path}. +%%--------------------------------------------------------------------- +t1_env_only_path_identical_across_sessions(_Config) -> + %% Both attributes are created under bootstrap nref 6 ("Names"), and + %% graphdb_attr writes the taxonomy pair 6 -24-> New / New -23-> 6. + %% So the only [taxonomy] route from A1 to A2 runs through 6. + {ok, A1} = graphdb_attr:create_name_attribute("T1Alpha"), + {ok, A2} = graphdb_attr:create_name_attribute("T1Beta"), + Q = #q_find_path{from = A1, to = A2, max_depth = 4, + arc_kinds = [taxonomy]}, + + {ok, EnvPath, _} = + graphdb_query:execute_query(Q, graphdb_query:new_session()), + + %% (b) a project holding a single node -- nref 6 is NOT shadowed. + {ok, SmallP} = graphdb_project:register_project("T1 small"), + {ok, Small} = graphdb_project:open(SmallP), + _ = root_instance(Small), + {ok, SmallPath, _} = + graphdb_query:execute_query(Q, graphdb_query:new_session(Small)), + + %% (c) a project holding >= 6 nodes -- nref 6 IS shadowed. Project + %% allocators start at 1, so this is the steady state of any real + %% project, not a contrived setup. + {ok, BigP} = graphdb_project:register_project("T1 shadowing"), + {ok, Big} = graphdb_project:open(BigP), + Seeded = [root_instance(Big) || _ <- lists:seq(1, 6)], + ?assert(lists:member(?NREF_NAMES, Seeded)), + {ok, BigPath, _} = + graphdb_query:execute_query(Q, graphdb_query:new_session(Big)), + + ?assertEqual(EnvPath, SmallPath), + ?assertEqual(EnvPath, BigPath), + ?assertMatch([#{from := A1, via := ?ARC_ATTR_PARENT, + to := ?NREF_NAMES, kind := taxonomy}, + #{from := ?NREF_NAMES, via := ?ARC_ATTR_CHILD, + to := A2, kind := taxonomy}], EnvPath), + %% An environment-only path crosses no store, so no edge discloses + %% a Home (see Task 3). + ?assertEqual([], [E || E <- EnvPath, maps:is_key(home, E)]). + +%%--------------------------------------------------------------------- +%% T2 -- false `found` via the bare-nref target comparison. With +%% to = 6 under a shadowing project, resolve_home/2 resolves the +%% ENDPOINT to the project's instance 6 (documented, intentional). A +%% walk through the environment's attribute 6 must therefore NOT count +%% as having found it. Pre-fix, `case T of To` compared 6 =:= 6 and +%% returned a one-edge path -- a fabricated result. +%% +%% max_depth = 2 makes the post-fix outcome deterministic: level 1 +%% expands A1 to {6}, level 2 expands 6's children, none of which is +%% the project's instance 6, and the budget runs out with a non-empty +%% frontier -> partial. +%%--------------------------------------------------------------------- +t2_shadowed_target_is_not_falsely_found(_Config) -> + {ok, A1} = graphdb_attr:create_name_attribute("T2Alpha"), + {ok, P} = graphdb_project:register_project("T2 shadowing"), + {ok, Project} = graphdb_project:open(P), + Seeded = [root_instance(Project) || _ <- lists:seq(1, 6)], + ?assert(lists:member(?NREF_NAMES, Seeded)), + Session = graphdb_query:new_session(Project), + Reply = graphdb_query:execute_query( + #q_find_path{from = A1, to = ?NREF_NAMES, max_depth = 2, + arc_kinds = [taxonomy]}, Session), + %% The invariant: no path is fabricated. + ?assertNotMatch({ok, [_ | _], _}, Reply), + ?assertMatch({partial, _Best, _Cont, _S}, Reply). +``` + +Register both in the export list (after `resume_rejects_bad_session_project/1`, keeping the trailing entry comma-correct): + +```erlang + resume_rejects_bad_session_project/1, + %% SP2 follow-up — Home routing for arc-discovered nrefs + t1_env_only_path_identical_across_sessions/1, + t2_shadowed_target_is_not_falsely_found/1 +]). +``` + +Add a new group in `groups/0`, after the `sp2_project_session` group (add a comma after that group's closing `]}`): + +```erlang + {sp2_traversal_home_routing, [], [ + t1_env_only_path_identical_across_sessions, + t2_shadowed_target_is_not_falsely_found + ]}]. +``` + +And add it to `all/0`: + +```erlang + {group, q6_find_path}, {group, sp2_project_session}, + {group, sp2_traversal_home_routing}]. +``` + +- [ ] **Step 2: Run the tests to verify they fail — and check WHY** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --group=sp2_traversal_home_routing` + +Expected: both FAIL. +- `t1_...` must fail on the **third** session with a badmatch against `{ok, no_path, _}` — proving the shadowing defect, not a fixture error. If it fails on the environment session instead, the fixture is wrong; fix the fixture before touching `graphdb_query.erl`. +- `t2_...` must fail at `?assertNotMatch({ok, [_ | _], _}, Reply)` — proving the fabricated path exists today. + +Record both failure messages; the reviewer will ask for them. + +- [ ] **Step 3: Re-type the continuation record** + +In `apps/graphdb/include/graphdb_query.hrl`, add the type above the `-record(cont_path, ...)` block and re-type three fields: + +```erlang +%% -- Home identity ---------------------------------------------------- +%% Compact, comparable form of a Home: the atom `environment`, or a +%% project named by its anchor nref. Deliberately NOT the full Project +%% handle -- that carries physical Mnesia table atoms, which have no +%% business in continuation state or in query results. +-type home_id() :: environment | {project, integer()}. + +%% -- Continuation ----------------------------------------------------- +%% Returned by bounded queries (currently only Q6). Tagged with the +%% snapshot it was issued against; resuming with a mismatched session +%% returns {error, snapshot_expired}. +%% +%% Every nref here is Home-qualified: a bare nref is unique only within +%% a Home, so project-6 and environment-6 must never collapse into one +%% visited entry. +-record(cont_path, { + snapshot_at :: erlang:timestamp(), + target :: {home_id(), integer()}, + arc_kinds :: [arc_kind()], + remaining_depth :: non_neg_integer(), + visited :: #{{home_id(), integer()} => true}, + %% [{HomeId, Nref, PathToHere}] — frontier nodes to expand on resume + frontier :: [{home_id(), integer(), [map()]}] +}). +``` + +- [ ] **Step 4: Add the two Home-id helpers** + +In `apps/graphdb/src/graphdb_query.erl`, immediately after `resolve_home/2` (which ends at line 380 with `resolve_home(_Session, _Nref) -> environment.`): + +```erlang +%%--------------------------------------------------------------------- +%% home_id(Home) -> home_id() +%% home_of_id(Session, Id) -> environment | Project +%% +%% `home_id()` is the compact, comparable form of a Home and is what +%% travels in the BFS frontier, the visited set, #cont_path{}, and the +%% public result. The full Project handle stays out of all four: it +%% carries physical table atoms, and it makes an unwieldy map key. +%% +%% home_of_id/2 is the inverse, and is total for any id a session can +%% legitimately produce: a session binds at most ONE Project, so a +%% {project, _} id can only mean that one. resume/2 gates continuations +%% carrying a foreign id before they ever reach here (validate_cont_homes/2). +%%--------------------------------------------------------------------- +home_id(environment) -> environment; +home_id(#{anchor := Anchor}) -> {project, Anchor}. + +home_of_id(_Session, environment) -> + environment; +home_of_id(Session, {project, _Anchor}) -> + maps:get(project, Session). +``` + +- [ ] **Step 5: Resolve endpoints once in `dispatch/2`** + +Replace the `#q_find_path{}` clause at `graphdb_query.erl:321-330`: + +```erlang +dispatch(#q_find_path{from = From, to = To, max_depth = D, + arc_kinds = Kinds}, Session) -> + SnapshotAt = maps:get(snapshot_at, Session), + %% Entry points -- and ONLY entry points -- resolve Home by + %% guessing. resolve_home/2 was designed for exactly this position: + %% a bare nref with no characterization context. From here on Home + %% travels with the frontier and is derived from each arc (see + %% graphdb_ns:arc_target_namespace/3); it is never re-guessed. + FromId = home_id(resolve_home(Session, From)), + ToId = home_id(resolve_home(Session, To)), + %% Initial budget D doubles as the resume budget — partial conts + %% carry max_depth as remaining_depth so resume gets a fresh full + %% allotment rather than the exhausted 0. + bfs(SnapshotAt, {ToId, To}, D, D, Kinds, + #{{FromId, From} => true}, + [{FromId, From, []}], + Session); +``` + +- [ ] **Step 6: Thread Home through `bfs/8` and `bfs_step/5`** + +Replace `graphdb_query.erl:860-898`. Update the `bfs/8` head comment's `Frontier` line to `Frontier :: [{HomeId, Nref, PathToHere}]`: + +```erlang +bfs(_Snap, _ToKey, _Budget, _D, _Kinds, _Vis, [], Session) -> + {{ok, no_path}, Session}; +bfs(Snap, ToKey, Budget, 0, Kinds, Vis, Frontier, Session) -> + %% Depth exhausted but frontier non-empty -- partial. + BestSoFar = case Frontier of + [{_HomeId, _Nref, P} | _] -> P; + [] -> [] + end, + Cont = #cont_path{snapshot_at = Snap, + target = ToKey, + arc_kinds = Kinds, + remaining_depth = Budget, + visited = Vis, + frontier = Frontier}, + {{partial, BestSoFar, Cont}, Session}; +bfs(Snap, ToKey, Budget, D, Kinds, Vis, Frontier, Session) -> + {NextFrontier, Vis1, FoundPath, Session1} = + bfs_step(ToKey, Kinds, Frontier, Vis, Session), + case FoundPath of + {found, Path} -> + {{ok, Path}, Session1}; + not_found -> + bfs(Snap, ToKey, Budget, D - 1, Kinds, Vis1, NextFrontier, + Session1) + end. + +bfs_step(ToKey, Kinds, Frontier, Vis, Session) -> + lists:foldl( + fun({HomeId, Nref, PathToHere}, {Acc, V, Found, S}) -> + case Found of + {found, _} -> + {Acc, V, Found, S}; + not_found -> + %% session_read_arcs_home/5, NOT session_read_arcs/4: + %% the Home is known, so there is nothing to guess. + Home = home_of_id(S, HomeId), + {Arcs, S1} = session_read_arcs_home(S, Home, Nref, + outgoing, Kinds), + expand_arcs(ToKey, HomeId, Home, Nref, PathToHere, + Arcs, V, Acc, Found, S1) + end + end, {[], Vis, not_found, Session}, Frontier). +``` + +- [ ] **Step 7: Derive the target's Home in `expand_arcs`** + +Replace `graphdb_query.erl:900-923` (`expand_arcs/8` becomes `/10`): + +```erlang +expand_arcs(_ToKey, _FromId, _FromHome, _From, _PathHere, [], V, Acc, + Found, S) -> + {Acc, V, Found, S}; +expand_arcs(ToKey, FromId, FromHome, From, PathHere, + [#relationship{kind = K, + characterization = C, + target_nref = T} | Rest], + V, Acc, Found, S) -> + %% Half A: the target's Home is DERIVED from the arc we arrived on, + %% never guessed from the bare nref. + TargetHome = graphdb_ns:arc_target_namespace(FromHome, K, C), + TargetId = home_id(TargetHome), + Edge = #{from => From, via => C, to => T, kind => K}, + NewPath = PathHere ++ [Edge], + %% Half B: compare and remember the Home-qualified key. A bare nref + %% is unique only within a Home. + Key = {TargetId, T}, + case Key of + ToKey -> + {Acc, V, {found, NewPath}, S}; + _ -> + case maps:is_key(Key, V) orelse is_scaffold_node(TargetHome, T) of + true -> + expand_arcs(ToKey, FromId, FromHome, From, PathHere, + Rest, V, Acc, Found, S); + false -> + V1 = V#{Key => true}, + Acc1 = Acc ++ [{TargetId, T, NewPath}], + expand_arcs(ToKey, FromId, FromHome, From, PathHere, + Rest, V1, Acc1, Found, S) + end + end. +``` + +`ToKey` is already bound at this point, so `case Key of ToKey ->` matches by value — the same idiom the original `case T of To ->` used. + +- [ ] **Step 8: Make `is_scaffold_node` take a Home** + +Replace `graphdb_query.erl:925-948` (comment block and function): + +```erlang +%%--------------------------------------------------------------------- +%% is_scaffold_node(Home, Nref) -> boolean() +%% +%% Category nodes are structural scaffold (nrefs 1-5) -- never +%% traversed by graph queries. Matches the semantics already encoded +%% in graphdb_class:ancestors/1 which filters NREF_CLASSES out of the +%% taxonomy walk. Without this filter, two classes sharing only +%% NREF_CLASSES as a parent would be considered taxonomically +%% connected, which contradicts both the design and existing helpers. +%% +%% Takes a resolved Home rather than a Session: the caller already +%% derived it from the arc. This needs no policy about what +%% scaffold-ness means under a project session -- a project-homed nref +%% reads from the project's table, finds kind=instance, and yields +%% false, which is the right answer reached structurally. +%%--------------------------------------------------------------------- +is_scaffold_node(Home, Nref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Home), Nref) of + [#node{kind = category}] -> true; + _ -> false + end. +``` + +- [ ] **Step 9: Compile and check for stale callers** + +Run: `./rebar3 compile` +Expected: zero errors, zero warnings. A warning about an unused function means a caller was missed. + +Run: `grep -n "session_read_arcs(S" apps/graphdb/src/graphdb_query.erl` +Expected: **no output**. `session_read_arcs/4` must have no BFS caller left. It stays defined and exported-in-module for `#q_get_arcs{}` and the describe paths — do not delete it. + +Run: `./rebar3 xref` +Expected: clean. + +- [ ] **Step 10: Run the new tests to verify they pass** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --group=sp2_traversal_home_routing` +Expected: 2 passed, 0 failed. + +- [ ] **Step 11: Run the whole query suite — T7, non-regression** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE` +Expected: all pass. `resolve_home_prefers_project_and_logs_on_collision`, the full `q6_find_path` group, and `resume_rejects_bad_session_project` must pass **without being edited** — that is the proof that entry-point behaviour is untouched. If any needed a change to stay green, stop and report it rather than editing the test. + +- [ ] **Step 12: Verify zero tabs** + +Run each separately: +```bash +grep -cP '\t' apps/graphdb/src/graphdb_query.erl +grep -cP '\t' apps/graphdb/test/graphdb_query_SUITE.erl +grep -cP '\t' apps/graphdb/include/graphdb_query.hrl +``` +Expected: `0` for all three. + +- [ ] **Step 13: Commit** + +```bash +git add apps/graphdb/src/graphdb_query.erl \ + apps/graphdb/include/graphdb_query.hrl \ + apps/graphdb/test/graphdb_query_SUITE.erl +git commit -m "Half B: Home-qualified BFS frontier, visited set and target" +``` + +--- + +### Task 3: Disclose the Home on cross-store edges + +**Files:** +- Modify: `apps/graphdb/src/graphdb_query.erl` (`expand_arcs/10`, plus a new `make_edge/6`) +- Test: `apps/graphdb/test/graphdb_query_SUITE.erl` + +**Interfaces:** +- Consumes: `home_id/1` (Task 2); `?ARC_INST_TO_CLASS` from `graphdb_nrefs.hrl` (already included at `graphdb_query.erl:55`). +- Produces: path edges of shape `#{from := integer(), via := integer(), to := integer(), kind := arc_kind()}`, plus `home := home_id()` **iff** the target's Home differs from the source's. + +**SPACES. 0 tabs.** + +- [ ] **Step 1: Write the failing test** + +Add to `apps/graphdb/test/graphdb_query_SUITE.erl`: + +```erlang +%%--------------------------------------------------------------------- +%% T5 -- a path that crosses stores says so. A project instance's +%% outgoing membership row (characterization 29) lives in the PROJECT's +%% relationship table but targets an environment class, so this is the +%% one crossing reachable under this scope. The edge must disclose +%% `home => environment`; an environment-only path (T1) discloses +%% nothing. +%%--------------------------------------------------------------------- +t5_cross_store_edge_discloses_home(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, X, _} = graphdb_instance:create_instance(Project, "T5X", Cls, + root()), + Session = graphdb_query:new_session(Project), + {ok, Path, _} = graphdb_query:execute_query( + #q_find_path{from = X, to = Cls, max_depth = 2, + arc_kinds = [instantiation]}, Session), + ?assertMatch([#{from := X, via := ?ARC_INST_TO_CLASS, to := Cls, + kind := instantiation, home := environment}], Path). +``` + +Register it in the export list and add it to the `sp2_traversal_home_routing` group. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --case=t5_cross_store_edge_discloses_home` +Expected: FAIL — the returned edge map has no `home` key. + +If instead it fails with `no_path`, the membership arc is not being read; check that `create_instance/4` wrote the 29-row into `Project`'s own relationship table before changing anything else. + +- [ ] **Step 3: Add `make_edge/6` and call it** + +In `apps/graphdb/src/graphdb_query.erl`, add after `expand_arcs/10`: + +```erlang +%%--------------------------------------------------------------------- +%% make_edge(From, Char, To, Kind, FromId, TargetId) -> map() +%% +%% A path edge discloses `home` iff this hop crosses stores. The absent +%% key means "same store as the previous hop"; the first edge's store is +%% the one the caller named in `from`. A consumer reconstructs the whole +%% Home sequence by carrying the last disclosed value forward. +%% +%% The value is a home_id(), never the raw Project handle -- results +%% must not leak physical table atoms. +%%--------------------------------------------------------------------- +make_edge(From, Char, To, Kind, HomeId, HomeId) -> + #{from => From, via => Char, to => To, kind => Kind}; +make_edge(From, Char, To, Kind, _FromId, TargetId) -> + #{from => From, via => Char, to => To, kind => Kind, + home => TargetId}. +``` + +The two clauses discriminate on whether the last two arguments are equal — the first head binds both to `HomeId`, so it matches only when they are. + +In `expand_arcs/10`, replace the `Edge = ...` line from Task 2: + +```erlang + Edge = make_edge(From, C, T, K, FromId, TargetId), +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --case=t5_cross_store_edge_discloses_home` +Expected: PASS. + +- [ ] **Step 5: Confirm T1 still sees no `home` key** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --group=sp2_traversal_home_routing` +Expected: all pass, including T1's `?assertEqual([], [E || E <- EnvPath, maps:is_key(home, E)])`. + +- [ ] **Step 6: Verify and commit** + +```bash +./rebar3 xref +grep -cP '\t' apps/graphdb/src/graphdb_query.erl +``` +Expected: xref clean; tab count `0`. + +```bash +git add apps/graphdb/src/graphdb_query.erl apps/graphdb/test/graphdb_query_SUITE.erl +git commit -m "Disclose home_id on path edges that cross stores" +``` + +--- + +### Task 4: `resume/2` validates continuation Homes + +**Files:** +- Modify: `apps/graphdb/src/graphdb_query.erl:184-201` (`resume/2`, beside `validate_session_home/1`) +- Test: `apps/graphdb/test/graphdb_query_SUITE.erl` + +**Interfaces:** +- Consumes: `#cont_path{}`'s Task 2 field types. +- Produces: `{error, session_project_mismatch}` as a new `resume/2` return value. + +**SPACES. 0 tabs.** + +> **Scope note the implementer must not silently "fix":** the design listed a +> test T3 asserting that a hop through environment-6 is not suppressed by +> having visited project-6. That test is **not constructible under this +> scope**. Reaching both keys in one walk requires crossing stores, and the +> only crossing available is project→environment via arc 29, which lands on a +> class — from which the environment attribute subtree containing nref 6 is +> unreachable by taxonomy. The design anticipated this ("if either proves +> unreachable under this scope, the plan must say so explicitly"). It is +> replaced below by T3', which asserts the same Half-B property directly and +> observably: the continuation's state is Home-qualified. Do not invent a +> contrived fixture to resurrect the original T3. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/graphdb/test/graphdb_query_SUITE.erl`: + +```erlang +%%--------------------------------------------------------------------- +%% T3' -- Half B's state shape, asserted where it is observable. +%% (The design's original T3 -- visiting project-6 must not suppress +%% environment-6 -- is not constructible under this scope; see the +%% plan's Task 4 scope note.) +%%--------------------------------------------------------------------- +t3_continuation_state_is_home_qualified(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, A, _} = graphdb_instance:create_instance(Project, "T3A", Cls, + root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "T3B", Cls, A), + {ok, C, _} = graphdb_instance:create_instance(Project, "T3C", Cls, B), + {ok, D, _} = graphdb_instance:create_instance(Project, "T3D", Cls, C), + Anchor = maps:get(anchor, Project), + Q = #q_find_path{from = D, to = A, max_depth = 1, + arc_kinds = [composition]}, + {partial, _Best, Cont, _S1} = graphdb_query:execute_query( + Q, graphdb_query:new_session(Project)), + #cont_path{target = Target, visited = Visited, frontier = Frontier} = + Cont, + ?assertEqual({{project, Anchor}, A}, Target), + ?assert(lists:all(fun({_HomeId, N}) when is_integer(N) -> true; + (_) -> false + end, maps:keys(Visited))), + ?assert(lists:all(fun({_HomeId, N, P}) -> is_integer(N) + andalso is_list(P); + (_) -> false + end, Frontier)). + +%%--------------------------------------------------------------------- +%% T6 -- the new frontier/visited shapes survive a round trip through +%% #cont_path{}: partial + resume must equal an unbounded run. +%%--------------------------------------------------------------------- +t6_resume_round_trip_under_project_session(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, A, _} = graphdb_instance:create_instance(Project, "T6A", Cls, + root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "T6B", Cls, A), + {ok, C, _} = graphdb_instance:create_instance(Project, "T6C", Cls, B), + {ok, D, _} = graphdb_instance:create_instance(Project, "T6D", Cls, C), + Bounded = #q_find_path{from = D, to = A, max_depth = 2, + arc_kinds = [composition]}, + S0 = graphdb_query:new_session(Project), + {partial, _Best, Cont, S1} = graphdb_query:execute_query(Bounded, S0), + {ok, Resumed, _S2} = graphdb_query:resume(Cont, S1), + {ok, Direct, _S3} = graphdb_query:execute_query( + #q_find_path{from = D, to = A, max_depth = 9, + arc_kinds = [composition]}, + graphdb_query:new_session(Project)), + ?assertEqual(Direct, Resumed). + +%%--------------------------------------------------------------------- +%% A continuation carrying a project id the session is not bound to must +%% be rejected on the caller side, not carried into home_of_id/2 inside +%% the singleton. Same reasoning as validate_session_home/1: the pid +%% must be unchanged afterwards. +%%--------------------------------------------------------------------- +resume_rejects_foreign_project_continuation(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, A, _} = graphdb_instance:create_instance(Project, "TFA", Cls, + root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "TFB", Cls, A), + {ok, C, _} = graphdb_instance:create_instance(Project, "TFC", Cls, B), + {ok, D, _} = graphdb_instance:create_instance(Project, "TFD", Cls, C), + Q = #q_find_path{from = D, to = A, max_depth = 1, + arc_kinds = [composition]}, + S0 = graphdb_query:new_session(Project), + {partial, _, Cont, S1} = graphdb_query:execute_query(Q, S0), + {ok, OtherP} = graphdb_project:register_project("TF other"), + {ok, Other} = graphdb_project:open(OtherP), + OtherSession = S1#{project => Other, + snapshot_at => maps:get(snapshot_at, S1)}, + PidBefore = whereis(graphdb_query), + ?assertEqual({error, session_project_mismatch}, + graphdb_query:resume(Cont, OtherSession)), + ?assertEqual(PidBefore, whereis(graphdb_query)). +``` + +Register all three in the export list and add them to the `sp2_traversal_home_routing` group. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --group=sp2_traversal_home_routing` + +Expected: `t3_...` and `t6_...` **PASS already** (Task 2 delivered the shape they assert — they are regression locks, not drivers). `resume_rejects_foreign_project_continuation` must **FAIL**, because `resume/2` does not yet check continuation Homes. + +If `t3_` or `t6_` fails, stop — Task 2 is incomplete and this task cannot proceed. + +- [ ] **Step 3: Implement the gate** + +In `apps/graphdb/src/graphdb_query.erl`, replace `resume/2` (currently at `:184-188`): + +```erlang +resume(Cont, Session) when is_map(Session) -> + case validate_session_home(Session) of + ok -> + case validate_cont_homes(Cont, Session) of + ok -> + gen_server:call(?MODULE, {resume, Cont, Session}); + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end. +``` + +Add after `validate_session_home/1` (which ends at `:201`): + +```erlang +%% validate_cont_homes(Cont, Session) -> ok | {error, session_project_mismatch} +%% +%% Every home_id() a continuation can feed to home_of_id/2 must be +%% resolvable against THIS session. Checked on the caller side, before +%% the gen_server:call, for the same reason validate_session_home/1 is: +%% home_of_id/2 would otherwise return the wrong project's handle (or +%% `undefined` for an environment-bound session) deep inside the +%% singleton's own handle_call. +%% +%% Only the target and the frontier are checked. Visited keys are never +%% resolved -- they are compared, and a foreign id there can only ever +%% fail to match, which is harmless. +validate_cont_homes(#cont_path{target = {TargetId, _Nref}, + frontier = Frontier}, Session) -> + Bound = home_id(maps:get(project, Session, environment)), + Ids = [TargetId | [Id || {Id, _N, _P} <- Frontier]], + case lists:all(fun(environment) -> true; + (Id) -> Id =:= Bound + end, Ids) of + true -> ok; + false -> {error, session_project_mismatch} + end. +``` + +`home_id/1` already accepts both `environment` and a Project handle, so `Bound` is correct for either session kind. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --group=sp2_traversal_home_routing` +Expected: all pass. + +- [ ] **Step 5: Confirm the existing resume tests still pass** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --group=q6_find_path` +Expected: all pass — in particular `q6_resume_continues_from_frontier` and `resume_against_refreshed_session_fails`, unmodified. + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_query_SUITE --case=resume_rejects_bad_session_project` +Expected: PASS. The `validate_session_home/1` gate must still fire **before** `validate_cont_homes/2` — a malformed handle returns `{error, invalid_project}`, not `session_project_mismatch`. + +- [ ] **Step 6: Verify and commit** + +```bash +./rebar3 xref +grep -cP '\t' apps/graphdb/src/graphdb_query.erl +``` +Expected: xref clean; tab count `0`. + +```bash +git add apps/graphdb/src/graphdb_query.erl apps/graphdb/test/graphdb_query_SUITE.erl +git commit -m "resume/2 rejects continuations carrying a foreign project Home" +``` + +--- + +### Task 5: Documentation and full verification + +**Files:** +- Modify: `TASKS.md` (the open-defect entry) +- Modify: `docs/Architecture.md:24`, `:31`, `:404-440` +- Modify: `apps/graphdb/CLAUDE.md` (`graphdb_ns` file-table row; `graphdb_query` worker section) + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: nothing consumed by code. + +- [ ] **Step 1: Flip the `TASKS.md` entry from open to resolved** + +Find the section beginning `**Open defect (Important) — query traversal silently truncates`. Change the heading to `**RESOLVED (query-traversal Home routing) — query traversal silently truncated`, keep the symptom paragraph as the historical record, and replace the "Designed — see ..." paragraph and the "Superseded fix direction" blockquote with: + +```markdown +**Implemented.** Half A: `graphdb_ns:arc_target_namespace(Home, Kind, Char)` +derives an arc-discovered nref's store from `#relationship.kind`, with the +29/30 membership pair split on characterization. Half B: the BFS frontier, +visited set, and target comparison are Home-qualified via +`home_id() :: environment | {project, Anchor}`; path edges disclose `home` +when a hop crosses stores; `resume/2` rejects a continuation carrying a +foreign project id. `resolve_home/2` and `session_read_arcs/4` are unchanged +— only `bfs_step/5` changed caller, to `session_read_arcs_home/5`. Design: +`docs/designs/query-traversal-home-routing-design.md`. + +Still open, deliberately out of that scope: BFS at an environment-homed node +reads only the environment relationship table, so an environment class cannot +reach its project instances across arc 30 (`?ARC_CLASS_TO_INST`). +`#q_instances_of{}` keeps its `session_read_arcs_home/5` special case. Also +still open: backfilling `target_kind` onto bootstrap arc labels 21–30, which +would let `graphdb_instance:check_target_kind/3` drop its permissive legacy +arm. +``` + +Leave the **Accepted consequence of `resolve_home/2`** section immediately below untouched — it describes intentional behaviour and is still accurate. + +- [ ] **Step 2: Update `docs/Architecture.md`** + +At line 31, extend the `graphdb_query` row's SP2 sentence: + +``` +SP2: `new_session/1` binds a `Project`; bare-nref **entry-point** reads resolve `Home` via `resolve_home/2`, while arc-discovered nrefs during BFS route deterministically via `graphdb_ns:arc_target_namespace/3`. `#q_find_path{}` state is Home-qualified and path edges disclose `home` on a store crossing. +``` + +In the `graphdb_ns` bullet around line 404, add `arc_target_namespace/3` to the listed functions with a one-clause description. Do not restructure the section. + +- [ ] **Step 3: Update `apps/graphdb/CLAUDE.md`** + +In the file table, extend the `graphdb_ns.erl` row to list `arc_target_namespace/3`. In the `graphdb_query` worker section, replace the paragraph that currently reads "Every bare-nref read resolves its physical table via `resolve_home/2` (SP2)" so it distinguishes entry points from arc-discovered nrefs, matching the wording used in Step 2. + +- [ ] **Step 4: Full verification gate** + +Run each and record the output: + +```bash +./rebar3 compile +``` +Expected: zero errors, zero warnings. + +```bash +./rebar3 as test compile +``` +Expected: zero errors, zero warnings. + +```bash +./rebar3 xref +``` +Expected: clean — only the pre-existing `rel_id_server:seed_from_mnesia/0` entry in `xref_ignores` is suppressed; no new ignores may be added. + +```bash +make test-ct-parallel +``` +Expected: all CT suites green. Baseline at `bb6c8c6` is **544 CT**; this branch adds 6 cases (T1, T2, T3', T5, T6, foreign-continuation) → **550 CT**. + +```bash +./rebar3 eunit +``` +Expected: all green. Baseline **145 EUnit**; Task 1 adds 6 → **151 EUnit**. + +If any count differs from the expected total, report the discrepancy rather than adjusting the expectation. + +- [ ] **Step 5: Confirm no unrelated file drifted** + +```bash +git status --short +``` +Expected: only the files this plan names. Nothing under `.wolf/` or `.superpowers/` may be staged. + +- [ ] **Step 6: Commit** + +```bash +git add TASKS.md docs/Architecture.md apps/graphdb/CLAUDE.md +git commit -m "Docs: record query-traversal Home routing as implemented" +``` + +--- + +## Self-Review + +**Spec coverage.** Every section of `docs/designs/query-traversal-home-routing-design.md` maps to a task: Half A → Task 1 + Task 2 Step 7; `is_scaffold_node` → Task 2 Step 8; Half B (`home_id`, frontier, visited, target, `#cont_path{}`) → Task 2 Steps 3–7; result shape → Task 3; error handling clause 1 (`arc_target_namespace/3` catch-all) → Task 1 Step 4; clause 2 (`home_of_id/2` via `resume/2`) → Task 4; "what does not change" → Task 2 Step 9's `grep` and Step 11's unmodified-suite requirement; testing T1/T2/T4/T5/T6/T7 → Tasks 1–4; scope boundary → Task 5 Step 1. + +**One deliberate deviation.** The design's T3 is not constructible under this scope. Task 4's scope note states why and substitutes T3', which asserts the same Half-B property observably. The design anticipated exactly this outcome and required it be stated rather than papered over. + +**Type consistency.** `home_id()` is `environment | {project, integer()}` everywhere — the `.hrl` type, `home_id/1`'s return, `#cont_path{}`'s three fields, `make_edge/6`'s `home` value, and `validate_cont_homes/2`'s `Bound`. `arc_target_namespace/3` returns `environment | Home` (a full Home, not an id); every caller passes it through `home_id/1` before it enters traversal state. `expand_arcs` is `/10` in Tasks 2, 3, and 4 alike. + +--- + +**Plan complete and saved to `docs/superpowers/plans/2026-08-09-query-traversal-home-routing.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints. From 1a59a7af0cca391402a9ca8fd1b015caf3f07218 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 06:36:19 -0400 Subject: [PATCH 3/9] Half A: graphdb_ns:arc_target_namespace/3 routes arc-discovered nrefs --- apps/graphdb/src/graphdb_ns.erl | 49 +++++++++++++++++++++++- apps/graphdb/test/graphdb_ns_tests.erl | 52 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/apps/graphdb/src/graphdb_ns.erl b/apps/graphdb/src/graphdb_ns.erl index b2a135d..6d81ea4 100644 --- a/apps/graphdb/src/graphdb_ns.erl +++ b/apps/graphdb/src/graphdb_ns.erl @@ -22,8 +22,10 @@ %%--------------------------------------------------------------------- -module(graphdb_ns). +-include_lib("graphdb/include/graphdb_nrefs.hrl"). --export([namespace_of/2, target_namespace/2, node_table/1, rel_table/1]). +-export([namespace_of/2, target_namespace/2, arc_target_namespace/3, + node_table/1, rel_table/1]). %%--------------------------------------------------------------------- %% NYI / UEM Macros @@ -70,6 +72,51 @@ target_namespace(_Home, class) -> environment; target_namespace(Home, instance) -> Home. +%%--------------------------------------------------------------------- +%% arc_target_namespace(Home, ArcKind, Characterization) +%% -> environment | Home +%% +%% The arc-row analogue of namespace_of/2, for a traversal that has an +%% arc in hand. `Home` is the store the arc row was READ from. +%% +%% Unlike a bare nref, an nref reached BY TRAVERSING AN ARC is not +%% ambiguous: #relationship.kind and .characterization are present on +%% every row and together determine the target's store outright. This +%% is what lets graphdb_query stop guessing mid-traversal. +%% +%% taxonomy -- class/attribute refinement; projects hold only +%% instances, so the target is always environment. +%% composition -- home-relative: category/class composition is +%% environment<->environment, instance composition is +%% project<->project. +%% connection -- instance<->instance inside one store. +%% instantiation -- the 29/30 membership pair. BOTH rows live in the +%% project's table, but 29 (instance->class) targets +%% an environment class while 30 (class->instance) +%% targets a project instance. This is the one arc +%% shape whose ends deliberately differ in Home, and +%% the characterization is exactly what says which +%% direction we are walking. +%% +%% Deliberately total. A caller of this module is the graphdb_query +%% singleton; a function_clause here would take the query server down +%% for every session over one malformed row, so an unrecognised shape +%% logs and degrades to same-store (which is what that row did before +%% this function existed). +%%--------------------------------------------------------------------- +arc_target_namespace(_Home, taxonomy, _Char) -> environment; +arc_target_namespace(Home, composition, _Char) -> Home; +arc_target_namespace(Home, connection, _Char) -> Home; +arc_target_namespace(_Home, instantiation, ?ARC_INST_TO_CLASS) -> environment; +arc_target_namespace(Home, instantiation, ?ARC_CLASS_TO_INST) -> Home; +arc_target_namespace(Home, Kind, Char) -> + logger:warning( + "graphdb_ns: unroutable arc kind ~p (characterization ~p) -- " + "defaulting to same store ~p", + [Kind, Char, Home]), + Home. + + %%--------------------------------------------------------------------- %% node_table(Home) -> atom() %% rel_table(Home) -> atom() diff --git a/apps/graphdb/test/graphdb_ns_tests.erl b/apps/graphdb/test/graphdb_ns_tests.erl index 3f789f8..847d08a 100644 --- a/apps/graphdb/test/graphdb_ns_tests.erl +++ b/apps/graphdb/test/graphdb_ns_tests.erl @@ -1,5 +1,6 @@ -module(graphdb_ns_tests). -include_lib("eunit/include/eunit.hrl"). +-include_lib("graphdb/include/graphdb_nrefs.hrl"). -define(PROJECT, #{anchor => 42, nodes => nodes_42, rels => relationships_42, counters => counters_42}). @@ -40,3 +41,54 @@ rel_table_environment_is_literal_test() -> rel_table_project_is_its_own_table_test() -> ?assertEqual(relationships_42, graphdb_ns:rel_table(?PROJECT)). + +%%--------------------------------------------------------------------- +%% arc_target_namespace/3 -- the arc-row analogue of namespace_of/2. +%% Home is the store the arc row was READ from. +%%--------------------------------------------------------------------- +arc_target_namespace_taxonomy_is_always_environment_test() -> + %% Taxonomy is class/attribute refinement; projects hold only + %% instances, so a taxonomy arc never targets a project node. + [ ?assertEqual(environment, + graphdb_ns:arc_target_namespace(Home, taxonomy, C)) + || Home <- [environment, ?PROJECT], + C <- [?ARC_ATTR_PARENT, ?ARC_ATTR_CHILD, 9999] ]. + +arc_target_namespace_composition_is_home_relative_test() -> + [ ?assertEqual(Home, + graphdb_ns:arc_target_namespace(Home, composition, C)) + || Home <- [environment, ?PROJECT], + C <- [?ARC_INST_PARENT, ?ARC_INST_CHILD, 9999] ]. + +arc_target_namespace_connection_is_home_relative_test() -> + [ ?assertEqual(Home, + graphdb_ns:arc_target_namespace(Home, connection, 9999)) + || Home <- [environment, ?PROJECT] ]. + +arc_target_namespace_membership_splits_on_characterization_test() -> + %% The 29/30 pair is the one arc shape whose two directions + %% deliberately live in different Homes: both rows sit in the + %% PROJECT's table, but 29 targets an environment class and 30 + %% targets a project instance. + ?assertEqual(environment, + graphdb_ns:arc_target_namespace(?PROJECT, instantiation, + ?ARC_INST_TO_CLASS)), + ?assertEqual(?PROJECT, + graphdb_ns:arc_target_namespace(?PROJECT, instantiation, + ?ARC_CLASS_TO_INST)). + +arc_target_namespace_unknown_kind_defaults_to_home_test() -> + %% Deliberately NOT a function_clause: this is reached from inside + %% the graphdb_query singleton, where a crash would take the query + %% server down for every session over one malformed row. + ?assertEqual(environment, + graphdb_ns:arc_target_namespace(environment, bogus_kind, 1)), + ?assertEqual(?PROJECT, + graphdb_ns:arc_target_namespace(?PROJECT, bogus_kind, 1)). + +arc_target_namespace_unknown_instantiation_char_defaults_to_home_test() -> + %% instantiation with a characterization outside the 29/30 pair is + %% not a shape this codebase writes; fall through to the catch-all + %% rather than crash. + ?assertEqual(?PROJECT, + graphdb_ns:arc_target_namespace(?PROJECT, instantiation, 9999)). From 431ab26067f905cff52253171542a236e4325db3 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 06:49:58 -0400 Subject: [PATCH 4/9] Half B: Home-qualified BFS frontier, visited set and target Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/include/graphdb_query.hrl | 19 +++- apps/graphdb/src/graphdb_query.erl | 115 ++++++++++++++-------- apps/graphdb/test/graphdb_query_SUITE.erl | 89 ++++++++++++++++- 3 files changed, 178 insertions(+), 45 deletions(-) diff --git a/apps/graphdb/include/graphdb_query.hrl b/apps/graphdb/include/graphdb_query.hrl index e19257c..4c364a3 100644 --- a/apps/graphdb/include/graphdb_query.hrl +++ b/apps/graphdb/include/graphdb_query.hrl @@ -52,18 +52,29 @@ arc_kinds :: [arc_kind()] }). +%% -- Home identity ---------------------------------------------------- +%% Compact, comparable form of a Home: the atom `environment`, or a +%% project named by its anchor nref. Deliberately NOT the full Project +%% handle -- that carries physical Mnesia table atoms, which have no +%% business in continuation state or in query results. +-type home_id() :: environment | {project, integer()}. + %% -- Continuation ----------------------------------------------------- %% Returned by bounded queries (currently only Q6). Tagged with the %% snapshot it was issued against; resuming with a mismatched session %% returns {error, snapshot_expired}. +%% +%% Every nref here is Home-qualified: a bare nref is unique only within +%% a Home, so project-6 and environment-6 must never collapse into one +%% visited entry. -record(cont_path, { snapshot_at :: erlang:timestamp(), - target :: integer(), + target :: {home_id(), integer()}, arc_kinds :: [arc_kind()], remaining_depth :: non_neg_integer(), - visited :: #{integer() => true}, - %% [{Nref, PathToHere}] — frontier nodes to expand on resume - frontier :: [{integer(), [map()]}] + visited :: #{{home_id(), integer()} => true}, + %% [{HomeId, Nref, PathToHere}] — frontier nodes to expand on resume + frontier :: [{home_id(), integer(), [map()]}] }). -endif. diff --git a/apps/graphdb/src/graphdb_query.erl b/apps/graphdb/src/graphdb_query.erl index 2d48e1d..5146816 100644 --- a/apps/graphdb/src/graphdb_query.erl +++ b/apps/graphdb/src/graphdb_query.erl @@ -321,12 +321,19 @@ dispatch(#q_instances_of{class = C, recursive = Recursive}, Session) -> dispatch(#q_find_path{from = From, to = To, max_depth = D, arc_kinds = Kinds}, Session) -> SnapshotAt = maps:get(snapshot_at, Session), + %% Entry points -- and ONLY entry points -- resolve Home by + %% guessing. resolve_home/2 was designed for exactly this position: + %% a bare nref with no characterization context. From here on Home + %% travels with the frontier and is derived from each arc (see + %% graphdb_ns:arc_target_namespace/3); it is never re-guessed. + FromId = home_id(resolve_home(Session, From)), + ToId = home_id(resolve_home(Session, To)), %% Initial budget D doubles as the resume budget — partial conts %% carry max_depth as remaining_depth so resume gets a fresh full %% allotment rather than the exhausted 0. - bfs(SnapshotAt, To, D, D, Kinds, - #{From => true}, - [{From, []}], + bfs(SnapshotAt, {ToId, To}, D, D, Kinds, + #{{FromId, From} => true}, + [{FromId, From, []}], Session); dispatch(_Query, Session) -> {{error, not_implemented}, Session}. @@ -379,6 +386,28 @@ resolve_home(#{project := Project}, Nref) when Project =/= undefined -> resolve_home(_Session, _Nref) -> environment. +%%--------------------------------------------------------------------- +%% home_id(Home) -> home_id() +%% home_of_id(Session, Id) -> environment | Project +%% +%% `home_id()` is the compact, comparable form of a Home and is what +%% travels in the BFS frontier, the visited set, #cont_path{}, and the +%% public result. The full Project handle stays out of all four: it +%% carries physical table atoms, and it makes an unwieldy map key. +%% +%% home_of_id/2 is the inverse, and is total for any id a session can +%% legitimately produce: a session binds at most ONE Project, so a +%% {project, _} id can only mean that one. resume/2 gates continuations +%% carrying a foreign id before they ever reach here (validate_cont_homes/2). +%%--------------------------------------------------------------------- +home_id(environment) -> environment; +home_id(#{anchor := Anchor}) -> {project, Anchor}. + +home_of_id(_Session, environment) -> + environment; +home_of_id(Session, {project, _Anchor}) -> + maps:get(project, Session). + %%--------------------------------------------------------------------- %% session_read_node(Session, Nref) -> {Node | not_found, Session1} %% @@ -845,7 +874,7 @@ all_subclasses(C) -> %% bfs(SnapshotAt, Target, ResumeBudget, RemainingDepth, ArcKinds, %% Visited, Frontier, Session) -> {Reply, Session1} %% -%% Frontier :: [{Nref, PathToHere}] +%% Frontier :: [{HomeId, Nref, PathToHere}] %% PathToHere :: [#{from, via, to, kind}] (edges already taken) %% %% ResumeBudget is the original max_depth — stored on the cont so resume @@ -857,73 +886,84 @@ all_subclasses(C) -> %% {{ok, no_path}, Session1} -- frontier emptied %% {{partial, BestSoFar, #cont_path{}}, Session1} -- depth-bounded %%--------------------------------------------------------------------- -bfs(_Snap, _To, _Budget, _D, _Kinds, _Vis, [], Session) -> +bfs(_Snap, _ToKey, _Budget, _D, _Kinds, _Vis, [], Session) -> {{ok, no_path}, Session}; -bfs(Snap, To, Budget, 0, Kinds, Vis, Frontier, Session) -> +bfs(Snap, ToKey, Budget, 0, Kinds, Vis, Frontier, Session) -> %% Depth exhausted but frontier non-empty -- partial. BestSoFar = case Frontier of - [{_, P} | _] -> P; - [] -> [] + [{_HomeId, _Nref, P} | _] -> P; + [] -> [] end, Cont = #cont_path{snapshot_at = Snap, - target = To, + target = ToKey, arc_kinds = Kinds, remaining_depth = Budget, visited = Vis, frontier = Frontier}, {{partial, BestSoFar, Cont}, Session}; -bfs(Snap, To, Budget, D, Kinds, Vis, Frontier, Session) -> +bfs(Snap, ToKey, Budget, D, Kinds, Vis, Frontier, Session) -> {NextFrontier, Vis1, FoundPath, Session1} = - bfs_step(To, Kinds, Frontier, Vis, Session), + bfs_step(ToKey, Kinds, Frontier, Vis, Session), case FoundPath of {found, Path} -> {{ok, Path}, Session1}; not_found -> - bfs(Snap, To, Budget, D - 1, Kinds, Vis1, NextFrontier, + bfs(Snap, ToKey, Budget, D - 1, Kinds, Vis1, NextFrontier, Session1) end. -bfs_step(To, Kinds, Frontier, Vis, Session) -> +bfs_step(ToKey, Kinds, Frontier, Vis, Session) -> lists:foldl( - fun({Nref, PathToHere}, {Acc, V, Found, S}) -> + fun({HomeId, Nref, PathToHere}, {Acc, V, Found, S}) -> case Found of {found, _} -> {Acc, V, Found, S}; not_found -> - {Arcs, S1} = session_read_arcs(S, Nref, outgoing, - Kinds), - expand_arcs(To, Nref, PathToHere, Arcs, V, Acc, - Found, S1) + %% session_read_arcs_home/5, NOT session_read_arcs/4: + %% the Home is known, so there is nothing to guess. + Home = home_of_id(S, HomeId), + {Arcs, S1} = session_read_arcs_home(S, Home, Nref, + outgoing, Kinds), + expand_arcs(ToKey, HomeId, Home, Nref, PathToHere, + Arcs, V, Acc, Found, S1) end end, {[], Vis, not_found, Session}, Frontier). -expand_arcs(_To, _From, _PathHere, [], V, Acc, Found, S) -> +expand_arcs(_ToKey, _FromId, _FromHome, _From, _PathHere, [], V, Acc, + Found, S) -> {Acc, V, Found, S}; -expand_arcs(To, From, PathHere, +expand_arcs(ToKey, FromId, FromHome, From, PathHere, [#relationship{kind = K, characterization = C, target_nref = T} | Rest], V, Acc, Found, S) -> + %% Half A: the target's Home is DERIVED from the arc we arrived on, + %% never guessed from the bare nref. + TargetHome = graphdb_ns:arc_target_namespace(FromHome, K, C), + TargetId = home_id(TargetHome), Edge = #{from => From, via => C, to => T, kind => K}, NewPath = PathHere ++ [Edge], - case T of - To -> + %% Half B: compare and remember the Home-qualified key. A bare nref + %% is unique only within a Home. + Key = {TargetId, T}, + case Key of + ToKey -> {Acc, V, {found, NewPath}, S}; _ -> - case maps:is_key(T, V) orelse is_scaffold_node(S, T) of + case maps:is_key(Key, V) orelse is_scaffold_node(TargetHome, T) of true -> - expand_arcs(To, From, PathHere, Rest, V, Acc, - Found, S); + expand_arcs(ToKey, FromId, FromHome, From, PathHere, + Rest, V, Acc, Found, S); false -> - V1 = V#{T => true}, - Acc1 = Acc ++ [{T, NewPath}], - expand_arcs(To, From, PathHere, Rest, V1, Acc1, - Found, S) + V1 = V#{Key => true}, + Acc1 = Acc ++ [{TargetId, T, NewPath}], + expand_arcs(ToKey, FromId, FromHome, From, PathHere, + Rest, V1, Acc1, Found, S) end end. %%--------------------------------------------------------------------- -%% is_scaffold_node(Session, Nref) -> boolean() +%% is_scaffold_node(Home, Nref) -> boolean() %% %% Category nodes are structural scaffold (nrefs 1-5) -- never %% traversed by graph queries. Matches the semantics already encoded @@ -932,16 +972,13 @@ expand_arcs(To, From, PathHere, %% NREF_CLASSES as a parent would be considered taxonomically %% connected, which contradicts both the design and existing helpers. %% -%% Home-routed via resolve_home/2: a project instance can legitimately -%% collide in key with an environment scaffold nref (1-5). Without this, -%% BFS would silently skip a real project-instance hop as if it were -%% scaffold, producing a wrong path result with no error. When the -%% collision resolves to the project, the project's node is read (never -%% kind=category there), so is_scaffold_node correctly returns false and -%% the real instance hop is traversed. +%% Takes a resolved Home rather than a Session: the caller already +%% derived it from the arc. This needs no policy about what +%% scaffold-ness means under a project session -- a project-homed nref +%% reads from the project's table, finds kind=instance, and yields +%% false, which is the right answer reached structurally. %%--------------------------------------------------------------------- -is_scaffold_node(Session, Nref) -> - Home = resolve_home(Session, Nref), +is_scaffold_node(Home, Nref) -> case mnesia:dirty_read(graphdb_ns:node_table(Home), Nref) of [#node{kind = category}] -> true; _ -> false diff --git a/apps/graphdb/test/graphdb_query_SUITE.erl b/apps/graphdb/test/graphdb_query_SUITE.erl index 6ad7b2a..20e4c65 100644 --- a/apps/graphdb/test/graphdb_query_SUITE.erl +++ b/apps/graphdb/test/graphdb_query_SUITE.erl @@ -112,7 +112,10 @@ resolve_home_prefers_project_and_logs_on_collision/1, %% SP2 review wave B Fix 2 — malformed-handle read-path gating execute_query_2_rejects_bad_session_project/1, - resume_rejects_bad_session_project/1 + resume_rejects_bad_session_project/1, + %% SP2 follow-up — Home routing for arc-discovered nrefs + t1_env_only_path_identical_across_sessions/1, + t2_shadowed_target_is_not_falsely_found/1 ]). suite() -> @@ -122,7 +125,8 @@ all() -> [{group, skeleton}, {group, q1_get_node}, {group, q1b_get_arcs}, {group, q2_describe_attribute}, {group, q3_describe_class}, {group, q4_describe_instance}, {group, q5_list_instances_of}, - {group, q6_find_path}, {group, sp2_project_session}]. + {group, q6_find_path}, {group, sp2_project_session}, + {group, sp2_traversal_home_routing}]. groups() -> [{skeleton, [], [ @@ -192,6 +196,10 @@ groups() -> resolve_home_prefers_project_and_logs_on_collision, execute_query_2_rejects_bad_session_project, resume_rejects_bad_session_project + ]}, + {sp2_traversal_home_routing, [], [ + t1_env_only_path_identical_across_sessions, + t2_shadowed_target_is_not_falsely_found ]}]. @@ -934,6 +942,83 @@ resume_rejects_bad_session_project(_Config) -> graphdb_query:resume(Cont, BadSession)), ?assertEqual(PidBefore, whereis(graphdb_query)). +%%===================================================================== +%% SP2 follow-up — Home routing for arc-discovered nrefs +%% (docs/designs/query-traversal-home-routing-design.md) +%%===================================================================== + +%%--------------------------------------------------------------------- +%% T1 -- the invariant that broke. An environment-only path must be +%% returned IDENTICALLY under an environment session, under a project +%% that does not shadow the intermediate hop, and under a project that +%% does. Before the fix the third case returned {ok, no_path}. +%%--------------------------------------------------------------------- +t1_env_only_path_identical_across_sessions(_Config) -> + %% Both attributes are created under bootstrap nref 6 ("Names"), and + %% graphdb_attr writes the taxonomy pair 6 -24-> New / New -23-> 6. + %% So the only [taxonomy] route from A1 to A2 runs through 6. + {ok, A1} = graphdb_attr:create_name_attribute("T1Alpha"), + {ok, A2} = graphdb_attr:create_name_attribute("T1Beta"), + Q = #q_find_path{from = A1, to = A2, max_depth = 4, + arc_kinds = [taxonomy]}, + + {ok, EnvPath, _} = + graphdb_query:execute_query(Q, graphdb_query:new_session()), + + %% (b) a project holding a single node -- nref 6 is NOT shadowed. + {ok, SmallP} = graphdb_project:register_project("T1 small"), + {ok, Small} = graphdb_project:open(SmallP), + _ = root_instance(Small), + {ok, SmallPath, _} = + graphdb_query:execute_query(Q, graphdb_query:new_session(Small)), + + %% (c) a project holding >= 6 nodes -- nref 6 IS shadowed. Project + %% allocators start at 1, so this is the steady state of any real + %% project, not a contrived setup. + {ok, BigP} = graphdb_project:register_project("T1 shadowing"), + {ok, Big} = graphdb_project:open(BigP), + Seeded = [root_instance(Big) || _ <- lists:seq(1, 6)], + ?assert(lists:member(?NREF_NAMES, Seeded)), + {ok, BigPath, _} = + graphdb_query:execute_query(Q, graphdb_query:new_session(Big)), + + ?assertEqual(EnvPath, SmallPath), + ?assertEqual(EnvPath, BigPath), + ?assertMatch([#{from := A1, via := ?ARC_ATTR_PARENT, + to := ?NREF_NAMES, kind := taxonomy}, + #{from := ?NREF_NAMES, via := ?ARC_ATTR_CHILD, + to := A2, kind := taxonomy}], EnvPath), + %% An environment-only path crosses no store, so no edge discloses + %% a Home (see Task 3). + ?assertEqual([], [E || E <- EnvPath, maps:is_key(home, E)]). + +%%--------------------------------------------------------------------- +%% T2 -- false `found` via the bare-nref target comparison. With +%% to = 6 under a shadowing project, resolve_home/2 resolves the +%% ENDPOINT to the project's instance 6 (documented, intentional). A +%% walk through the environment's attribute 6 must therefore NOT count +%% as having found it. Pre-fix, `case T of To` compared 6 =:= 6 and +%% returned a one-edge path -- a fabricated result. +%% +%% max_depth = 2 makes the post-fix outcome deterministic: level 1 +%% expands A1 to {6}, level 2 expands 6's children, none of which is +%% the project's instance 6, and the budget runs out with a non-empty +%% frontier -> partial. +%%--------------------------------------------------------------------- +t2_shadowed_target_is_not_falsely_found(_Config) -> + {ok, A1} = graphdb_attr:create_name_attribute("T2Alpha"), + {ok, P} = graphdb_project:register_project("T2 shadowing"), + {ok, Project} = graphdb_project:open(P), + Seeded = [root_instance(Project) || _ <- lists:seq(1, 6)], + ?assert(lists:member(?NREF_NAMES, Seeded)), + Session = graphdb_query:new_session(Project), + Reply = graphdb_query:execute_query( + #q_find_path{from = A1, to = ?NREF_NAMES, max_depth = 2, + arc_kinds = [taxonomy]}, Session), + %% The invariant: no path is fabricated. + ?assertNotMatch({ok, [_ | _], _}, Reply), + ?assertMatch({partial, _Best, _Cont, _S}, Reply). + %%--------------------------------------------------------------------- %% proj() -> Project From 3cda6cf44b28200e1de7efb418df0123f3f23b5d Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 07:03:29 -0400 Subject: [PATCH 5/9] Disclose home_id on path edges that cross stores Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_query.erl | 19 ++++++++++++++++- apps/graphdb/test/graphdb_query_SUITE.erl | 26 +++++++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/graphdb/src/graphdb_query.erl b/apps/graphdb/src/graphdb_query.erl index 5146816..cf97e24 100644 --- a/apps/graphdb/src/graphdb_query.erl +++ b/apps/graphdb/src/graphdb_query.erl @@ -941,7 +941,7 @@ expand_arcs(ToKey, FromId, FromHome, From, PathHere, %% never guessed from the bare nref. TargetHome = graphdb_ns:arc_target_namespace(FromHome, K, C), TargetId = home_id(TargetHome), - Edge = #{from => From, via => C, to => T, kind => K}, + Edge = make_edge(From, C, T, K, FromId, TargetId), NewPath = PathHere ++ [Edge], %% Half B: compare and remember the Home-qualified key. A bare nref %% is unique only within a Home. @@ -962,6 +962,23 @@ expand_arcs(ToKey, FromId, FromHome, From, PathHere, end end. +%%--------------------------------------------------------------------- +%% make_edge(From, Char, To, Kind, FromId, TargetId) -> map() +%% +%% A path edge discloses `home` iff this hop crosses stores. The absent +%% key means "same store as the previous hop"; the first edge's store is +%% the one the caller named in `from`. A consumer reconstructs the whole +%% Home sequence by carrying the last disclosed value forward. +%% +%% The value is a home_id(), never the raw Project handle -- results +%% must not leak physical table atoms. +%%--------------------------------------------------------------------- +make_edge(From, Char, To, Kind, HomeId, HomeId) -> + #{from => From, via => Char, to => To, kind => Kind}; +make_edge(From, Char, To, Kind, _FromId, TargetId) -> + #{from => From, via => Char, to => To, kind => Kind, + home => TargetId}. + %%--------------------------------------------------------------------- %% is_scaffold_node(Home, Nref) -> boolean() %% diff --git a/apps/graphdb/test/graphdb_query_SUITE.erl b/apps/graphdb/test/graphdb_query_SUITE.erl index 20e4c65..afa2a79 100644 --- a/apps/graphdb/test/graphdb_query_SUITE.erl +++ b/apps/graphdb/test/graphdb_query_SUITE.erl @@ -115,7 +115,8 @@ resume_rejects_bad_session_project/1, %% SP2 follow-up — Home routing for arc-discovered nrefs t1_env_only_path_identical_across_sessions/1, - t2_shadowed_target_is_not_falsely_found/1 + t2_shadowed_target_is_not_falsely_found/1, + t5_cross_store_edge_discloses_home/1 ]). suite() -> @@ -199,7 +200,8 @@ groups() -> ]}, {sp2_traversal_home_routing, [], [ t1_env_only_path_identical_across_sessions, - t2_shadowed_target_is_not_falsely_found + t2_shadowed_target_is_not_falsely_found, + t5_cross_store_edge_discloses_home ]}]. @@ -1019,6 +1021,26 @@ t2_shadowed_target_is_not_falsely_found(_Config) -> ?assertNotMatch({ok, [_ | _], _}, Reply), ?assertMatch({partial, _Best, _Cont, _S}, Reply). +%%--------------------------------------------------------------------- +%% T5 -- a path that crosses stores says so. A project instance's +%% outgoing membership row (characterization 29) lives in the PROJECT's +%% relationship table but targets an environment class, so this is the +%% one crossing reachable under this scope. The edge must disclose +%% `home => environment`; an environment-only path (T1) discloses +%% nothing. +%%--------------------------------------------------------------------- +t5_cross_store_edge_discloses_home(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, X, _} = graphdb_instance:create_instance(Project, "T5X", Cls, + root()), + Session = graphdb_query:new_session(Project), + {ok, Path, _} = graphdb_query:execute_query( + #q_find_path{from = X, to = Cls, max_depth = 2, + arc_kinds = [instantiation]}, Session), + ?assertMatch([#{from := X, via := ?ARC_INST_TO_CLASS, to := Cls, + kind := instantiation, home := environment}], Path). + %%--------------------------------------------------------------------- %% proj() -> Project From 0f76e1805805fc7f8b29f9772d0a39af5c025fd6 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 07:12:07 -0400 Subject: [PATCH 6/9] resume/2 rejects continuations carrying a foreign project Home Adds validate_cont_homes/2, a caller-side gate on resume/2 that runs after validate_session_home/1 and before the gen_server:call. Every home_id() a #cont_path{} carries (target + frontier) must resolve against the resuming session's own bound project; a mismatch returns {error, session_project_mismatch} instead of reaching home_of_id/2 inside the graphdb_query singleton. Closes two review findings tracked from Task 2: - a continuation resumed under a project-less session no longer risks maps:get(project, Session) raising {badkey, project} inside handle_call/3 and killing the singleton - a continuation resumed under a *different* project is rejected instead of home_of_id/2 silently discarding the anchor and reading the wrong project's tables Adds three regression tests: t3_continuation_state_is_home_qualified and t6_resume_round_trip_under_project_session (pass on arrival -- Task 2 already delivered this shape) and resume_rejects_foreign_project_continuation (drives this fix). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_query.erl | 34 ++++++++- apps/graphdb/test/graphdb_query_SUITE.erl | 90 ++++++++++++++++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/apps/graphdb/src/graphdb_query.erl b/apps/graphdb/src/graphdb_query.erl index cf97e24..3ab1a8d 100644 --- a/apps/graphdb/src/graphdb_query.erl +++ b/apps/graphdb/src/graphdb_query.erl @@ -183,8 +183,15 @@ execute_query(Query, Session) when is_map(Session) -> %% straight into a handle_call that resolves bare nrefs via resolve_home/2. resume(Cont, Session) when is_map(Session) -> case validate_session_home(Session) of - ok -> gen_server:call(?MODULE, {resume, Cont, Session}); - {error, _} = Err -> Err + ok -> + case validate_cont_homes(Cont, Session) of + ok -> + gen_server:call(?MODULE, {resume, Cont, Session}); + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err end. %% validate_session_home(Session) -> ok | {error, invalid_project} @@ -200,6 +207,29 @@ validate_session_home(Session) -> Project -> graphdb_project:require_project(Project) end. +%% validate_cont_homes(Cont, Session) -> ok | {error, session_project_mismatch} +%% +%% Every home_id() a continuation can feed to home_of_id/2 must be +%% resolvable against THIS session. Checked on the caller side, before +%% the gen_server:call, for the same reason validate_session_home/1 is: +%% home_of_id/2 would otherwise return the wrong project's handle (or +%% `undefined` for an environment-bound session) deep inside the +%% singleton's own handle_call. +%% +%% Only the target and the frontier are checked. Visited keys are never +%% resolved -- they are compared, and a foreign id there can only ever +%% fail to match, which is harmless. +validate_cont_homes(#cont_path{target = {TargetId, _Nref}, + frontier = Frontier}, Session) -> + Bound = home_id(maps:get(project, Session, environment)), + Ids = [TargetId | [Id || {Id, _N, _P} <- Frontier]], + case lists:all(fun(environment) -> true; + (Id) -> Id =:= Bound + end, Ids) of + true -> ok; + false -> {error, session_project_mismatch} + end. + %% find_path/3 — public convenience matching the query task spec API. find_path(From, To, MaxDepth) -> execute_query(#q_find_path{from = From, diff --git a/apps/graphdb/test/graphdb_query_SUITE.erl b/apps/graphdb/test/graphdb_query_SUITE.erl index afa2a79..1349a52 100644 --- a/apps/graphdb/test/graphdb_query_SUITE.erl +++ b/apps/graphdb/test/graphdb_query_SUITE.erl @@ -116,7 +116,10 @@ %% SP2 follow-up — Home routing for arc-discovered nrefs t1_env_only_path_identical_across_sessions/1, t2_shadowed_target_is_not_falsely_found/1, - t5_cross_store_edge_discloses_home/1 + t5_cross_store_edge_discloses_home/1, + t3_continuation_state_is_home_qualified/1, + t6_resume_round_trip_under_project_session/1, + resume_rejects_foreign_project_continuation/1 ]). suite() -> @@ -201,7 +204,10 @@ groups() -> {sp2_traversal_home_routing, [], [ t1_env_only_path_identical_across_sessions, t2_shadowed_target_is_not_falsely_found, - t5_cross_store_edge_discloses_home + t5_cross_store_edge_discloses_home, + t3_continuation_state_is_home_qualified, + t6_resume_round_trip_under_project_session, + resume_rejects_foreign_project_continuation ]}]. @@ -1041,6 +1047,86 @@ t5_cross_store_edge_discloses_home(_Config) -> ?assertMatch([#{from := X, via := ?ARC_INST_TO_CLASS, to := Cls, kind := instantiation, home := environment}], Path). +%%--------------------------------------------------------------------- +%% T3' -- Half B's state shape, asserted where it is observable. +%% (The design's original T3 -- visiting project-6 must not suppress +%% environment-6 -- is not constructible under this scope; see the +%% plan's Task 4 scope note.) +%%--------------------------------------------------------------------- +t3_continuation_state_is_home_qualified(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, A, _} = graphdb_instance:create_instance(Project, "T3A", Cls, + root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "T3B", Cls, A), + {ok, C, _} = graphdb_instance:create_instance(Project, "T3C", Cls, B), + {ok, D, _} = graphdb_instance:create_instance(Project, "T3D", Cls, C), + Anchor = maps:get(anchor, Project), + Q = #q_find_path{from = D, to = A, max_depth = 1, + arc_kinds = [composition]}, + {partial, _Best, Cont, _S1} = graphdb_query:execute_query( + Q, graphdb_query:new_session(Project)), + #cont_path{target = Target, visited = Visited, frontier = Frontier} = + Cont, + ?assertEqual({{project, Anchor}, A}, Target), + ?assert(lists:all(fun({_HomeId, N}) when is_integer(N) -> true; + (_) -> false + end, maps:keys(Visited))), + ?assert(lists:all(fun({_HomeId, N, P}) -> is_integer(N) + andalso is_list(P); + (_) -> false + end, Frontier)). + +%%--------------------------------------------------------------------- +%% T6 -- the new frontier/visited shapes survive a round trip through +%% #cont_path{}: partial + resume must equal an unbounded run. +%%--------------------------------------------------------------------- +t6_resume_round_trip_under_project_session(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, A, _} = graphdb_instance:create_instance(Project, "T6A", Cls, + root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "T6B", Cls, A), + {ok, C, _} = graphdb_instance:create_instance(Project, "T6C", Cls, B), + {ok, D, _} = graphdb_instance:create_instance(Project, "T6D", Cls, C), + Bounded = #q_find_path{from = D, to = A, max_depth = 2, + arc_kinds = [composition]}, + S0 = graphdb_query:new_session(Project), + {partial, _Best, Cont, S1} = graphdb_query:execute_query(Bounded, S0), + {ok, Resumed, _S2} = graphdb_query:resume(Cont, S1), + {ok, Direct, _S3} = graphdb_query:execute_query( + #q_find_path{from = D, to = A, max_depth = 9, + arc_kinds = [composition]}, + graphdb_query:new_session(Project)), + ?assertEqual(Direct, Resumed). + +%%--------------------------------------------------------------------- +%% A continuation carrying a project id the session is not bound to must +%% be rejected on the caller side, not carried into home_of_id/2 inside +%% the singleton. Same reasoning as validate_session_home/1: the pid +%% must be unchanged afterwards. +%%--------------------------------------------------------------------- +resume_rejects_foreign_project_continuation(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, A, _} = graphdb_instance:create_instance(Project, "TFA", Cls, + root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "TFB", Cls, A), + {ok, C, _} = graphdb_instance:create_instance(Project, "TFC", Cls, B), + {ok, D, _} = graphdb_instance:create_instance(Project, "TFD", Cls, C), + Q = #q_find_path{from = D, to = A, max_depth = 1, + arc_kinds = [composition]}, + S0 = graphdb_query:new_session(Project), + {partial, _, Cont, S1} = graphdb_query:execute_query(Q, S0), + {ok, OtherP} = graphdb_project:register_project("TF other"), + {ok, Other} = graphdb_project:open(OtherP), + OtherSession = S1#{project => Other, + snapshot_at => maps:get(snapshot_at, S1)}, + PidBefore = whereis(graphdb_query), + ?assertEqual({error, session_project_mismatch}, + graphdb_query:resume(Cont, OtherSession)), + ?assertEqual(PidBefore, whereis(graphdb_query)). + %%--------------------------------------------------------------------- %% proj() -> Project From 35ce78770156e05bb7cf7e16cae86d796ce10493 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 07:24:34 -0400 Subject: [PATCH 7/9] Docs: record query-traversal Home routing as implemented Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- TASKS.md | 37 ++++++++++++++++++------------------- apps/graphdb/CLAUDE.md | 19 +++++++++++++------ docs/Architecture.md | 7 +++++-- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/TASKS.md b/TASKS.md index e9ef68e..9d68746 100644 --- a/TASKS.md +++ b/TASKS.md @@ -533,7 +533,7 @@ SP1/SP2 without one). 15 call sites across 11 test functions in found zero production callers, so this is an API-completeness gap, not a live bug — pick it up when a project-side relationship-read caller appears. -**Open defect (Important) — query traversal silently truncates +**RESOLVED (query-traversal Home routing) — query traversal silently truncated environment-only paths under a project-bound session.** `graphdb_query:session_read_arcs/4` and `is_scaffold_node/2` push every *arc-discovered* nref through `resolve_home/2`, which was designed for @@ -552,24 +552,23 @@ shadows nref 6 and any with ≥35 shadows the whole bootstrap scaffold, so this is high-likelihood, not theoretical. The result is silently wrong: no error, only a collision warning in the log. -**Designed — see `docs/designs/query-traversal-home-routing-design.md`.** -Arc-discovered nrefs *do* have context, so the guess is replaced by a -deterministic lookup. Two halves: (A) a new pure -`graphdb_ns:arc_target_namespace(Home, Kind, Char)` routes on -`#relationship.kind`, with the 29/30 membership pair distinguished by -characterization; (B) the BFS frontier, visited set, and target comparison -become Home-qualified — without B, project-6 and environment-6 collapse into -one visited entry and a bare-nref target test reports a *false* `found`. -`resolve_home/2` and `session_read_arcs/4` are untouched; only -`bfs_step/5` changes caller, to the existing `session_read_arcs_home/5`. - -> **Superseded fix direction.** This entry previously said to route via -> `graphdb_ns:target_namespace/2` on the arc's `target_kind`. That does not -> work: bootstrap arc labels 21–30 carry **no `target_kind` AVP** -> (`bootstrap.terms:122-131` creates them with `[]`; `graphdb_attr:init/1` -> retro-stamps only `attribute_type`), and arcs 23/24 are the decisive hops -> in the repro above. Only runtime-created pairs carry `target_kind`. See -> the design doc's correction note. +**Implemented.** Half A: `graphdb_ns:arc_target_namespace(Home, Kind, Char)` +derives an arc-discovered nref's store from `#relationship.kind`, with the +29/30 membership pair split on characterization. Half B: the BFS frontier, +visited set, and target comparison are Home-qualified via +`home_id() :: environment | {project, Anchor}`; path edges disclose `home` +when a hop crosses stores; `resume/2` rejects a continuation carrying a +foreign project id. `resolve_home/2` and `session_read_arcs/4` are unchanged +— only `bfs_step/5` changed caller, to `session_read_arcs_home/5`. Design: +`docs/designs/query-traversal-home-routing-design.md`. + +Still open, deliberately out of that scope: BFS at an environment-homed node +reads only the environment relationship table, so an environment class cannot +reach its project instances across arc 30 (`?ARC_CLASS_TO_INST`). +`#q_instances_of{}` keeps its `session_read_arcs_home/5` special case. Also +still open: backfilling `target_kind` onto bootstrap arc labels 21–30, which +would let `graphdb_instance:check_target_kind/3` drop its permissive legacy +arm. **Open defect (Important, pre-existing, unrelated to SP2) — `rel_id_server:seed_from_mnesia/0` calls a nonexistent function.** diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index d2b9f1d..f875d67 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -17,7 +17,7 @@ SPDX-License-Identifier: GPL-2.0-or-later | `graphdb_sup.erl` | OTP `supervisor` behaviour callback module | | `graphdb_nref.erl` | Switchable node-nref allocation facade gen_server (first child; permanent during init) | | `graphdb_bootstrap.erl` | Bootstrap file loader + Mnesia schema creator (implemented) | -| `graphdb_ns.erl` | Pure namespace-resolution module (SP1+SP2) — `namespace_of/2`, `target_namespace/2` (home-relative), `node_table/1`, `rel_table/1`; the code expression of the field-role namespace map | +| `graphdb_ns.erl` | Pure namespace-resolution module (SP1+SP2) — `namespace_of/2`, `target_namespace/2` (home-relative), `node_table/1`, `rel_table/1`, `arc_target_namespace/3` (arc-discovered nref routing by `#relationship.kind`); the code expression of the field-role namespace map | | `graphdb_project.erl` | Project registry + physical store (SP1+SP2) — `register_project/1` (also creates the project's three tables), `is_project/1`, `open/1`, `require_project/1`, `next_nref/1`, `next_rel_id_pair/1`; canonical project-scoped relationship API surface | | `graphdb_mgr.erl` | Primary coordinator gen_server (implemented — bootstrap init, read API, category guard) | | `graphdb_rules.erl` | Graph rules gen_server (implemented — F4 Phase A+B1+B2+B3+B4+B5: rule meta-ontology, create/retrieve, taxonomy walk, composition firing, propose mode, connection firing, conflict precedence) | @@ -443,11 +443,18 @@ Parses and executes graph queries. Public API: Queries are represented as records defined in `apps/graphdb/include/graphdb_query.hrl`. Every Mnesia read goes through `session_read_node/2` or `session_read_arcs/4`; direct -`mnesia:dirty_*` calls outside those helpers are a code smell. Every -bare-nref read resolves its physical table via `resolve_home/2` (SP2): -try the session's bound `Project` first, fall back to the environment, -and log a warning if the nref genuinely exists in both — the project's -copy wins on the theory that a project-bound session is caller intent. +`mnesia:dirty_*` calls outside those helpers are a code smell. Bare-nref +**entry-point** reads (`#q_get_node{}`, `#q_get_arcs{}`, `#q_describe{}`, +and `#q_find_path{}`'s two endpoints) resolve `Home` via `resolve_home/2` +(SP2): try the session's bound `Project` first, fall back to the +environment, and log a warning if the nref genuinely exists in both — the +project's copy wins on the theory that a project-bound session is caller +intent. Arc-discovered nrefs found mid-traversal during BFS route +deterministically instead, via `graphdb_ns:arc_target_namespace/3` +(keyed on `#relationship.kind`, with the 29/30 membership pair split on +characterization) — no guessing. `#q_find_path{}` state (frontier, +visited set, target) is Home-qualified via `home_id()`, and a path edge +discloses `home` when a hop crosses stores. See `docs/designs/f3-graphdb-query-design.md` for the architectural contract. diff --git a/docs/Architecture.md b/docs/Architecture.md index 9318d16..c0f9055 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -28,7 +28,7 @@ SPDX-License-Identifier: GPL-2.0-or-later | `graphdb_instance` | Implemented — compositional hierarchy + four-level inheritance with multi-class membership and ambiguity-detecting class resolver; refuses instantiation/membership of abstract classes; refuses retired nodes as new instance targets, compositional parents, and arc endpoints; fires composition rules on `create_instance/4` and surfaces `proposed` outcomes for propose-mode rules; fires connection rules via a caller-supplied resolver on `create_instance/5`; applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/6`. SP2: every public function, including the instance reads, takes a leading `Project` handle — fully Project-routed (§6) | | `graphdb_rules` | Implemented — rule meta-ontology, applies_to attachment, scope-aware create/retrieve, taxonomy-walking effective-rules read, composition firing engine, propose mode, connection firing, horizontal conflict precedence | | `graphdb_language` | Implemented — multilingual overlay layer (label resolution, dialect chains, per-language Mnesia overlay tables) | -| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS. SP2: `new_session/1` binds a `Project`; bare-nref reads resolve `Home` via `resolve_home/2`. | +| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS. SP2: `new_session/1` binds a `Project`; bare-nref **entry-point** reads resolve `Home` via `resolve_home/2`, while arc-discovered nrefs during BFS route deterministically via `graphdb_ns:arc_target_namespace/3`. `#q_find_path{}` state is Home-qualified and path edges disclose `home` on a store crossing. | | Tests | 677 passing (532 Common Test + 145 EUnit) | The kernel is functional under multi-inheritance, multi-class- @@ -404,7 +404,10 @@ and SP2 (physical project store) are both implemented: - **`graphdb_ns`** — pure namespace-resolution module encoding the routing table above; `namespace_of/2` / `target_namespace/2` take a leading `Home` and resolve every nref field to `environment | Home`; `node_table/1` / - `rel_table/1` map a `Home` to its physical table atom. + `rel_table/1` map a `Home` to its physical table atom; `arc_target_namespace/3` + routes an arc-discovered nref by `#relationship.kind` (with the 29/30 + membership pair split on characterization) for deterministic mid-traversal + Home resolution. - **`graphdb_project`** — project registry and physical store. `register_project/1` creates the nref-5-child anchor node **and** the project's three Mnesia tables (`nodes_`, `relationships_`, From 8851647683357d32a5a01b14ae64224ddeae3fd7 Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 07:28:31 -0400 Subject: [PATCH 8/9] Docs: fix stale resolve_home/2 duplicates and test count after review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/CLAUDE.md | 12 +++++++----- docs/Architecture.md | 8 +++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index f875d67..04e825e 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -113,11 +113,13 @@ namespace model) and SP2 (physical project store) are both implemented: `update_node_avps`) stay environment-only; `mutate/1` in particular stays environment-only by design (a batch is inherently single-table once the physical split exists). -- **`graphdb_query` sessions bind a `Project`** — `new_session/1`. Every - bare-nref read resolves its `Home` via `resolve_home/2`: try the bound - project's table first, fall back to the environment, and log if the nref - genuinely exists in both (a real collision, resolved in the project's - favor on the theory that a project-bound session is evidence of intent). +- **`graphdb_query` sessions bind a `Project`** — `new_session/1`. + Bare-nref **entry-point** reads resolve their `Home` via `resolve_home/2`: + try the bound project's table first, fall back to the environment, and + log if the nref genuinely exists in both (a real collision, resolved in + the project's favor on the theory that a project-bound session is + evidence of intent). Arc-discovered nrefs during BFS route + deterministically instead, via `graphdb_ns:arc_target_namespace/3`. - **Proxy contract** — cross-project links are local nodes of the seeded "Remote Reference" class carrying `remote_project` / `remote_nref` AVP payload; no structural reference crosses a project boundary. Recognized by diff --git a/docs/Architecture.md b/docs/Architecture.md index c0f9055..3ccca0a 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -29,7 +29,7 @@ SPDX-License-Identifier: GPL-2.0-or-later | `graphdb_rules` | Implemented — rule meta-ontology, applies_to attachment, scope-aware create/retrieve, taxonomy-walking effective-rules read, composition firing engine, propose mode, connection firing, horizontal conflict precedence | | `graphdb_language` | Implemented — multilingual overlay layer (label resolution, dialect chains, per-language Mnesia overlay tables) | | `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS. SP2: `new_session/1` binds a `Project`; bare-nref **entry-point** reads resolve `Home` via `resolve_home/2`, while arc-discovered nrefs during BFS route deterministically via `graphdb_ns:arc_target_namespace/3`. `#q_find_path{}` state is Home-qualified and path edges disclose `home` on a store crossing. | -| Tests | 677 passing (532 Common Test + 145 EUnit) | +| Tests | 701 passing (550 Common Test + 151 EUnit) | The kernel is functional under multi-inheritance, multi-class- membership, and per-class template semantics. Multilingual label @@ -436,8 +436,10 @@ and SP2 (physical project store) are both implemented: against once the store is split. `get_relationships` has no Project-taking twin yet (tracked in `../TASKS.md`). - **`graphdb_query` sessions bind a `Project` (SP2)** — `new_session/1`; - bare-nref reads resolve `Home` per nref via `resolve_home/2`, trying the - bound project first and falling back to the environment. + bare-nref **entry-point** reads resolve `Home` via `resolve_home/2`, + trying the bound project first and falling back to the environment; + arc-discovered nrefs during BFS route deterministically instead, via + `graphdb_ns:arc_target_namespace/3`. - **Proxy contract** — a cross-project link is a local node of the seeded "Remote Reference" class carrying `remote_project` / `remote_nref` AVP payload; no structural reference crosses a project boundary. Recognized by From 2450d61f15314c5e2fa8b31fafb805a56d5cb44c Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 9 Aug 2026 07:45:39 -0400 Subject: [PATCH 9/9] Final review fix wave: docs corrections, dead-code removal, hardened frontier gate Six findings from the whole-branch final review: 1. docs/Architecture.md: correct the false claim that bootstrap arc labels 21-30 carry target_kind (they don't -- only runtime-created relationship- attribute pairs do); note arc-discovered traversal routes via graphdb_ns:arc_target_namespace/3 instead, and that target_namespace/2 has no production caller. 2. graphdb_query.erl: rewrite resume/2's stale header comment -- it no longer dispatches through resolve_home/2; validate_session_home/1 is still load-bearing because validate_cont_homes/2 depends on it running first. 3. apps/graphdb/CLAUDE.md: document resume/2's {error, invalid_project} and {error, session_project_mismatch} returns alongside the existing {error, snapshot_expired}. 4. graphdb_query.erl: validate_cont_homes/2 folds a frontier-element shape check into its lists:all/2 pass, closing a gap where a wrong-arity frontier tuple was silently dropped by the old list-comprehension generator instead of failing the gate. Adds resume_rejects_malformed_frontier_continuation/1 to graphdb_query_SUITE's sp2_traversal_home_routing group, asserting both the clean error and that the graphdb_query singleton pid survives. 5. graphdb_ns.erl: Rev PA3 revision-history entry for arc_target_namespace/3. 6. graphdb_query.erl: remove the dead `[] -> []` arm in bfs/8's depth-exhausted clause -- unreachable because the first bfs/8 clause already matches an empty frontier for any depth. Verification: rebar3 compile / as test compile zero warnings, rebar3 xref clean, make test-ct-parallel 551/551, rebar3 eunit 151/151. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/CLAUDE.md | 5 ++- apps/graphdb/src/graphdb_ns.erl | 4 +++ apps/graphdb/src/graphdb_query.erl | 40 ++++++++++++++++------- apps/graphdb/test/graphdb_query_SUITE.erl | 38 +++++++++++++++++++-- docs/Architecture.md | 14 +++++--- 5 files changed, 83 insertions(+), 18 deletions(-) diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index 04e825e..ec916cc 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -439,7 +439,10 @@ Parses and executes graph queries. Public API: - `new_session/0`, `new_session/1`, `refresh/1` — snapshot-semantics session lifecycle. `new_session/1` binds the session to a `Project`; `new_session/0` stays environment-only. - `execute_query/1`, `execute_query/2` — ephemeral and session-threaded - `resume/2` — continue a `#cont_path{}` (returns - `{error, snapshot_expired}` if the session has been refreshed since) + `{error, snapshot_expired}` if the session has been refreshed since, + `{error, invalid_project}` for a malformed session `Project` handle, or + `{error, session_project_mismatch}` for a continuation whose target/frontier + carry a home id foreign to the session) - `find_path/3` — convenience wrapper for `#q_find_path{}` Queries are represented as records defined in diff --git a/apps/graphdb/src/graphdb_ns.erl b/apps/graphdb/src/graphdb_ns.erl index 6d81ea4..7890a55 100644 --- a/apps/graphdb/src/graphdb_ns.erl +++ b/apps/graphdb/src/graphdb_ns.erl @@ -19,6 +19,10 @@ %% SP2: home-relative routing. namespace_of/1 and target_namespace/1 %% replaced by /2 forms taking a Home (environment | project handle). %% node_table/1 and rel_table/1 added. +%% Rev PA3 Date: 2026-08-09 Author: David W. Thomas +%% arc_target_namespace/3 added -- routes an arc-discovered nref's Home +%% off #relationship.kind/characterization instead of target_kind, so +%% graphdb_query's BFS can stop re-guessing Home mid-traversal. %%--------------------------------------------------------------------- -module(graphdb_ns). diff --git a/apps/graphdb/src/graphdb_query.erl b/apps/graphdb/src/graphdb_query.erl index 3ab1a8d..d206438 100644 --- a/apps/graphdb/src/graphdb_query.erl +++ b/apps/graphdb/src/graphdb_query.erl @@ -179,8 +179,13 @@ execute_query(Query, Session) when is_map(Session) -> %% resume(Cont, Session) -> {ok, _, _} | {partial, _, _, _} | {error, _} %% -%% Same gate as execute_query/2 above -- resume/2 also dispatches Session -%% straight into a handle_call that resolves bare nrefs via resolve_home/2. +%% Same gate as execute_query/2 above, but load-bearing for a different +%% reason here: resume/2 never calls resolve_home/2 (bfs_step/5 uses +%% home_of_id/2 + session_read_arcs_home/5, and is_scaffold_node/2 takes an +%% already-resolved Home). validate_session_home/1 must still run first, +%% though -- it has to complete before validate_cont_homes/2 below, whose +%% home_id(maps:get(project, Session, environment)) would function_clause +%% on a malformed Project handle. resume(Cont, Session) when is_map(Session) -> case validate_session_home(Session) of ok -> @@ -219,13 +224,26 @@ validate_session_home(Session) -> %% Only the target and the frontier are checked. Visited keys are never %% resolved -- they are compared, and a foreign id there can only ever %% fail to match, which is harmless. +%% +%% FrontierOk/1 folds a shape check into the same lists:all/2 pass: a +%% frontier element of the wrong arity does not match {Id, _N, _P} and +%% falls to the catch-all `false` clause, rather than being silently +%% dropped by a list-comprehension generator (as a plain `[Id || {Id, _N, +%% _P} <- Frontier]` would do) and passing the gate unchecked. #cont_path{} +%% is defined in a public header and resume/2's Cont argument is +%% unguarded, so a malformed frontier element must fail closed here -- +%% bfs_step/5's fold would otherwise function_clause deep inside this +%% gen_server's singleton process. validate_cont_homes(#cont_path{target = {TargetId, _Nref}, frontier = Frontier}, Session) -> Bound = home_id(maps:get(project, Session, environment)), - Ids = [TargetId | [Id || {Id, _N, _P} <- Frontier]], - case lists:all(fun(environment) -> true; - (Id) -> Id =:= Bound - end, Ids) of + HomeOk = fun(environment) -> true; + (Id) -> Id =:= Bound + end, + FrontierOk = fun({Id, _N, _P}) -> HomeOk(Id); + (_Malformed) -> false + end, + case HomeOk(TargetId) andalso lists:all(FrontierOk, Frontier) of true -> ok; false -> {error, session_project_mismatch} end. @@ -919,11 +937,11 @@ all_subclasses(C) -> bfs(_Snap, _ToKey, _Budget, _D, _Kinds, _Vis, [], Session) -> {{ok, no_path}, Session}; bfs(Snap, ToKey, Budget, 0, Kinds, Vis, Frontier, Session) -> - %% Depth exhausted but frontier non-empty -- partial. - BestSoFar = case Frontier of - [{_HomeId, _Nref, P} | _] -> P; - [] -> [] - end, + %% Depth exhausted but frontier non-empty -- partial. The first bfs/8 + %% clause above already matches an empty Frontier (any D) and returns + %% before this clause is reached, so Frontier is guaranteed non-empty + %% here; no `[] -> []` fallback arm is reachable. + [{_HomeId, _Nref, BestSoFar} | _] = Frontier, Cont = #cont_path{snapshot_at = Snap, target = ToKey, arc_kinds = Kinds, diff --git a/apps/graphdb/test/graphdb_query_SUITE.erl b/apps/graphdb/test/graphdb_query_SUITE.erl index 1349a52..0cf5656 100644 --- a/apps/graphdb/test/graphdb_query_SUITE.erl +++ b/apps/graphdb/test/graphdb_query_SUITE.erl @@ -119,7 +119,8 @@ t5_cross_store_edge_discloses_home/1, t3_continuation_state_is_home_qualified/1, t6_resume_round_trip_under_project_session/1, - resume_rejects_foreign_project_continuation/1 + resume_rejects_foreign_project_continuation/1, + resume_rejects_malformed_frontier_continuation/1 ]). suite() -> @@ -207,7 +208,8 @@ groups() -> t5_cross_store_edge_discloses_home, t3_continuation_state_is_home_qualified, t6_resume_round_trip_under_project_session, - resume_rejects_foreign_project_continuation + resume_rejects_foreign_project_continuation, + resume_rejects_malformed_frontier_continuation ]}]. @@ -1127,6 +1129,38 @@ resume_rejects_foreign_project_continuation(_Config) -> graphdb_query:resume(Cont, OtherSession)), ?assertEqual(PidBefore, whereis(graphdb_query)). +%%--------------------------------------------------------------------- +%% A continuation whose frontier carries a malformed (wrong-arity) element +%% must be rejected on the caller side too, not just a foreign home id. +%% Pre-fix, `[Id || {Id, _N, _P} <- Frontier]` silently dropped the bad +%% element instead of failing the gate; a hand-built continuation with a +%% valid target and one malformed frontier tuple would then pass +%% validate_cont_homes/2 and function_clause inside bfs_step/5's fold -- +%% INSIDE the graphdb_query singleton, taking it down for every session in +%% the VM. The pid assertion is the point: it proves the singleton +%% survived. +%%--------------------------------------------------------------------- +resume_rejects_malformed_frontier_continuation(_Config) -> + Project = proj(), + Cls = widget_class(), + {ok, A, _} = graphdb_instance:create_instance(Project, "TMA", Cls, + root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "TMB", Cls, A), + {ok, C, _} = graphdb_instance:create_instance(Project, "TMC", Cls, B), + {ok, D, _} = graphdb_instance:create_instance(Project, "TMD", Cls, C), + Q = #q_find_path{from = D, to = A, max_depth = 1, + arc_kinds = [composition]}, + S0 = graphdb_query:new_session(Project), + {partial, _, Cont, S1} = graphdb_query:execute_query(Q, S0), + #cont_path{frontier = Frontier} = Cont, + [{HomeId, Nref, _Path} | Rest] = Frontier, + CorruptFrontier = [{HomeId, Nref} | Rest], + CorruptCont = Cont#cont_path{frontier = CorruptFrontier}, + PidBefore = whereis(graphdb_query), + ?assertEqual({error, session_project_mismatch}, + graphdb_query:resume(CorruptCont, S1)), + ?assertEqual(PidBefore, whereis(graphdb_query)). + %%--------------------------------------------------------------------- %% proj() -> Project diff --git a/docs/Architecture.md b/docs/Architecture.md index 3ccca0a..a91bdef 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -352,10 +352,16 @@ that `Home` — not the nref's numeric value — selects the physical table. | `characterization`, `reciprocal` | Always the environment | | `target_nref` | Routed by the arc label's `target_kind` AVP: `category`/`attribute`/`class` → always the environment; `instance` → `Home` | -`target_kind :: category | attribute | class | instance` is stored as a -literal AVP on every arc-label attribute node. Built-in arc labels -(nrefs 21–30) carry it; `graphdb_attr:create_relationship_attribute_pair/4` -requires it for runtime additions. +`target_kind :: category | attribute | class | instance` is a literal AVP +that `graphdb_attr:create_relationship_attribute_pair/4` requires on every +**runtime-created** relationship-attribute pair. The ten built-in arc +labels (nrefs 21–30) do **not** carry it — `graphdb_attr:init/1` retro-stamps +only `attribute_type` on the bootstrap scaffold — so consumers must tolerate +its absence rather than assume it; `target_namespace/2` currently has no +production caller for this reason (only its own EUnit suite exercises it). +Arc-discovered traversal (`graphdb_query`'s bounded BFS) instead routes +on `#relationship.kind`/`characterization` via +`graphdb_ns:arc_target_namespace/3`, which needs no `target_kind` lookup. This routing table is the code contract of the pure module `graphdb_ns` (`namespace_of/2`, `target_namespace/2` — both `Home`-first — plus