diff --git a/CLAUDE.md b/CLAUDE.md index 74ce363..57d74cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,7 @@ nref spaces: - **Environment**: scaffold nrefs 1–35; permanent tier `[?LABEL_START, ?NREF_START)` = `[10001, 1000000)` holds English (10000), loader-assigned atom-labeled bootstrap nodes, and worker `init/1` seeds (graphdb_attr, graphdb_language sub-groups); runtime allocations ≥ `?NREF_START` (1000000). Boundaries are macros in `apps/graphdb/include/graphdb_nrefs.hrl` — **not** directives in `bootstrap.terms`. All graphdb node-nref allocation goes through `graphdb_nref` (first child of `graphdb_sup`): permanent phase during init, runtime phase after the `graphdb:start/2` flip. - **Project**: allocator starts at **1** — no pre-assigned nrefs, no bootstrap file, no floor needed -Cross-database nref resolution: `characterization` and `reciprocal` fields always reference environment nrefs; `target_nref` is routed to environment or project based on the arc label's `target_kind` AVP stored in the environment attribute library. +Cross-database nref resolution: `characterization` and `reciprocal` fields always reference environment nrefs; `target_nref` is routed to environment or project based on the arc label's `target_kind` AVP stored in the environment attribute library. This routing is encoded by the pure `graphdb_ns` module (SP1). A project is an anchor node under `Projects` (nref 5); project write operations require a `graphdb_project` session (SP1 — see `docs/designs/project-env-reference-namespace-model-design.md` and `TASKS.md` → *Multi-project sessions*). SP1 is behaviour-preserving against today's single store; physical per-project storage is SP2. ### Bootstrap Nref Quick-Reference (BFS, nrefs 1–35) diff --git a/TASKS.md b/TASKS.md index 9ae69e4..345e490 100644 --- a/TASKS.md +++ b/TASKS.md @@ -425,19 +425,59 @@ resolver is supplied via `create_instance/4`. ## Multi-project sessions -Every public API already accepts a `Scope` of `environment | {project, -_}`; the handlers serve the `environment` scope only and reject or empty -`{project, _}` requests. This area turns project scope on: - -- Session state carrying a list of `{ProjectId, AnchorNref}` (a list, - not a singleton). -- Cross-project arc traversal — an arc whose target is a project nref - carries `target_kind` but not *which* project; the session must supply - that context. -- Session-level priority resolution — environment first, then project - A's rules, then project B's, or a declared order. -- Project-scoped overlay tables for rule instances and for language - labels (`language__`). +This is a four-sub-project program (design: +`docs/designs/project-env-reference-namespace-model-design.md`). SP1 (the +reference & namespace model) is done; SP2–SP4 remain. + +### SP1 — reference & namespace model — IMPLEMENTED + +At the API/code layer only, no `node`/`relationship` record changes: + +- **`graphdb_ns`** — pure module encoding the field-role namespace map + (`namespace_of/1`, `target_namespace/1`): every nref reference resolves to + `environment | project | home`. The code expression of design §3. +- **`graphdb_project`** — project registry (`register_project/1`, + `is_project/1`) creating an anchor node under `Projects` (nref 5); project + session (`open_session/1`, `session_project/1`, `require_session/1`); and + the canonical project-scoped relationship API surface (env/project split). +- **Proxy representation contract** — a seeded "Remote Reference" class (under + Classes, nref 3) + `remote_project` / `remote_nref` literal attributes + + `graphdb_instance:is_proxy/1` / `proxy_coordinates/1` recognizers. Cross- + project links are local proxy nodes carrying remote coordinates as AVP + payload; no structural reference ever crosses a project boundary. +- **Required project session on the project write path** — `create_instance`, + `add_relationship`, `remove_relationship`, `update_relationship`(`_both`), + and `add_class_membership` take a `Session` first arg and reject a missing/ + invalid one with `{error, invalid_session}`. Behaviour-preserving against + today's single store (the session is validated but inert until SP2). + +**SP1 deliberate deferrals (to SP2+):** + +- `mutate/1` and the instance reads (`get_instance` / `children` / + `compositional_ancestors` / `resolve_value`) stay **namespace-agnostic** in + SP1 — like `get_node` / `get_relationships`. `mutate/1` is mixed env/project + (a project session would over-constrain env-only batches); the reads are + consumed by `graphdb_query`, so gating them would force the deferred + query-session unification. Their per-namespace routing lands in SP2. +- `proxy_coordinates/1` assumes a well-formed proxy (both AVPs present); it can + badmatch on a malformed proxy. Harmless until SP2 adds proxy **creation**; + handle the missing-AVP case there. +- Proxy-node creation API and dereference; private environment overlays + (a private overlay hiding a project's nref-5 anchor); session unification + with the `graphdb_query` session. + +### SP2+ — turning project scope on + +- **Physical project store (SP2)** — separate Mnesia table set / schema per + project; per-project allocator from 1; the resolution seam gains real + env-vs-project routing; the session binds to physical storage. +- **Distribution & residency (SP3)** — projects on separate nodes / locations; + environment reachability or replication at each location; proxy dereference. +- **Migration (SP4)** — move existing instances out of the shared environment + tables into project storage; reassign their nrefs. +- Session state may carry multiple `{ProjectId, AnchorNref}` and a + cross-project priority order; project-scoped overlay tables for rule + instances and language labels (`language__`). **Open question — multi-class instance creation.** `create_instance` stays single-class: one primary driving class. Additional class diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index 0c52a21..290ad61 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -11,19 +11,21 @@ SPDX-License-Identifier: GPL-2.0-or-later ## Files -| File | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `graphdb.erl` | OTP `application` behaviour callback module; performs permanent→runtime phase flip | -| `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_mgr.erl` | Primary coordinator gen_server (implemented — bootstrap init, read API, category guard) | +| File | Description | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphdb.erl` | OTP `application` behaviour callback module; performs permanent→runtime phase flip | +| `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) — `namespace_of/1`, `target_namespace/1`; the code expression of the field-role namespace map | +| `graphdb_project.erl` | Project registry + project session (SP1) — `register_project/1`, `is_project/1`, `open_session/1`, `session_project/1`, `require_session/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) | -| `graphdb_attr.erl` | Attribute library gen_server (implemented) | -| `graphdb_class.erl` | Taxonomic hierarchy gen_server (implemented) | -| `graphdb_instance.erl` | Instance/compositional hierarchy gen_server (implemented) | -| `graphdb_language.erl` | M6 multilingual overlay layer (implemented) | -| `graphdb_query.erl` | F3 query language gen_server (implemented) | +| `graphdb_attr.erl` | Attribute library gen_server (implemented) | +| `graphdb_class.erl` | Taxonomic hierarchy gen_server (implemented) | +| `graphdb_instance.erl` | Instance/compositional hierarchy gen_server (implemented) | +| `graphdb_language.erl` | M6 multilingual overlay layer (implemented) | +| `graphdb_query.erl` | F3 query language gen_server (implemented) | `apps/graphdb/priv/bootstrap.terms` — Erlang Terms file fully written; contains 38 nodes (nrefs 1–35 scaffold, nref 10000 English, 2 atom-labeled nodes) and hierarchy relationship pairs. Loaded at first ontology startup. Tier boundaries are macros in `graphdb_nrefs.hrl` — no `{nref_start}` or `{label_start}` directives. @@ -70,6 +72,38 @@ nref spaces: Cross-database nref resolution: `characterization` and `reciprocal` fields always reference environment nrefs; `target_nref` is routed to environment or project based on the arc label's `target_kind` AVP. +### Reference & namespace model (SP1) + +The environment/project separation is being built as a four-sub-project +program (design: `../../docs/designs/project-env-reference-namespace-model-design.md`; +tracking: `../../TASKS.md` → *Multi-project sessions*). SP1 (reference & +namespace model) is implemented at the **API/code layer only — no +`node`/`relationship` record changes**: + +- **Namespace map** — `graphdb_ns:namespace_of/1` / `target_namespace/1` + encode which store each nref field resolves against + (`environment | project | home`). Pure; the code expression of the design's + field-role table. +- **Project identity + session** — a project is an anchor node under `Projects` + (nref 5), created by `graphdb_project:register_project/1`. A project + operation carries a `Session` (`graphdb_project:open_session/1`), validated + by `require_session/1`. Sessions are opaque values threaded as data (the + workers are shared singletons, so project context cannot be ambient). +- **Required session on the project write path** — `create_instance`, + `add_relationship`, `remove_relationship`, `update_relationship`(`_both`), + and `add_class_membership` take `Session` as the first argument and reject a + missing/invalid one with `{error, invalid_session}`. +- **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 + `graphdb_instance:is_proxy/1` / `proxy_coordinates/1`. Representation only — + creation/dereference are SP2/SP3. +- **Namespace-agnostic in SP1** — `mutate/1` and the instance reads + (`get_instance` / `children` / `compositional_ancestors` / `resolve_value`), + like `get_node` / `get_relationships`, are NOT session-gated: `mutate/1` is + mixed env/project, and the reads are consumed by `graphdb_query`. Their + routing lands in SP2. Behaviour is unchanged against today's single store. + --- ## Knowledge Model @@ -237,7 +271,7 @@ the ontology `nodes` Mnesia table with `kind = attribute`. - `create_relationship_attribute_pair/3,4` (name, reciprocal_name, target_kind [, parent_nref]) — reciprocal arc-label pair; `target_kind :: category | attribute | class | instance`; defaults parent to nref 8 - All creators validate `parent_nref` (must be an existing `kind=attribute` node) - `get_attribute/1`, `list_attributes/0`, `list_relationship_types/0` -- At bootstrap: seeds the `Attribute Literals` sub-group under the `Literals` subtree (nref 7), then seeds `literal_type`, `target_kind`, `relationship_avp`, `attribute_type`, and `instantiable` literal attributes as children of that sub-group. Also stamps the `relationship_avp` marker AVP on the bootstrap Template node and retro-stamps `attribute_type` AVPs across the Attributes subtree. The `instantiable` marker (L9) is stamped on a class node as `instantiable => false` to make it abstract (non-instantiable). +- At bootstrap: seeds the `Attribute Literals` sub-group under the `Literals` subtree (nref 7), then seeds `literal_type`, `target_kind`, `relationship_avp`, `attribute_type`, `instantiable`, and the SP1 proxy literals `remote_project` / `remote_nref` as children of that sub-group. Also stamps the `relationship_avp` marker AVP on the bootstrap Template node and retro-stamps `attribute_type` AVPs across the Attributes subtree. The `instantiable` marker (L9) is stamped on a class node as `instantiable => false` to make it abstract (non-instantiable). ### `graphdb_class` — Taxonomic Hierarchy @@ -276,15 +310,15 @@ Manages the "is a" hierarchy of class nodes in the ontology. Creates and manages instance nodes in the project (instance space). -- `create_instance/3,4,5` (name, class_nref, compositional_parent_nref [, connection_resolver [, conflict_resolver]]) — atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30), then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/4` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/3` uses the built-in `report_only` (defer-all) connection resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. `/5` threads a B5 **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/3` and `/4` inject the built-in `graphdb_rules:default_conflict_resolver/0`, which shadows conflicting inherited rules (nearest-level winner by mode priority), merges multiplicity (nearest Min, greatest Max), and demotes both-real-template losers to `propose` (F4 B5). -- `add_relationship/4,5,6` (source_nref, characterization_nref, target_nref, reciprocal_nref [, template_nref [, {FwdAVPs, RevAVPs}]]) — validates endpoints, resolves source/target class and template scope, and writes the two directed `kind=connection` rows in a **single** `graphdb_mgr:transaction/1` (TOCTOU-isolated). The rel-id pair is allocated up-front (outside the transaction) via `rel_id_server:get_id_pair/0`. `/4` uses the source class's default template; `/5` takes an explicit template nref; `/6` adds per-direction AVPs. +- `create_instance/4,5,6` (**session**, name, class_nref, compositional_parent_nref [, connection_resolver [, conflict_resolver]]) — requires a valid project session (SP1); atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30), then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/4` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/3` uses the built-in `report_only` (defer-all) connection resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. `/5` threads a B5 **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/3` and `/4` inject the built-in `graphdb_rules:default_conflict_resolver/0`, which shadows conflicting inherited rules (nearest-level winner by mode priority), merges multiplicity (nearest Min, greatest Max), and demotes both-real-template losers to `propose` (F4 B5). +- `add_relationship/5,6,7` (**session**, source_nref, characterization_nref, target_nref, reciprocal_nref [, template_nref [, {FwdAVPs, RevAVPs}]]) — requires a valid project session (SP1); validates endpoints, resolves source/target class and template scope, and writes the two directed `kind=connection` rows in a **single** `graphdb_mgr:transaction/1` (TOCTOU-isolated). The rel-id pair is allocated up-front (outside the transaction) via `rel_id_server:get_id_pair/0`. `/4` uses the source class's default template; `/5` takes an explicit template nref; `/6` adds per-direction AVPs. - `add_relationship_in_txn/9` (IdPair, S, C, T, R, TemplateSpec, AVPSpec, TkAttr, RetAttr) — tier-1 **in-transaction** primitive (bare-mnesia twin of `add_relationship`'s transaction body; aborts on failure, never opens its own txn). The caller allocates the rel-id pair up-front. `do_add_relationship/7` (tier-2) and `graphdb_mgr:mutate/1` (tier-3) both compose it into their single transaction. -- `remove_relationship/3,4` (source, char, target [, template]) — deletes +- `remove_relationship/4,5` (**session**, source, char, target [, template]) — deletes **both** directed rows of a logical connection edge atomically (connection-arcs only; no cache work). `/3` ignores template; `/4` narrows by it. Identity contract: zero matches → `{error, relationship_not_found}`, @@ -293,8 +327,8 @@ Creates and manages instance nodes in the project (instance space). aborts `{error, {dangling_half_edge, Id}}` (never deletes a half-edge). Tier-1 `remove_relationship_in_txn/4` + shared `resolve_forward_connection/4` resolver are the in-txn primitives (slice E). -- `update_relationship/4,5` + `update_relationship_both/4,5` (source, char, - target [, template], Updates | {Fwd, Rev}) — AVP-only edit of an existing +- `update_relationship/5,6` + `update_relationship_both/5,6` (**session**, + source, char, target [, template], Updates | {Fwd, Rev}) — AVP-only edit of an existing connection edge, reusing slice B's `validate_avp_updates/1` + `apply_avp_updates/2`. **Remove is edge-level; AVP update is directed-row-level**: `update_relationship` edits the single row named by @@ -303,8 +337,9 @@ Creates and manages instance nodes in the project (instance space). primitive `update_relationship_avps_in_txn/5` twice. The `?ARC_TEMPLATE` scope AVP is protected from edit. Same not-found/ambiguity arms as remove (slice E). -- `add_class_membership/2` (instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9) -- `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2` +- `add_class_membership/3` (**session**, instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9) +- `is_proxy/1`, `proxy_coordinates/1` (SP1) — recognize a Remote Reference proxy node and extract its `{remote_project, remote_nref}` coordinates; `remote_reference_class/0` returns the seeded class nref +- `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2` — reads; **not** session-gated in SP1 (namespace-agnostic, consumed by `graphdb_query`; routing deferred to SP2) ### `graphdb_rules` — Graph Rules (F4 Phase A + B1 + B2 + B3 + B4 + B5) @@ -393,6 +428,9 @@ See `docs/designs/f3-graphdb-query-design.md` for the architectural contract. Single public entry point; delegates to the five specialized workers. +- `create_instance/4` and `add_relationship/5` take a project `Session` first + arg (SP1) and reject a missing/invalid one via + `graphdb_project:require_session/1`, delegating to `graphdb_instance`. - In `init/1`: checks if `nodes` table is empty; if so, calls `graphdb_bootstrap:load/0` - Rejects any runtime request to create, modify, or delete a `category` node with `{error, category_nodes_are_immutable}` - Sequences Nref allocation → record write → Nref confirmation @@ -402,7 +440,9 @@ Single public entry point; delegates to the five specialized workers. mutations atomically in one `transaction/1` (all commit or none). Tagged-tuple grammar; opaque bare-reason contract `{ok, [ok, ...]}` | `{error, Reason}` with whole-batch rollback; `mutate([]) -> {ok, []}`. A **plain function**, not - a `gen_server:call` — it owns the transaction in the caller's process. See + a `gen_server:call` — it owns the transaction in the caller's process. + **Not session-gated in SP1** (a batch is mixed env/project; a project session + would over-constrain env-only batches — routing deferred to SP2). See `docs/designs/batch-mutate-design.md`. - `update_node_avps/2` — merges a list of AVP updates onto a node atomically through the transaction seam (tier-2 wrapper owning one `transaction/1`; diff --git a/apps/graphdb/src/graphdb_attr.erl b/apps/graphdb/src/graphdb_attr.erl index c476069..70963f7 100644 --- a/apps/graphdb/src/graphdb_attr.erl +++ b/apps/graphdb/src/graphdb_attr.erl @@ -107,7 +107,9 @@ relationship_avp_nref, %% integer() -- seeded literal attribute attribute_type_nref, %% integer() -- seeded literal attribute instantiable_nref, %% integer() -- seeded marker literal attribute - retired_nref %% integer() -- seeded `retired` lifecycle marker + retired_nref, %% integer() -- seeded `retired` lifecycle marker + remote_project_nref, %% integer() -- seeded `remote_project` proxy literal + remote_nref_nref %% integer() -- seeded `remote_nref` proxy literal }). @@ -348,18 +350,22 @@ init([]) -> relationship_avp_nref = ensure_seed("relationship_avp", AttrLitNref), attribute_type_nref = ensure_seed("attribute_type", AttrLitNref), instantiable_nref = ensure_seed("instantiable", AttrLitNref), - retired_nref = ensure_seed("retired", AttrLitNref) + retired_nref = ensure_seed("retired", AttrLitNref), + remote_project_nref = ensure_seed("remote_project", AttrLitNref), + remote_nref_nref = ensure_seed("remote_nref", AttrLitNref) }, ok = ensure_template_avp_marker(State#state.relationship_avp_nref), ok = retro_stamp_bootstrap_attribute_types( State#state.attribute_type_nref), logger:info("graphdb_attr: started (attribute_literals_group=~p, " "literal_type=~p, target_kind=~p, relationship_avp=~p, " - "attribute_type=~p, instantiable=~p, retired=~p)", + "attribute_type=~p, instantiable=~p, retired=~p, " + "remote_project=~p, remote_nref=~p)", [AttrLitNref, State#state.literal_type_nref, State#state.target_kind_nref, State#state.relationship_avp_nref, State#state.attribute_type_nref, State#state.instantiable_nref, - State#state.retired_nref]), + State#state.retired_nref, State#state.remote_project_nref, + State#state.remote_nref_nref]), {ok, State} catch throw:{error, Reason} -> @@ -420,7 +426,9 @@ handle_call(seeded_nrefs, _From, State) -> relationship_avp => State#state.relationship_avp_nref, attribute_type => State#state.attribute_type_nref, instantiable => State#state.instantiable_nref, - retired => State#state.retired_nref + retired => State#state.retired_nref, + remote_project => State#state.remote_project_nref, + remote_nref => State#state.remote_nref_nref }}, {reply, Reply, State}; diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index 497bb0c..f3973db 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -101,12 +101,15 @@ }). -record(state, { - target_kind_avp_nref, %% integer() -- nref of the seeded `target_kind` - %% literal-attribute, cached from graphdb_attr - %% at init time and used by add_relationship - %% validation. - instantiable_nref, %% integer() -- seeded `instantiable` marker - retired_nref %% integer() -- seeded `retired` marker + target_kind_avp_nref, %% integer() -- nref of the seeded `target_kind` + %% literal-attribute, cached from graphdb_attr + %% at init time and used by add_relationship + %% validation. + instantiable_nref, %% integer() -- seeded `instantiable` marker + retired_nref, %% integer() -- seeded `retired` marker + remote_reference_class_nref, %% integer() -- seeded "Remote Reference" class + remote_project_nref, %% integer() -- seeded `remote_project` literal + remote_nref_nref %% integer() -- seeded `remote_nref` literal }). @@ -119,26 +122,26 @@ -export([ start_link/0, %% Creators - create_instance/3, create_instance/4, create_instance/5, - add_relationship/4, + create_instance/6, add_relationship/5, add_relationship/6, - add_class_membership/2, + add_relationship/7, + add_class_membership/3, %% Tier-1 in-transaction primitive (write-path seam) add_relationship_in_txn/9, - remove_relationship/3, remove_relationship/4, + remove_relationship/5, remove_relationship_in_txn/4, resolve_forward_connection/4, template_of/1, - update_relationship/4, update_relationship/5, + update_relationship/6, update_relationship_avps_in_txn/5, has_template_update/1, - update_relationship_both/4, update_relationship_both/5, + update_relationship_both/6, update_relationship_both_in_txn/6, %% Lookups get_instance/1, @@ -147,7 +150,11 @@ class_of/1, class_memberships/1, %% Inheritance - resolve_value/2 + resolve_value/2, + %% Proxy recognizer + remote_reference_class/0, + is_proxy/1, + proxy_coordinates/1 ]). %%--------------------------------------------------------------------- @@ -197,8 +204,8 @@ start_link() -> %% - instance→class membership arc pair (char=29/30) %% - compositional parent→child arc pair (char=28/27) %%----------------------------------------------------------------------------- -create_instance(Name, ClassNref, ParentNref) -> - create_instance(Name, ClassNref, ParentNref, fun report_only/1). +create_instance(Session, Name, ClassNref, ParentNref) -> + create_instance(Session, Name, ClassNref, ParentNref, fun report_only/1). %%----------------------------------------------------------------------------- %% create_instance(Name, ClassNref, ParentNref, ConnResolver) -> @@ -209,9 +216,9 @@ create_instance(Name, ClassNref, ParentNref) -> %% outcome and nothing is connected. /4 supplies the built-in default %% conflict resolver. %%----------------------------------------------------------------------------- -create_instance(Name, ClassNref, ParentNref, ConnResolver) +create_instance(Session, Name, ClassNref, ParentNref, ConnResolver) when is_function(ConnResolver, 1) -> - create_instance(Name, ClassNref, ParentNref, ConnResolver, + create_instance(Session, Name, ClassNref, ParentNref, ConnResolver, graphdb_rules:default_conflict_resolver()). %%----------------------------------------------------------------------------- @@ -222,11 +229,14 @@ create_instance(Name, ClassNref, ParentNref, ConnResolver) %% in the CALLER's process (where seeded_nrefs/0 is safe) and applied per %% cascade level for composition rules and per plan node for connection rules. %%----------------------------------------------------------------------------- -create_instance(Name, ClassNref, ParentNref, ConnResolver, ConflictResolver) +create_instance(Session, Name, ClassNref, ParentNref, ConnResolver, + ConflictResolver) when is_function(ConnResolver, 1), is_function(ConflictResolver, 1) -> - gen_server:call(?MODULE, - {create_instance, Name, ClassNref, ParentNref, ConnResolver, - ConflictResolver}). + with_session(Session, fun() -> + gen_server:call(?MODULE, + {create_instance, Name, ClassNref, ParentNref, ConnResolver, + ConflictResolver}) + end). %% report_only(ConnContext) -> defer (the built-in /3 resolver) report_only(_Ctx) -> defer. @@ -246,10 +256,12 @@ report_only(_Ctx) -> defer. %% its default template removed; the caller must then use /5 to provide %% an explicit template. %%----------------------------------------------------------------------------- -add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref) -> - gen_server:call(?MODULE, - {add_relationship, SourceNref, CharNref, TargetNref, - ReciprocalNref, default, {[], []}}). +add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + with_session(Session, fun() -> + gen_server:call(?MODULE, + {add_relationship, SourceNref, CharNref, TargetNref, + ReciprocalNref, default, {[], []}}) + end). %%----------------------------------------------------------------------------- @@ -264,11 +276,13 @@ add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref) -> %% whose parent class is in the taxonomic ancestry of the source's %% class or the target's class. %%----------------------------------------------------------------------------- -add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, +add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref) when is_integer(TemplateNref) -> - gen_server:call(?MODULE, - {add_relationship, SourceNref, CharNref, TargetNref, - ReciprocalNref, TemplateNref, {[], []}}). + with_session(Session, fun() -> + gen_server:call(?MODULE, + {add_relationship, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, {[], []}}) + end). %%----------------------------------------------------------------------------- @@ -283,12 +297,14 @@ add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, %% The Template AVP (#{attribute => 31, value => TemplateNref}) is %% prepended to each direction's user-supplied AVP list. %%----------------------------------------------------------------------------- -add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, +add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, {FwdAVPs, RevAVPs} = AVPSpec) when is_integer(TemplateNref), is_list(FwdAVPs), is_list(RevAVPs) -> - gen_server:call(?MODULE, - {add_relationship, SourceNref, CharNref, TargetNref, - ReciprocalNref, TemplateNref, AVPSpec}). + with_session(Session, fun() -> + gen_server:call(?MODULE, + {add_relationship, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec}) + end). %%----------------------------------------------------------------------------- @@ -355,9 +371,11 @@ class_memberships(InstanceNref) -> %% a class already present returns ok without writing. Validates that %% the subject is an instance and the target is a class. %%----------------------------------------------------------------------------- -add_class_membership(InstanceNref, ClassNref) -> - gen_server:call(?MODULE, - {add_class_membership, InstanceNref, ClassNref}). +add_class_membership(Session, InstanceNref, ClassNref) -> + with_session(Session, fun() -> + gen_server:call(?MODULE, + {add_class_membership, InstanceNref, ClassNref}) + end). %%----------------------------------------------------------------------------- @@ -381,24 +399,79 @@ resolve_value(InstanceNref, AttrNref) -> gen_server:call(?MODULE, {resolve_value, InstanceNref, AttrNref}). +%%----------------------------------------------------------------------------- +%% remote_reference_class() -> integer() +%% +%% Returns the nref of the seeded "Remote Reference" class node. +%% The class is seeded at init time under the Classes category (nref 3). +%%----------------------------------------------------------------------------- +remote_reference_class() -> + gen_server:call(?MODULE, remote_reference_class). + + +%%----------------------------------------------------------------------------- +%% is_proxy(#node{}) -> boolean() +%% +%% Returns true iff the node is a member of the "Remote Reference" class. +%% This is a representation-contract check only — no dereference. +%%----------------------------------------------------------------------------- +is_proxy(#node{classes = Classes}) -> + lists:member(remote_reference_class(), Classes). + + +%%----------------------------------------------------------------------------- +%% proxy_coordinates(#node{}) -> +%% {ok, #{remote_project => integer(), remote_nref => integer()}} +%% | not_a_proxy +%% +%% Extracts the remote_project and remote_nref AVP values from a proxy node. +%% Returns not_a_proxy if the node is not a member of the Remote Reference class. +%%----------------------------------------------------------------------------- +proxy_coordinates(#node{attribute_value_pairs = AVPs} = N) -> + case is_proxy(N) of + false -> + not_a_proxy; + true -> + {ok, #{remote_project := RP, remote_nref := RN}} = + graphdb_attr:seeded_nrefs(), + {ok, #{remote_project => avp_value(AVPs, RP), + remote_nref => avp_value(AVPs, RN)}} + end. + + %%============================================================================= %% gen_server Behaviour Callbacks %%============================================================================= init([]) -> - logger:info("graphdb_instance: started"), - %% Cache the seeded `target_kind` literal-attribute nref from - %% graphdb_attr. Used by add_relationship validation to check - %% that an arc's target node has the kind declared on the - %% characterization. Also cache `instantiable` to check at - %% create_instance time that the class is not marked non-instantiable. - %% graphdb_attr is started before graphdb_instance by graphdb_sup, - %% so this call is safe at init time. - {ok, #{target_kind := TkAttr, instantiable := InstAttr, - retired := RetAttr}} = graphdb_attr:seeded_nrefs(), - {ok, #state{target_kind_avp_nref = TkAttr, - instantiable_nref = InstAttr, - retired_nref = RetAttr}}. + %% Cache literal-attribute nrefs from graphdb_attr. graphdb_attr is + %% started before graphdb_instance by graphdb_sup, so this call is + %% safe at init time. + try + {ok, #{target_kind := TkAttr, + instantiable := InstAttr, + retired := RetAttr, + remote_project := RpAttr, + remote_nref := RnAttr}} = graphdb_attr:seeded_nrefs(), + %% Ensure the "Remote Reference" class exists under Classes (nref 3). + %% find_subclass_by_name runs a transaction; graphdb_class:create_class + %% is a gen_server call resolved outside any transaction. + RRClass = ensure_remote_reference_class(), + logger:info("graphdb_instance: started " + "(target_kind=~p, instantiable=~p, retired=~p, " + "remote_reference_class=~p, remote_project=~p, remote_nref=~p)", + [TkAttr, InstAttr, RetAttr, RRClass, RpAttr, RnAttr]), + {ok, #state{target_kind_avp_nref = TkAttr, + instantiable_nref = InstAttr, + retired_nref = RetAttr, + remote_reference_class_nref = RRClass, + remote_project_nref = RpAttr, + remote_nref_nref = RnAttr}} + catch + throw:{error, Reason} -> + logger:error("graphdb_instance: seeding failed: ~p", [Reason]), + {stop, {seed_failed, Reason}} + end. %%----------------------------------------------------------------------------- @@ -447,6 +520,13 @@ handle_call({class_memberships, Nref}, _From, State) -> handle_call({resolve_value, InstNref, AttrNref}, _From, State) -> {reply, do_resolve_value(InstNref, AttrNref), State}; +%%----------------------------------------------------------------------------- +%% handle_call/3 -- Proxy accessor +%%----------------------------------------------------------------------------- +handle_call(remote_reference_class, _From, + #state{remote_reference_class_nref = RRClass} = State) -> + {reply, RRClass, State}; + handle_call(Request, From, State) -> ?UEM(handle_call, {Request, From, State}), {noreply, State}. @@ -494,6 +574,72 @@ find_avp_value([_ | Rest], AttrNref) -> find_avp_value(Rest, AttrNref). +%%----------------------------------------------------------------------------- +%% avp_value(AVPs, AttrNref) -> term() +%% +%% Unwraps find_avp_value/2, returning the raw value. Only used where the +%% value is expected to be present (e.g., a verified proxy node). +%%----------------------------------------------------------------------------- +avp_value(AVPs, AttrNref) -> + {ok, V} = find_avp_value(AVPs, AttrNref), + V. + + +%%----------------------------------------------------------------------------- +%% ensure_remote_reference_class() -> integer() +%% +%% Idempotent init helper: returns the nref of the "Remote Reference" class +%% under the Classes category (nref 3), creating it on first startup. +%% Throws {error, Reason} on failure (caught by init/1 try/catch). +%%----------------------------------------------------------------------------- +ensure_remote_reference_class() -> + case find_subclass_by_name(?NREF_CLASSES, "Remote Reference") of + {ok, Nref} -> + Nref; + not_found -> + case graphdb_class:create_class("Remote Reference", ?NREF_CLASSES) of + {ok, Nref} -> Nref; + {error, Reason} -> throw({error, Reason}) + end + end. + + +%%----------------------------------------------------------------------------- +%% find_subclass_by_name(ParentNref, Name) -> {ok, Nref} | not_found +%% +%% Searches the direct taxonomy children of ParentNref for a class node +%% whose NAME_ATTR_CLASS AVP matches Name. Runs in one Mnesia transaction. +%% Throws {error, Reason} on transaction failure. +%%----------------------------------------------------------------------------- +find_subclass_by_name(ParentNref, Name) -> + F = fun() -> + Arcs = mnesia:index_read(relationships, ParentNref, + #relationship.source_nref), + ChildNrefs = [A#relationship.target_nref || A <- Arcs, + A#relationship.kind =:= taxonomy, + A#relationship.characterization =:= ?ARC_CLS_CHILD], + Nodes = lists:flatmap(fun(N) -> mnesia:read(nodes, N) end, ChildNrefs), + lists:search(fun(N) -> class_has_name(N, Name) end, Nodes) + end, + case graphdb_mgr:transaction(F) of + {ok, {value, #node{nref = Nref}}} -> {ok, Nref}; + {ok, false} -> not_found; + {error, Reason} -> throw({error, Reason}) + end. + + +%%----------------------------------------------------------------------------- +%% class_has_name(#node{}, Name) -> boolean() +%% +%% True iff the node carries a NAME_ATTR_CLASS AVP with value Name. +%%----------------------------------------------------------------------------- +class_has_name(#node{attribute_value_pairs = AVPs}, Name) -> + lists:any(fun + (#{attribute := ?NAME_ATTR_CLASS, value := V}) -> V =:= Name; + (_) -> false + end, AVPs). + + %%----------------------------------------------------------------------------- %% do_create_instance(Name, ClassNref, ParentNref, Ctx) %% -> {ok, Nref, report()} | {error, Reason, report()} | {error, Reason} @@ -1326,16 +1472,20 @@ remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec) -> %% narrows by an explicit template. Plain functions owning one %% graphdb_mgr:transaction/1 in the caller's process (no gen_server state). %%----------------------------------------------------------------------------- -remove_relationship(SourceNref, CharNref, TargetNref) -> - txn_ok(fun() -> - remove_relationship_in_txn(SourceNref, CharNref, TargetNref, any) +remove_relationship(Session, SourceNref, CharNref, TargetNref) -> + with_session(Session, fun() -> + txn_ok(fun() -> + remove_relationship_in_txn(SourceNref, CharNref, TargetNref, any) + end) end). -remove_relationship(SourceNref, CharNref, TargetNref, TemplateNref) +remove_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref) when is_integer(TemplateNref) -> - txn_ok(fun() -> - remove_relationship_in_txn(SourceNref, CharNref, TargetNref, - TemplateNref) + with_session(Session, fun() -> + txn_ok(fun() -> + remove_relationship_in_txn(SourceNref, CharNref, TargetNref, + TemplateNref) + end) end). %% Run an in-txn primitive in one transaction; normalise {ok, _} -> ok. @@ -1345,6 +1495,16 @@ txn_ok(Fun) -> {error, _} = Err -> Err end. +%% Gate a project operation on a valid project session (SP1). A missing or +%% malformed session short-circuits with {error, invalid_session}; a valid +%% one runs Fun. The session is required but otherwise inert against today's +%% single store (SP2 gives it physical routing). +with_session(Session, Fun) -> + case graphdb_project:require_session(Session) of + ok -> Fun(); + {error, _} = Err -> Err + end. + %%----------------------------------------------------------------------------- %% update_relationship_avps_in_txn(S, C, T, TemplateSpec, Updates) -> ok %% (aborts the enclosing transaction on any failure) @@ -1388,13 +1548,17 @@ has_template_update(Updates) -> %% (S, C, T). Validates the update grammar client-side (slice B), then owns %% one transaction. %%----------------------------------------------------------------------------- -update_relationship(SourceNref, CharNref, TargetNref, Updates) -> - do_update_relationship(SourceNref, CharNref, TargetNref, any, Updates). +update_relationship(Session, SourceNref, CharNref, TargetNref, Updates) -> + with_session(Session, fun() -> + do_update_relationship(SourceNref, CharNref, TargetNref, any, Updates) + end). -update_relationship(SourceNref, CharNref, TargetNref, TemplateNref, Updates) - when is_integer(TemplateNref) -> - do_update_relationship(SourceNref, CharNref, TargetNref, TemplateNref, - Updates). +update_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref, + Updates) when is_integer(TemplateNref) -> + with_session(Session, fun() -> + do_update_relationship(SourceNref, CharNref, TargetNref, TemplateNref, + Updates) + end). do_update_relationship(SourceNref, CharNref, TargetNref, TemplateSpec, Updates) -> @@ -1455,12 +1619,17 @@ update_relationship_both_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, %% transaction. The two update lists are independent (forward need not mirror %% reverse). Both lists are validated client-side (slice B grammar). %%----------------------------------------------------------------------------- -update_relationship_both(SourceNref, CharNref, TargetNref, {Fwd, Rev}) -> - do_update_both(SourceNref, CharNref, TargetNref, any, Fwd, Rev). +update_relationship_both(Session, SourceNref, CharNref, TargetNref, + {Fwd, Rev}) -> + with_session(Session, fun() -> + do_update_both(SourceNref, CharNref, TargetNref, any, Fwd, Rev) + end). -update_relationship_both(SourceNref, CharNref, TargetNref, TemplateNref, +update_relationship_both(Session, SourceNref, CharNref, TargetNref, TemplateNref, {Fwd, Rev}) when is_integer(TemplateNref) -> - do_update_both(SourceNref, CharNref, TargetNref, TemplateNref, Fwd, Rev). + with_session(Session, fun() -> + do_update_both(SourceNref, CharNref, TargetNref, TemplateNref, Fwd, Rev) + end). do_update_both(SourceNref, CharNref, TargetNref, TemplateSpec, Fwd, Rev) -> case {graphdb_mgr:validate_avp_updates(Fwd), diff --git a/apps/graphdb/src/graphdb_mgr.erl b/apps/graphdb/src/graphdb_mgr.erl index 9252635..98a7803 100644 --- a/apps/graphdb/src/graphdb_mgr.erl +++ b/apps/graphdb/src/graphdb_mgr.erl @@ -114,8 +114,8 @@ %% Write operations (delegate to workers) create_attribute/3, create_class/2, - create_instance/3, - add_relationship/4, + create_instance/4, + add_relationship/5, delete_node/1, retire_node/1, unretire_node/1, @@ -222,26 +222,38 @@ create_class(Name, ParentClassNref) -> %%----------------------------------------------------------------------------- -%% create_instance(Name, ClassNref, ParentNref) -> +%% create_instance(Session, Name, ClassNref, ParentNref) -> %% {ok, Nref, report()} | {error, Reason, report()} | {error, Reason} %% -%% Creates a new instance node and fires mandatory composition rules. -%% Delegates to graphdb_instance; propagates the 3-tuple return verbatim. +%% Creates a new instance node in the project named by Session and fires +%% mandatory composition rules. A project operation requires a valid session +%% (SP1). Delegates to graphdb_instance; propagates the 3-tuple return verbatim. %%----------------------------------------------------------------------------- -create_instance(Name, ClassNref, ParentNref) -> - gen_server:call(?MODULE, {create_instance, Name, ClassNref, ParentNref}). +create_instance(Session, Name, ClassNref, ParentNref) -> + case graphdb_project:require_session(Session) of + {error, _} = Err -> Err; + ok -> + gen_server:call(?MODULE, + {create_instance, Session, Name, ClassNref, ParentNref}) + end. %%----------------------------------------------------------------------------- -%% add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref) -> -%% {ok, {Id1, Id2}} | {error, term()} +%% add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> +%% ok | {error, term()} %% -%% Creates a bidirectional relationship (two directed rows). -%% Delegates to graphdb_instance (not yet implemented). +%% Creates a bidirectional relationship (two directed rows) in the project +%% named by Session. A project operation requires a valid session (SP1); +%% delegates to graphdb_instance. %%----------------------------------------------------------------------------- -add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref) -> - gen_server:call(?MODULE, - {add_relationship, SourceNref, CharNref, TargetNref, ReciprocalNref}). +add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + case graphdb_project:require_session(Session) of + {error, _} = Err -> Err; + ok -> + gen_server:call(?MODULE, + {add_relationship, Session, SourceNref, CharNref, TargetNref, + ReciprocalNref}) + end. %%----------------------------------------------------------------------------- @@ -594,14 +606,18 @@ handle_call({create_class, Name, ParentClassNref}, _From, State) -> %% Class nodes are kind=class -- never category, no guard needed. {reply, graphdb_class:create_class(Name, ParentClassNref), State}; -handle_call({create_instance, Name, ClassNref, ParentNref}, _From, State) -> +handle_call({create_instance, Session, Name, ClassNref, ParentNref}, _From, + State) -> %% Instance nodes are kind=instance -- never category, no guard needed. - {reply, graphdb_instance:create_instance(Name, ClassNref, ParentNref), State}; + {reply, + graphdb_instance:create_instance(Session, Name, ClassNref, ParentNref), + State}; -handle_call({add_relationship, SourceNref, CharNref, TargetNref, ReciprocalNref}, - _From, State) -> +handle_call({add_relationship, Session, SourceNref, CharNref, TargetNref, + ReciprocalNref}, _From, State) -> {reply, - graphdb_instance:add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref), + graphdb_instance:add_relationship(Session, SourceNref, CharNref, + TargetNref, ReciprocalNref), State}; handle_call({retire_node, Nref}, _From, State0) -> diff --git a/apps/graphdb/src/graphdb_ns.erl b/apps/graphdb/src/graphdb_ns.erl new file mode 100644 index 0000000..44ead83 --- /dev/null +++ b/apps/graphdb/src/graphdb_ns.erl @@ -0,0 +1,60 @@ +%%--------------------------------------------------------------------- +%% Copyright (c) 2008 SeerStone, Inc. +%% Copyright (c) 2026 David W. Thomas +%% SPDX-License-Identifier: GPL-2.0-or-later +%%--------------------------------------------------------------------- +%% Author: David W. Thomas +%% Created: 2026-06-29 +%% Description: Pure namespace resolution module. Encodes which +%% database namespace each kind of nref reference belongs +%% to. No dependencies on other modules; fixed lookup table +%% based on the project-environment separation model. +%%--------------------------------------------------------------------- +%% Revision History +%%--------------------------------------------------------------------- +%% Rev PA1 Date: 2026-06-29 Author: David W. Thomas +%% Initial implementation. +%%--------------------------------------------------------------------- + +-module(graphdb_ns). + +-export([namespace_of/1, target_namespace/1]). + +%%--------------------------------------------------------------------- +%% NYI / UEM Macros +%%--------------------------------------------------------------------- +-define(NYI(X), (begin + io:format("*** NYI ~p ~p ~p~n",[?MODULE, ?LINE, X]), + exit(nyi) +end)). +-define(UEM(F, X), (begin + io:format("*** UEM ~p:~p ~p ~p~n",[?MODULE, F, ?LINE, X]), + exit(uem) +end)). + + +%%--------------------------------------------------------------------- +%% namespace_of(Role) -> environment | project | home +%% +%% Encodes docs/designs/project-env-reference-namespace-model-design.md §3. +%% `home` = same store as the containing record (node's own DB / row's home). +%%--------------------------------------------------------------------- +namespace_of(characterization) -> environment; +namespace_of(reciprocal) -> environment; +namespace_of(avp_attribute) -> environment; +namespace_of(node_classes) -> environment; +namespace_of(taxonomy_parent) -> environment; +namespace_of(compositional_parent) -> project; +namespace_of(node_nref) -> home; +namespace_of(source_nref) -> home. + + +%%--------------------------------------------------------------------- +%% target_namespace(TargetKind) -> environment | project +%% +%% The single routed field (relationship.target_nref): project iff instance. +%%--------------------------------------------------------------------- +target_namespace(instance) -> project; +target_namespace(category) -> environment; +target_namespace(attribute) -> environment; +target_namespace(class) -> environment. diff --git a/apps/graphdb/src/graphdb_project.erl b/apps/graphdb/src/graphdb_project.erl new file mode 100644 index 0000000..fa78116 --- /dev/null +++ b/apps/graphdb/src/graphdb_project.erl @@ -0,0 +1,228 @@ +%%--------------------------------------------------------------------- +%% Copyright (c) 2008 SeerStone, Inc. +%% Copyright (c) 2026 David W. Thomas +%% SPDX-License-Identifier: GPL-2.0-or-later +%%--------------------------------------------------------------------- +%% Author: David W. Thomas +%% Created: 2026-06-29 +%% Description: Project registry module (SP1 — Reference & Namespace +%% Model). Provides register_project/1 to create a +%% project anchor node in the environment under the +%% Projects category (nref 5), and is_project/1 to +%% test whether an nref names a registered project. +%% +%% This is a plain module — not a gen_server. All +%% functions run in the caller's process. +%%--------------------------------------------------------------------- +%% Revision History +%%--------------------------------------------------------------------- +%% Rev PA1 Date: 2026-06-29 Author: David W. Thomas (david@davidwt.com) +%% Initial implementation: SP1 project registry. +%%--------------------------------------------------------------------- + +-module(graphdb_project). + + +%%--------------------------------------------------------------------- +%% Module Attributes +%%--------------------------------------------------------------------- +-revision('Revision: PA1 '). +-created('Date: 2026-06-29'). +-created_by('david@davidwt.com'). + + +%%--------------------------------------------------------------------- +%% Include files +%%--------------------------------------------------------------------- +-include_lib("graphdb/include/graphdb_nrefs.hrl"). + + +%%--------------------------------------------------------------------- +%% Macro Functions +%%--------------------------------------------------------------------- +%% NYI - Not Yet Implemented +%% F = {fun,{Arg1,Arg2,...}} +%% +-define(NYI(X), (begin + io:format("*** NYI ~p ~p ~p~n",[?MODULE, ?LINE, X]), + exit(nyi) +end)). +-define(UEM(F, X), (begin + io:format("*** UEM ~p:~p ~p ~p~n",[?MODULE, F, ?LINE, X]), + exit(uem) +end)). + + +%%--------------------------------------------------------------------- +%% Record definitions (inline — no shared graphdb records header) +%%--------------------------------------------------------------------- +-record(node, { + nref, + kind, + parents = [], + classes = [], + attribute_value_pairs +}). + +-record(relationship, { + id, + kind, + source_nref, + characterization, + target_nref, + reciprocal, + avps +}). + + +%%--------------------------------------------------------------------- +%% Exports +%%--------------------------------------------------------------------- +-export([register_project/1, is_project/1, open_session/1, session_project/1, + require_session/1, + add_relationship/5, add_relationship/6, add_relationship/7, + add_class_membership/3, + remove_relationship/4, remove_relationship/5, + update_relationship/5, update_relationship/6, + update_relationship_both/5, update_relationship_both/6]). + + +%%===================================================================== +%% Public API +%%===================================================================== + +%%--------------------------------------------------------------------- +%% register_project(Name) -> {ok, ProjectNref} | {error, term()} +%% +%% Creates a kind=instance node in the environment under the Projects +%% category (nref 5) via a pair of category composition arcs, then +%% returns the new node's nref. +%% +%% The nref and rel-id pair are allocated OUTSIDE the transaction fun: +%% calling gen_servers (graphdb_nref, rel_id_server) inside a Mnesia +%% activity is a latent deadlock — load-bearing invariant in this +%% codebase. +%%--------------------------------------------------------------------- +register_project(Name) when is_list(Name) -> + Nref = graphdb_nref:get_next(), + {Id1, Id2} = rel_id_server:get_id_pair(), + NameAVP = #{attribute => ?NAME_ATTR_INSTANCE, value => Name}, + Node = #node{nref = Nref, kind = instance, + parents = [?NREF_PROJECTS], + attribute_value_pairs = [NameAVP]}, + P2C = #relationship{id = Id1, kind = composition, + source_nref = ?NREF_PROJECTS, + characterization = ?ARC_CAT_CHILD, + target_nref = Nref, reciprocal = ?ARC_CAT_PARENT, + avps = []}, + C2P = #relationship{id = Id2, kind = composition, + source_nref = Nref, + characterization = ?ARC_CAT_PARENT, + target_nref = ?NREF_PROJECTS, reciprocal = ?ARC_CAT_CHILD, + avps = []}, + Fun = fun() -> + ok = mnesia:write(nodes, Node, write), + ok = mnesia:write(relationships, P2C, write), + ok = mnesia:write(relationships, C2P, write), + Nref + end, + graphdb_mgr:transaction(Fun). + + +%%--------------------------------------------------------------------- +%% is_project(Nref) -> boolean() +%% +%% Returns true iff the node at Nref has ?NREF_PROJECTS (5) in its +%% parents cache — i.e. it was registered as a project anchor node. +%%--------------------------------------------------------------------- +is_project(Nref) -> + case graphdb_mgr:get_node(Nref) of + {ok, #node{parents = Parents}} -> lists:member(?NREF_PROJECTS, Parents); + _ -> false + end. + + +%%--------------------------------------------------------------------- +%% open_session(ProjectNref) -> {ok, Session} | {error, not_a_project} +%% +%% Opens a session on a registered project. Returns an opaque Session map +%% if the nref is a registered project, otherwise {error, not_a_project}. +%% Session is #{kind => project_session, project => Nref}. +%%--------------------------------------------------------------------- +open_session(ProjectNref) -> + case is_project(ProjectNref) of + true -> {ok, #{kind => project_session, project => ProjectNref}}; + false -> {error, not_a_project} + end. + + +%%--------------------------------------------------------------------- +%% session_project(Session) -> ProjectNref +%% +%% Extracts the project nref from an opaque session map. +%%--------------------------------------------------------------------- +session_project(#{kind := project_session, project := Nref}) -> Nref. + + +%%--------------------------------------------------------------------- +%% require_session(Session) -> ok | {error, invalid_session} +%% +%% Gate for project-scoped operations: a well-formed project session +%% passes; any other term is rejected. Pure (no store access) — the +%% session was already validated against the registry by open_session/1. +%%--------------------------------------------------------------------- +require_session(#{kind := project_session, project := _}) -> ok; +require_session(_) -> {error, invalid_session}. + + +%%===================================================================== +%% Canonical project-scoped relationship API (SP1 §8 relocation). +%% +%% These are the project side of the environment/project split: they +%% take a project Session as the first argument and delegate to the +%% graphdb_instance implementations. The session is validated inside +%% graphdb_instance via require_session/1. +%%===================================================================== + +add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + graphdb_instance:add_relationship(Session, SourceNref, CharNref, + TargetNref, ReciprocalNref). + +add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref) -> + graphdb_instance:add_relationship(Session, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateNref). + +add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref, AVPSpec) -> + graphdb_instance:add_relationship(Session, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateNref, AVPSpec). + +add_class_membership(Session, InstanceNref, ClassNref) -> + graphdb_instance:add_class_membership(Session, InstanceNref, ClassNref). + +remove_relationship(Session, SourceNref, CharNref, TargetNref) -> + graphdb_instance:remove_relationship(Session, SourceNref, CharNref, + TargetNref). + +remove_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref) -> + graphdb_instance:remove_relationship(Session, SourceNref, CharNref, + TargetNref, TemplateNref). + +update_relationship(Session, SourceNref, CharNref, TargetNref, Updates) -> + graphdb_instance:update_relationship(Session, SourceNref, CharNref, + TargetNref, Updates). + +update_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref, + Updates) -> + graphdb_instance:update_relationship(Session, SourceNref, CharNref, + TargetNref, TemplateNref, Updates). + +update_relationship_both(Session, SourceNref, CharNref, TargetNref, Pair) -> + graphdb_instance:update_relationship_both(Session, SourceNref, CharNref, + TargetNref, Pair). + +update_relationship_both(Session, SourceNref, CharNref, TargetNref, TemplateNref, + Pair) -> + graphdb_instance:update_relationship_both(Session, SourceNref, CharNref, + TargetNref, TemplateNref, Pair). diff --git a/apps/graphdb/test/graphdb_attr_SUITE.erl b/apps/graphdb/test/graphdb_attr_SUITE.erl index 3cbf1c7..f9b1aa3 100644 --- a/apps/graphdb/test/graphdb_attr_SUITE.erl +++ b/apps/graphdb/test/graphdb_attr_SUITE.erl @@ -717,8 +717,10 @@ list_attributes_includes_bootstrap_and_runtime(_Config) -> {ok, _} = graphdb_attr:start_link(), {ok, Before} = graphdb_attr:list_attributes(), %% Bootstrap has 27 attribute nodes (nrefs 6-31 = 26, plus lang_code); - %% seeding adds the Attribute Literals sub-group + 6 literal attrs = 7 - ?assertEqual(27 + 7, length(Before)), + %% seeding adds: Attribute Literals sub-group (1) + 8 literal attrs + %% (literal_type, target_kind, relationship_avp, attribute_type, + %% instantiable, retired, remote_project, remote_nref) = 9 total + ?assertEqual(27 + 9, length(Before)), {ok, _} = graphdb_attr:create_name_attribute("One"), {ok, _} = graphdb_attr:create_name_attribute("Two"), diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index cee5bb1..c4c362f 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -61,6 +61,7 @@ -export([ %% Creation create_instance_basic/1, + create_instance_rejects_bad_session/1, create_instance_rejects_bad_class/1, create_instance_rejects_missing_class/1, create_instance_rejects_missing_parent/1, @@ -95,6 +96,10 @@ %% Remove relationships remove_relationship_basic/1, remove_relationship_not_found/1, + remove_relationship_rejects_bad_session/1, + add_relationship_rejects_bad_session/1, + update_relationship_rejects_bad_session/1, + add_class_membership_rejects_bad_session/1, remove_relationship_ambiguous/1, remove_relationship_disambiguate_by_template/1, remove_relationship_dangling_half_edge/1, @@ -199,7 +204,10 @@ %% B5 end-to-end firing b5_firing_same_level_mode_priority/1, b5_firing_cross_level_shadow/1, - b5_custom_resolver_pure_additive/1 + b5_custom_resolver_pure_additive/1, + %% Proxy recognizer + proxy_recognizer_identifies_proxy/1, + proxy_recognizer_rejects_plain_instance/1 ]). @@ -213,12 +221,13 @@ suite() -> all() -> [{group, creation}, {group, relationships}, {group, lookups}, {group, hierarchy}, {group, inheritance}, {group, multi_membership}, - {group, firing}]. + {group, firing}, {group, proxy_recognizer}]. groups() -> [ {creation, [], [ create_instance_basic, + create_instance_rejects_bad_session, create_instance_rejects_bad_class, create_instance_rejects_missing_class, create_instance_rejects_missing_parent, @@ -253,6 +262,10 @@ groups() -> class_of_returns_class, remove_relationship_basic, remove_relationship_not_found, + remove_relationship_rejects_bad_session, + add_relationship_rejects_bad_session, + update_relationship_rejects_bad_session, + add_class_membership_rejects_bad_session, remove_relationship_ambiguous, remove_relationship_disambiguate_by_template, remove_relationship_dangling_half_edge, @@ -354,6 +367,10 @@ groups() -> b5_firing_same_level_mode_priority, b5_firing_cross_level_shadow, b5_custom_resolver_pure_additive + ]}, + {proxy_recognizer, [], [ + proxy_recognizer_identifies_proxy, + proxy_recognizer_rejects_plain_instance ]} ]. @@ -407,6 +424,11 @@ init_per_testcase(TC, Config) -> %% Mirror production graphdb:start/2: flip to runtime tier after all workers %% have seeded so that user-level create_* calls allocate runtime nrefs. maybe_set_runtime_phase(TC), + %% SP1: pre-warm the project session (register a project + open it) as part + %% of setup, so its arc rows are already in the baseline before any test body + %% captures a relationships/nodes before/after delta. Runs in the test-case + %% process, so sess()'s process-dict memo is visible to the body. + _ = sess(), setup_firing_fixtures(TC, Config1). %% Test cases that call graphdb_mgr:retire_node/1 require runtime nrefs. @@ -533,27 +555,37 @@ verify_cache_invariant(TC) -> %%----------------------------------------------------------------------------- create_instance_basic(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Vehicle", 3), - {ok, InstNref, _} = graphdb_instance:create_instance("Car1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Car1", ClassNref, 5), {ok, Node} = graphdb_instance:get_instance(InstNref), ?assertEqual(instance, Node#node.kind), ?assertEqual([5], Node#node.parents), ?assertEqual([#{attribute => ?NAME_ATTR_INSTANCE, value => "Car1"}], Node#node.attribute_value_pairs). +%%----------------------------------------------------------------------------- +%% SP1: create_instance is a project op — a non-session term is rejected +%% before any store access (2-tuple {error, invalid_session}, consistent with +%% the pre-PLAN validation error shape). +%%----------------------------------------------------------------------------- +create_instance_rejects_bad_session(_Config) -> + {ok, ClassNref} = graphdb_class:create_class("Vehicle", 3), + ?assertEqual({error, invalid_session}, + graphdb_instance:create_instance(not_a_session, "Car1", ClassNref, 5)). + %%----------------------------------------------------------------------------- %% Reject creation with a non-class nref. %%----------------------------------------------------------------------------- create_instance_rejects_bad_class(_Config) -> %% Nref 6 (Names) is an attribute node ?assertMatch({error, {not_a_class, attribute}}, - graphdb_instance:create_instance("Bad", 6, 5)). + graphdb_instance:create_instance(sess(), "Bad", 6, 5)). %%----------------------------------------------------------------------------- %% Reject creation with a non-existent class. %%----------------------------------------------------------------------------- create_instance_rejects_missing_class(_Config) -> ?assertEqual({error, class_not_found}, - graphdb_instance:create_instance("Bad", 99999, 5)). + graphdb_instance:create_instance(sess(), "Bad", 99999, 5)). %%----------------------------------------------------------------------------- %% Reject creation with a non-existent parent. @@ -561,14 +593,14 @@ create_instance_rejects_missing_class(_Config) -> create_instance_rejects_missing_parent(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), ?assertEqual({error, parent_not_found}, - graphdb_instance:create_instance("Bad", ClassNref, 99999)). + graphdb_instance:create_instance(sess(), "Bad", ClassNref, 99999)). %%----------------------------------------------------------------------------- %% Creating an instance must write membership arcs (char=29/30). %%----------------------------------------------------------------------------- create_instance_writes_membership_arcs(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Animal", 3), - {ok, InstNref, _} = graphdb_instance:create_instance("Dog1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Dog1", ClassNref, 5), %% Instance -> Class (char=29, reciprocal=30) {atomic, InstOut} = mnesia:transaction(fun() -> @@ -597,7 +629,7 @@ create_instance_writes_membership_arcs(_Config) -> %%----------------------------------------------------------------------------- create_instance_writes_compositional_arcs(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", 3), - {ok, InstNref, _} = graphdb_instance:create_instance("Bolt1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Bolt1", ClassNref, 5), %% Parent (5) -> Child (InstNref) with char=28 {atomic, ParentOut} = mnesia:transaction(fun() -> @@ -631,7 +663,7 @@ create_instance_refused_for_abstract_class(_Config) -> [#{attribute => Inst, value => false}]), Before = mnesia:table_info(nodes, size), ?assertEqual({error, {class_not_instantiable, ClassNref}}, - graphdb_instance:create_instance("Nope", ClassNref, 5)), + graphdb_instance:create_instance(sess(), "Nope", ClassNref, 5)), ?assertEqual(Before, mnesia:table_info(nodes, size)). %%----------------------------------------------------------------------------- @@ -640,7 +672,7 @@ create_instance_refused_for_abstract_class(_Config) -> create_instance_allowed_for_unmarked_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Plain", 3), ?assertMatch({ok, _, _}, - graphdb_instance:create_instance("Inst1", ClassNref, 5)). + graphdb_instance:create_instance(sess(), "Inst1", ClassNref, 5)). %%----------------------------------------------------------------------------- %% create_instance rejects a retired class node. @@ -649,17 +681,17 @@ create_instance_refuses_retired_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("RetClass", 3), ok = graphdb_mgr:retire_node(ClassNref), ?assertEqual({error, {class_retired, ClassNref}}, - graphdb_instance:create_instance("i", ClassNref, 3)). + graphdb_instance:create_instance(sess(), "i", ClassNref, 3)). %%----------------------------------------------------------------------------- %% create_instance rejects a retired compositional parent. %%----------------------------------------------------------------------------- create_instance_refuses_retired_parent(_Config) -> {ok, ClassNref} = graphdb_class:create_class("PClass", 3), - {ok, Parent, _} = graphdb_instance:create_instance("p", ClassNref, 3), + {ok, Parent, _} = graphdb_instance:create_instance(sess(), "p", ClassNref, 3), ok = graphdb_mgr:retire_node(Parent), ?assertEqual({error, {parent_retired, Parent}}, - graphdb_instance:create_instance("child", ClassNref, Parent)). + graphdb_instance:create_instance(sess(), "child", ClassNref, Parent)). %%============================================================================= @@ -671,13 +703,13 @@ create_instance_refuses_retired_parent(_Config) -> %%----------------------------------------------------------------------------- add_relationship_basic(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), %% Create a relationship attribute pair for testing {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), RelsBefore = mnesia:table_info(relationships, size), - ok = graphdb_instance:add_relationship(A, MakesNref, B, MadeByNref), + ok = graphdb_instance:add_relationship(sess(), A, MakesNref, B, MadeByNref), RelsAfter = mnesia:table_info(relationships, size), ?assertEqual(RelsBefore + 2, RelsAfter). @@ -686,11 +718,11 @@ add_relationship_basic(_Config) -> %%----------------------------------------------------------------------------- add_relationship_both_directions(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), - {ok, Ford, _} = graphdb_instance:create_instance("Ford", ClassNref, 5), - {ok, Taurus, _} = graphdb_instance:create_instance("Taurus", ClassNref, 5), + {ok, Ford, _} = graphdb_instance:create_instance(sess(), "Ford", ClassNref, 5), + {ok, Taurus, _} = graphdb_instance:create_instance(sess(), "Taurus", ClassNref, 5), {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), - ok = graphdb_instance:add_relationship(Ford, MakesNref, Taurus, MadeByNref), + ok = graphdb_instance:add_relationship(sess(), Ford, MakesNref, Taurus, MadeByNref), %% Ford -> Taurus (char=Makes, reciprocal=MadeBy) {atomic, FordOut} = mnesia:transaction(fun() -> @@ -719,11 +751,11 @@ add_relationship_both_directions(_Config) -> add_relationship_stamps_template_avp(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), {atomic, ARels} = mnesia:transaction(fun() -> mnesia:index_read(relationships, A, #relationship.source_nref) @@ -741,11 +773,11 @@ add_relationship_stamps_template_avp(_Config) -> add_relationship_explicit_template(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Person", 3), {ok, AltTmpl} = graphdb_class:add_template(ClassNref, "social"), - {ok, A, _} = graphdb_instance:create_instance("Alice", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("Bob", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "Alice", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "Bob", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), - ok = graphdb_instance:add_relationship(A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, AltTmpl), {atomic, ARels} = mnesia:transaction(fun() -> mnesia:index_read(relationships, A, #relationship.source_nref) @@ -761,13 +793,13 @@ add_relationship_explicit_template(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_non_template_nref(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Animal", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), %% ClassNref is a class, not a template ?assertMatch({error, {invalid_template, _, not_a_template}}, - graphdb_instance:add_relationship(A, Char, B, Recip, ClassNref)). + graphdb_instance:add_relationship(sess(), A, Char, B, Recip, ClassNref)). %%----------------------------------------------------------------------------- %% add_relationship/5 rejects a template whose parent class is unrelated @@ -777,12 +809,12 @@ add_relationship_rejects_template_out_of_ancestry(_Config) -> {ok, AnimalCls} = graphdb_class:create_class("Animal", 3), {ok, VehicleCls} = graphdb_class:create_class("Vehicle", 3), {ok, VehTmpl} = graphdb_class:default_template(VehicleCls), - {ok, A, _} = graphdb_instance:create_instance("Cat", AnimalCls, 5), - {ok, B, _} = graphdb_instance:create_instance("Dog", AnimalCls, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "Cat", AnimalCls, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "Dog", AnimalCls, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertMatch({error, {template_class_not_in_ancestry, _, _, _, _}}, - graphdb_instance:add_relationship(A, Char, B, Recip, VehTmpl)). + graphdb_instance:add_relationship(sess(), A, Char, B, Recip, VehTmpl)). %%----------------------------------------------------------------------------- %% After deleting the default template, /4 returns no_default_template; @@ -791,61 +823,61 @@ add_relationship_rejects_template_out_of_ancestry(_Config) -> add_relationship_no_default_after_delete(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Animal", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), {atomic, ok} = mnesia:transaction(fun() -> mnesia:delete({nodes, DefaultTmpl}) end), ?assertEqual({error, no_default_template}, - graphdb_instance:add_relationship(A, Char, B, Recip)). + graphdb_instance:add_relationship(sess(), A, Char, B, Recip)). %%----------------------------------------------------------------------------- %% missing source nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_source(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {source_not_found, 99999}}, - graphdb_instance:add_relationship(99999, Char, B, Recip)). + graphdb_instance:add_relationship(sess(), 99999, Char, B, Recip)). %%----------------------------------------------------------------------------- %% missing target nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_target(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {target_not_found, 99999}}, - graphdb_instance:add_relationship(A, Char, 99999, Recip)). + graphdb_instance:add_relationship(sess(), A, Char, 99999, Recip)). %%----------------------------------------------------------------------------- %% missing characterization nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_characterization(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {_Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {characterization_not_found, 99999}}, - graphdb_instance:add_relationship(A, 99999, B, Recip)). + graphdb_instance:add_relationship(sess(), A, 99999, B, Recip)). %%----------------------------------------------------------------------------- %% missing reciprocal nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_reciprocal(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, _Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {reciprocal_not_found, 99999}}, - graphdb_instance:add_relationship(A, Char, B, 99999)). + graphdb_instance:add_relationship(sess(), A, Char, B, 99999)). %%----------------------------------------------------------------------------- %% characterization that is not kind=attribute is rejected. Uses @@ -853,24 +885,24 @@ add_relationship_rejects_missing_reciprocal(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_non_attribute_char(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {_Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertMatch({error, {characterization_not_an_attribute, 5, category}}, - graphdb_instance:add_relationship(A, 5, B, Recip)). + graphdb_instance:add_relationship(sess(), A, 5, B, Recip)). %%----------------------------------------------------------------------------- %% reciprocal that is not kind=attribute is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_non_attribute_reciprocal(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, _Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertMatch({error, {reciprocal_not_an_attribute, 5, category}}, - graphdb_instance:add_relationship(A, Char, B, 5)). + graphdb_instance:add_relationship(sess(), A, Char, B, 5)). %%----------------------------------------------------------------------------- %% target whose kind disagrees with the characterization's @@ -879,13 +911,13 @@ add_relationship_rejects_non_attribute_reciprocal(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_target_kind_mismatch(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), %% target_kind=class, but B is an instance {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Has", "HeldBy", class), ?assertEqual({error, {target_kind_mismatch, class, instance}}, - graphdb_instance:add_relationship(A, Char, B, Recip)). + graphdb_instance:add_relationship(sess(), A, Char, B, Recip)). %%----------------------------------------------------------------------------- %% source that exists and passes endpoint validation but has no instance->class @@ -894,11 +926,11 @@ add_relationship_rejects_target_kind_mismatch(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_source_has_no_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {source_has_no_class, ClassNref}}, - graphdb_instance:add_relationship(ClassNref, Char, B, Recip)). + graphdb_instance:add_relationship(sess(), ClassNref, Char, B, Recip)). %%----------------------------------------------------------------------------- %% target that exists and passes endpoint validation but has no instance->class @@ -907,11 +939,11 @@ add_relationship_rejects_source_has_no_class(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_target_has_no_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Has", "HeldBy", class), ?assertEqual({error, {target_has_no_class, ClassNref}}, - graphdb_instance:add_relationship(A, Char, ClassNref, Recip)). + graphdb_instance:add_relationship(sess(), A, Char, ClassNref, Recip)). %%----------------------------------------------------------------------------- @@ -922,13 +954,13 @@ add_relationship_rejects_target_has_no_class(_Config) -> add_relationship_stamps_user_avps(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), {ok, Confidence} = graphdb_attr:create_literal_attribute("confidence", float), UserAVP = #{attribute => Confidence, value => 0.95}, - ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl, + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl, {[UserAVP], [UserAVP]}), %% Both directions should carry Template AVP and the user AVP. @@ -949,15 +981,15 @@ add_relationship_stamps_user_avps(_Config) -> add_relationship_avps_are_per_direction(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), {ok, Source} = graphdb_attr:create_literal_attribute("source", string), {ok, Confidence} = graphdb_attr:create_literal_attribute("conf", float), FwdOnly = #{attribute => Source, value => "research-paper"}, RevOnly = #{attribute => Confidence, value => 0.42}, - ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl, + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl, {[FwdOnly], [RevOnly]}), {atomic, ARels} = mnesia:transaction(fun() -> @@ -985,11 +1017,11 @@ add_relationship_avps_are_per_direction(_Config) -> add_relationship_default_avps_empty(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), {atomic, ARels} = mnesia:transaction(fun() -> mnesia:index_read(relationships, A, #relationship.source_nref) @@ -1005,16 +1037,16 @@ add_relationship_default_avps_empty(_Config) -> %%----------------------------------------------------------------------------- add_relationship_refuses_retired_endpoint(_Config) -> {ok, ClassNref} = graphdb_class:create_class("ArcClass", 3), - {ok, Src, _} = graphdb_instance:create_instance("s", ClassNref, 3), - {ok, Tgt, _} = graphdb_instance:create_instance("t", ClassNref, 3), + {ok, Src, _} = graphdb_instance:create_instance(sess(), "s", ClassNref, 3), + {ok, Tgt, _} = graphdb_instance:create_instance(sess(), "t", ClassNref, 3), {ok, {Fwd, Rec}} = graphdb_attr:create_relationship_attribute_pair("Likes", "LikedBy", instance), - ok = graphdb_instance:add_relationship(Src, Fwd, Tgt, Rec), + ok = graphdb_instance:add_relationship(sess(), Src, Fwd, Tgt, Rec), ok = graphdb_mgr:retire_node(Tgt), - {ok, Tgt2, _} = graphdb_instance:create_instance("t2", ClassNref, 3), + {ok, Tgt2, _} = graphdb_instance:create_instance(sess(), "t2", ClassNref, 3), ok = graphdb_mgr:retire_node(Tgt2), ?assertEqual({error, {endpoint_retired, Tgt2}}, - graphdb_instance:add_relationship(Src, Fwd, Tgt2, Rec)). + graphdb_instance:add_relationship(sess(), Src, Fwd, Tgt2, Rec)). %%----------------------------------------------------------------------------- @@ -1022,7 +1054,7 @@ add_relationship_refuses_retired_endpoint(_Config) -> %%----------------------------------------------------------------------------- class_of_returns_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Color", 3), - {ok, InstNref, _} = graphdb_instance:create_instance("Red", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Red", ClassNref, 5), ?assertEqual({ok, ClassNref}, graphdb_instance:class_of(InstNref)). @@ -1035,7 +1067,7 @@ class_of_returns_class(_Config) -> %%----------------------------------------------------------------------------- get_instance_returns_node(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Widget", 3), - {ok, InstNref, _} = graphdb_instance:create_instance("W1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "W1", ClassNref, 5), {ok, Node} = graphdb_instance:get_instance(InstNref), ?assertEqual(InstNref, Node#node.nref), ?assertEqual(instance, Node#node.kind). @@ -1063,9 +1095,9 @@ get_instance_rejects_non_instance(_Config) -> %%----------------------------------------------------------------------------- children_returns_instance_children(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Car", 3), - {ok, Car, _} = graphdb_instance:create_instance("MyCar", ClassNref, 5), - {ok, Engine, _} = graphdb_instance:create_instance("Engine1", ClassNref, Car), - {ok, Wheel, _} = graphdb_instance:create_instance("Wheel1", ClassNref, Car), + {ok, Car, _} = graphdb_instance:create_instance(sess(), "MyCar", ClassNref, 5), + {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine1", ClassNref, Car), + {ok, Wheel, _} = graphdb_instance:create_instance(sess(), "Wheel1", ClassNref, Car), {ok, Kids} = graphdb_instance:children(Car), KidNrefs = lists:sort([N#node.nref || N <- Kids]), ?assertEqual(lists:sort([Engine, Wheel]), KidNrefs). @@ -1075,7 +1107,7 @@ children_returns_instance_children(_Config) -> %%----------------------------------------------------------------------------- children_empty_for_leaf(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Leaf", 3), - {ok, Leaf, _} = graphdb_instance:create_instance("Leaf1", ClassNref, 5), + {ok, Leaf, _} = graphdb_instance:create_instance(sess(), "Leaf1", ClassNref, 5), ?assertEqual({ok, []}, graphdb_instance:children(Leaf)). %%----------------------------------------------------------------------------- @@ -1083,9 +1115,9 @@ children_empty_for_leaf(_Config) -> %%----------------------------------------------------------------------------- ancestors_returns_chain(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", 3), - {ok, Car, _} = graphdb_instance:create_instance("Car", ClassNref, 5), - {ok, Engine, _} = graphdb_instance:create_instance("Engine", ClassNref, Car), - {ok, Block, _} = graphdb_instance:create_instance("Block", ClassNref, Engine), + {ok, Car, _} = graphdb_instance:create_instance(sess(), "Car", ClassNref, 5), + {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine", ClassNref, Car), + {ok, Block, _} = graphdb_instance:create_instance(sess(), "Block", ClassNref, Engine), {ok, Ancestors} = graphdb_instance:compositional_ancestors(Block), AncNrefs = [N#node.nref || N <- Ancestors], %% Nearest-first: Engine, then Car @@ -1097,7 +1129,7 @@ ancestors_returns_chain(_Config) -> %%----------------------------------------------------------------------------- ancestors_empty_for_top_level(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Top", 3), - {ok, Top, _} = graphdb_instance:create_instance("Top1", ClassNref, 5), + {ok, Top, _} = graphdb_instance:create_instance(sess(), "Top1", ClassNref, 5), ?assertEqual({ok, []}, graphdb_instance:compositional_ancestors(Top)). @@ -1110,7 +1142,7 @@ ancestors_empty_for_top_level(_Config) -> %%----------------------------------------------------------------------------- resolve_value_local(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, InstNref, _} = graphdb_instance:create_instance("T1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "T1", ClassNref, 5), %% The name attribute (20) was set by create_instance ?assertMatch({ok, "T1", _}, graphdb_instance:resolve_value(InstNref, ?NAME_ATTR_INSTANCE)). @@ -1123,7 +1155,7 @@ resolve_value_from_class(_Config) -> %% Add a custom AVP directly to the class node {ok, TestAttr} = graphdb_attr:create_literal_attribute("shade", string), set_avp(ClassNref, TestAttr, "blue"), - {ok, InstNref, _} = graphdb_instance:create_instance("C1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "C1", ClassNref, 5), %% Instance doesn't have shade — resolved from class ?assertMatch({ok, "blue", _}, graphdb_instance:resolve_value(InstNref, TestAttr)). @@ -1134,10 +1166,10 @@ resolve_value_from_class(_Config) -> resolve_value_from_ancestor(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("location", string), - {ok, Car, _} = graphdb_instance:create_instance("Car", ClassNref, 5), + {ok, Car, _} = graphdb_instance:create_instance(sess(), "Car", ClassNref, 5), set_avp(Car, TestAttr, "garage"), - {ok, Engine, _} = graphdb_instance:create_instance("Engine", ClassNref, Car), - {ok, Block, _} = graphdb_instance:create_instance("Block", ClassNref, Engine), + {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine", ClassNref, Car), + {ok, Block, _} = graphdb_instance:create_instance(sess(), "Block", ClassNref, Engine), %% Block doesn't have location, Engine doesn't — resolved from Car ?assertMatch({ok, "garage", _}, graphdb_instance:resolve_value(Block, TestAttr)). @@ -1148,12 +1180,12 @@ resolve_value_from_ancestor(_Config) -> resolve_value_from_connected(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("country", string), - {ok, Ford, _} = graphdb_instance:create_instance("Ford", ClassNref, 5), + {ok, Ford, _} = graphdb_instance:create_instance(sess(), "Ford", ClassNref, 5), set_avp(Ford, TestAttr, "USA"), - {ok, Taurus, _} = graphdb_instance:create_instance("Taurus", ClassNref, 5), + {ok, Taurus, _} = graphdb_instance:create_instance(sess(), "Taurus", ClassNref, 5), {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), - ok = graphdb_instance:add_relationship(Taurus, MadeByNref, Ford, MakesNref), + ok = graphdb_instance:add_relationship(sess(), Taurus, MadeByNref, Ford, MakesNref), %% Taurus doesn't have country, its class doesn't, no ancestors have it %% — resolved from connected Ford ?assertMatch({ok, "USA", _}, @@ -1164,7 +1196,7 @@ resolve_value_from_connected(_Config) -> %%----------------------------------------------------------------------------- resolve_value_not_found(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Empty", 3), - {ok, InstNref, _} = graphdb_instance:create_instance("E1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "E1", ClassNref, 5), ?assertEqual(not_found, graphdb_instance:resolve_value(InstNref, 99999)). @@ -1175,7 +1207,7 @@ resolve_value_priority_local_over_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Color", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("hue", string), set_avp(ClassNref, TestAttr, "class_hue"), - {ok, InstNref, _} = graphdb_instance:create_instance("C1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "C1", ClassNref, 5), set_avp(InstNref, TestAttr, "local_hue"), ?assertMatch({ok, "local_hue", _}, graphdb_instance:resolve_value(InstNref, TestAttr)). @@ -1187,9 +1219,9 @@ resolve_value_priority_class_over_ancestor(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("weight", string), set_avp(ClassNref, TestAttr, "class_weight"), - {ok, Parent, _} = graphdb_instance:create_instance("P1", ClassNref, 5), + {ok, Parent, _} = graphdb_instance:create_instance(sess(), "P1", ClassNref, 5), set_avp(Parent, TestAttr, "parent_weight"), - {ok, Child, _} = graphdb_instance:create_instance("C1", ClassNref, Parent), + {ok, Child, _} = graphdb_instance:create_instance(sess(), "C1", ClassNref, Parent), %% Child has no local value; class has weight; parent has weight %% Class (priority 2) should win over parent (priority 3) ?assertMatch({ok, "class_weight", _}, @@ -1201,14 +1233,14 @@ resolve_value_priority_class_over_ancestor(_Config) -> resolve_value_priority_ancestor_over_connected(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("region", string), - {ok, Parent, _} = graphdb_instance:create_instance("Parent", ClassNref, 5), + {ok, Parent, _} = graphdb_instance:create_instance(sess(), "Parent", ClassNref, 5), set_avp(Parent, TestAttr, "ancestor_region"), - {ok, Child, _} = graphdb_instance:create_instance("Child", ClassNref, Parent), - {ok, Peer, _} = graphdb_instance:create_instance("Peer", ClassNref, 5), + {ok, Child, _} = graphdb_instance:create_instance(sess(), "Child", ClassNref, Parent), + {ok, Peer, _} = graphdb_instance:create_instance(sess(), "Peer", ClassNref, 5), set_avp(Peer, TestAttr, "peer_region"), {ok, {LinksNref, LinkedByNref}} = graphdb_attr:create_relationship_attribute_pair("Links", "LinkedBy", instance), - ok = graphdb_instance:add_relationship(Child, LinksNref, Peer, LinkedByNref), + ok = graphdb_instance:add_relationship(sess(), Child, LinksNref, Peer, LinkedByNref), %% Child has no local value, class has no value %% Ancestor Parent (priority 3) should win over connected Peer (priority 4) ?assertMatch({ok, "ancestor_region", _}, @@ -1226,7 +1258,7 @@ resolve_value_walks_class_taxonomy(_Config) -> {ok, TestAttr} = graphdb_attr:create_literal_attribute("kingdom", string), %% Bind kingdom only on the topmost class set_avp(AnimalNref, TestAttr, "Animalia"), - {ok, Rex, _} = graphdb_instance:create_instance("Rex", DogNref, 5), + {ok, Rex, _} = graphdb_instance:create_instance(sess(), "Rex", DogNref, 5), ?assertMatch({ok, "Animalia", _}, graphdb_instance:resolve_value(Rex, TestAttr)). @@ -1240,7 +1272,7 @@ resolve_value_local_class_overrides_taxonomy_ancestor(_Config) -> {ok, TestAttr} = graphdb_attr:create_literal_attribute("class_color", string), set_avp(AnimalNref, TestAttr, "from_animal"), set_avp(DogNref, TestAttr, "from_dog"), - {ok, Rex, _} = graphdb_instance:create_instance("Rex", DogNref, 5), + {ok, Rex, _} = graphdb_instance:create_instance(sess(), "Rex", DogNref, 5), ?assertMatch({ok, "from_dog", _}, graphdb_instance:resolve_value(Rex, TestAttr)). @@ -1254,7 +1286,7 @@ resolve_value_p4_ignores_compositional_arc(_Config) -> {ok, TestAttr} = graphdb_attr:create_literal_attribute("color", string), %% Bind color directly on the Projects category (nref 5) set_avp(5, TestAttr, "category_color"), - {ok, InstNref, _} = graphdb_instance:create_instance("W1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "W1", ClassNref, 5), %% Local: no. Class: no. Ancestors: P3 stops at category 5 %% (non-instance). P4 must not pick up category 5's AVP via the %% parent_arc — only true connection arcs count. @@ -1269,7 +1301,7 @@ resolve_value_source_local(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), {ok, AttrNref} = graphdb_attr:create_literal_attribute("weight", number), ok = graphdb_class:add_qualifying_characteristic(ClassNref, AttrNref), - {ok, InstNref, _} = graphdb_instance:create_instance( + {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Taurus", ClassNref, ?NREF_PROJECTS), set_avp(InstNref, AttrNref, 3500), ?assertEqual({ok, 3500, local}, @@ -1284,7 +1316,7 @@ resolve_value_source_class(_Config) -> {ok, AttrN} = graphdb_attr:create_literal_attribute("weight", number), ok = graphdb_class:add_qualifying_characteristic(Veh, AttrN), ok = graphdb_class:bind_qc_value(Veh, AttrN, 3500), - {ok, InstN, _} = graphdb_instance:create_instance( + {ok, InstN, _} = graphdb_instance:create_instance(sess(), "Taurus", Veh, ?NREF_PROJECTS), ?assertEqual({ok, 3500, {class, Veh}}, graphdb_instance:resolve_value(InstN, AttrN)). @@ -1296,12 +1328,12 @@ resolve_value_source_class(_Config) -> resolve_value_source_ancestor(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", ?NREF_CLASSES), {ok, TestAttr} = graphdb_attr:create_literal_attribute("location", string), - {ok, Car, _} = graphdb_instance:create_instance( + {ok, Car, _} = graphdb_instance:create_instance(sess(), "Car", ClassNref, ?NREF_PROJECTS), set_avp(Car, TestAttr, "garage"), - {ok, Engine, _} = graphdb_instance:create_instance( + {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine", ClassNref, Car), - {ok, Block, _} = graphdb_instance:create_instance( + {ok, Block, _} = graphdb_instance:create_instance(sess(), "Block", ClassNref, Engine), ?assertEqual({ok, "garage", {compositional, Car}}, graphdb_instance:resolve_value(Block, TestAttr)). @@ -1313,14 +1345,14 @@ resolve_value_source_ancestor(_Config) -> resolve_value_source_connected(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", ?NREF_CLASSES), {ok, TestAttr} = graphdb_attr:create_literal_attribute("country", string), - {ok, Ford, _} = graphdb_instance:create_instance( + {ok, Ford, _} = graphdb_instance:create_instance(sess(), "Ford", ClassNref, ?NREF_PROJECTS), set_avp(Ford, TestAttr, "USA"), - {ok, Taurus, _} = graphdb_instance:create_instance( + {ok, Taurus, _} = graphdb_instance:create_instance(sess(), "Taurus", ClassNref, ?NREF_PROJECTS), {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), - ok = graphdb_instance:add_relationship(Taurus, MadeByNref, Ford, MakesNref), + ok = graphdb_instance:add_relationship(sess(), Taurus, MadeByNref, Ford, MakesNref), ?assertEqual({ok, "USA", {connected, Ford}}, graphdb_instance:resolve_value(Taurus, TestAttr)). @@ -1336,9 +1368,9 @@ resolve_value_source_connected(_Config) -> add_class_membership_basic(_Config) -> {ok, ClassA} = graphdb_class:create_class("Vehicle", 3), {ok, ClassB} = graphdb_class:create_class("Toy", 3), - {ok, Inst, _} = graphdb_instance:create_instance("ToyCar", ClassA, 5), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "ToyCar", ClassA, 5), ?assertEqual({ok, [ClassA]}, graphdb_instance:class_memberships(Inst)), - ?assertEqual(ok, graphdb_instance:add_class_membership(Inst, ClassB)), + ?assertEqual(ok, graphdb_instance:add_class_membership(sess(), Inst, ClassB)), ?assertEqual({ok, [ClassA, ClassB]}, graphdb_instance:class_memberships(Inst)). @@ -1348,8 +1380,8 @@ add_class_membership_basic(_Config) -> add_class_membership_writes_arcs(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), {ok, ClassB} = graphdb_class:create_class("B", 3), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), - ok = graphdb_instance:add_class_membership(Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), %% Instance -> ClassB (char=29, reciprocal=30) {atomic, InstOut} = mnesia:transaction(fun() -> @@ -1378,10 +1410,10 @@ add_class_membership_writes_arcs(_Config) -> add_class_membership_idempotent(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), {ok, ClassB} = graphdb_class:create_class("B", 3), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), - ok = graphdb_instance:add_class_membership(Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), RelsBefore = mnesia:table_info(relationships, size), - ok = graphdb_instance:add_class_membership(Inst, ClassB), + ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), RelsAfter = mnesia:table_info(relationships, size), ?assertEqual(RelsBefore, RelsAfter), ?assertEqual({ok, [ClassA, ClassB]}, @@ -1393,7 +1425,7 @@ add_class_membership_idempotent(_Config) -> add_class_membership_rejects_missing_instance(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), ?assertEqual({error, not_found}, - graphdb_instance:add_class_membership(99999, ClassA)). + graphdb_instance:add_class_membership(sess(), 99999, ClassA)). %%----------------------------------------------------------------------------- %% Non-instance subject (e.g., a class node) is rejected. @@ -1401,26 +1433,26 @@ add_class_membership_rejects_missing_instance(_Config) -> add_class_membership_rejects_non_instance(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), ?assertEqual({error, not_an_instance}, - graphdb_instance:add_class_membership(ClassA, ClassA)). + graphdb_instance:add_class_membership(sess(), ClassA, ClassA)). %%----------------------------------------------------------------------------- %% Missing class target is rejected. %%----------------------------------------------------------------------------- add_class_membership_rejects_missing_class(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), ?assertEqual({error, class_not_found}, - graphdb_instance:add_class_membership(Inst, 99999)). + graphdb_instance:add_class_membership(sess(), Inst, 99999)). %%----------------------------------------------------------------------------- %% Non-class target (e.g., an attribute node) is rejected. %%----------------------------------------------------------------------------- add_class_membership_rejects_non_class_target(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), %% Nref 6 (Names) is an attribute node ?assertMatch({error, {not_a_class, attribute}}, - graphdb_instance:add_class_membership(Inst, 6)). + graphdb_instance:add_class_membership(sess(), Inst, 6)). %%----------------------------------------------------------------------------- %% A non-instantiable (abstract) class target is rejected — an instance @@ -1430,12 +1462,12 @@ add_class_membership_rejects_non_class_target(_Config) -> add_class_membership_refuses_abstract_class(_Config) -> {ok, #{instantiable := Inst}} = graphdb_attr:seeded_nrefs(), {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Instance, _} = graphdb_instance:create_instance("X", ClassA, 5), + {ok, Instance, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), {ok, Abstract} = graphdb_class:create_class("Meta", 3, [#{attribute => Inst, value => false}]), RelsBefore = mnesia:table_info(relationships, size), ?assertEqual({error, {class_not_instantiable, Abstract}}, - graphdb_instance:add_class_membership(Instance, Abstract)), + graphdb_instance:add_class_membership(sess(), Instance, Abstract)), ?assertEqual(RelsBefore, mnesia:table_info(relationships, size)). %%----------------------------------------------------------------------------- @@ -1444,17 +1476,17 @@ add_class_membership_refuses_abstract_class(_Config) -> add_class_membership_refuses_retired_class(_Config) -> {ok, ClassA} = graphdb_class:create_class("MemA", 3), {ok, ClassB} = graphdb_class:create_class("MemB", 3), - {ok, Inst, _} = graphdb_instance:create_instance("m", ClassA, 3), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "m", ClassA, 3), ok = graphdb_mgr:retire_node(ClassB), ?assertEqual({error, {class_retired, ClassB}}, - graphdb_instance:add_class_membership(Inst, ClassB)). + graphdb_instance:add_class_membership(sess(), Inst, ClassB)). %%----------------------------------------------------------------------------- %% After create_instance/3, class_memberships/1 returns the single class. %%----------------------------------------------------------------------------- class_memberships_initial(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), ?assertEqual({ok, [ClassA]}, graphdb_instance:class_memberships(Inst)). @@ -1471,8 +1503,8 @@ resolve_value_unique_across_two_classes(_Config) -> {ok, ClassB} = graphdb_class:create_class("Tag", 3), {ok, Attr} = graphdb_attr:create_literal_attribute("badge", string), set_avp(ClassA, Attr, "blue_badge"), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), - ok = graphdb_instance:add_class_membership(Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), ?assertMatch({ok, "blue_badge", _}, graphdb_instance:resolve_value(Inst, Attr)). @@ -1486,8 +1518,8 @@ resolve_value_same_value_two_classes(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("colour", string), set_avp(ClassA, Attr, "red"), set_avp(ClassB, Attr, "red"), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), - ok = graphdb_instance:add_class_membership(Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), ?assertMatch({ok, "red", _}, graphdb_instance:resolve_value(Inst, Attr)). @@ -1501,8 +1533,8 @@ resolve_value_ambiguous_two_classes(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("flavour", string), set_avp(ClassA, Attr, "sweet"), set_avp(ClassB, Attr, "salty"), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), - ok = graphdb_instance:add_class_membership(Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), Result = graphdb_instance:resolve_value(Inst, Attr), ?assertMatch({error, {ambiguous_class_value, Attr, _}}, Result), {error, {ambiguous_class_value, _, Hits}} = Result, @@ -1520,8 +1552,8 @@ resolve_value_local_overrides_ambiguity(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("flavour", string), set_avp(ClassA, Attr, "sweet"), set_avp(ClassB, Attr, "salty"), - {ok, Inst, _} = graphdb_instance:create_instance("X", ClassA, 5), - ok = graphdb_instance:add_class_membership(Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), set_avp(Inst, Attr, "umami"), ?assertMatch({ok, "umami", _}, graphdb_instance:resolve_value(Inst, Attr)). @@ -1539,8 +1571,8 @@ resolve_value_ambiguity_via_taxonomy(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("origin", string), set_avp(AnimalCls, Attr, "biological"), set_avp(ToyCls, Attr, "manufactured"), - {ok, Inst, _} = graphdb_instance:create_instance("Plushie", MammalCls, 5), - ok = graphdb_instance:add_class_membership(Inst, ToyCls), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "Plushie", MammalCls, 5), + ok = graphdb_instance:add_class_membership(sess(), Inst, ToyCls), Result = graphdb_instance:resolve_value(Inst, Attr), ?assertMatch({error, {ambiguous_class_value, Attr, _}}, Result), {error, {ambiguous_class_value, _, Hits}} = Result, @@ -1558,7 +1590,7 @@ resolve_value_ambiguity_via_taxonomy(_Config) -> %%----------------------------------------------------------------------------- firing_no_rules_baseline(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Plain", 3), - {ok, Nref, Report} = graphdb_instance:create_instance("p1", ClassNref, 5), + {ok, Nref, Report} = graphdb_instance:create_instance(sess(), "p1", ClassNref, 5), ?assert(is_integer(Nref)), ?assertEqual([], Report). @@ -1569,7 +1601,7 @@ firing_single_mandatory(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OB", Owner, Bolt, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), %% one Bolt child created, reported fired under the rule {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(1, length(Kids)), @@ -1587,7 +1619,7 @@ firing_mandatory_mult(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OB", Owner, Bolt, mandatory, {3, 3}), {ok, _Root, [#{deployment := Dep, outcomes := Outs}]} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(3, length(Outs)), ?assertEqual([1, 2, 3], [maps:get(index, O) || O <- Outs]), %% report carries the rule's real deployment map @@ -1603,7 +1635,7 @@ firing_mandatory_cascade_atomic(Config) -> environment, "OB", Owner, Bolt, mandatory, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "BW", Bolt, Widget, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), {ok, [BoltInst]} = graphdb_instance:children(Root), BoltNref = element(2, BoltInst), {ok, [_Widget]} = graphdb_instance:children(BoltNref), @@ -1620,7 +1652,7 @@ firing_mandatory_failure_rolls_back(Config) -> environment, "OA", Owner, Abstract, mandatory, {1, 1}), Before = mnesia:table_info(nodes, size), {error, {class_not_instantiable, Abstract}, Report} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(Before, mnesia:table_info(nodes, size)), %% nothing written %% culprit rule has a failed outcome in the report ?assert(lists:any( @@ -1637,7 +1669,7 @@ firing_auto_best_effort(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBauto", Owner, Bolt, auto, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), {ok, [_]} = graphdb_instance:children(Root), %% auto child created ?assertEqual(#{fired => 1, failed => 0, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, @@ -1651,7 +1683,7 @@ firing_auto_failure_survives(Config) -> {Owner, Abstract} = ?config(oa, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OAauto", Owner, Abstract, auto, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assert(is_integer(Root)), %% root survived ?assertEqual(#{fired => 0, failed => 1, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, @@ -1667,7 +1699,7 @@ firing_auto_cascade_merges(Config) -> environment, "OBauto", Owner, Bolt, auto, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "BW", Bolt, Widget, mandatory, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), %% the auto Bolt and its mandatory Widget both fired ?assertEqual(#{fired => 2, failed => 0, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, @@ -1681,7 +1713,7 @@ firing_propose_outcome_in_report(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), %% no child materialised ?assertEqual({ok, []}, graphdb_instance:children(Root)), %% exactly one proposed outcome, owner=Root, proposed_class=Bolt, no child key @@ -1701,7 +1733,7 @@ firing_propose_not_materialised(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {3, 3}), Before = mnesia:table_info(nodes, size), - {ok, _Root, _Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, _Root, _Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), After = mnesia:table_info(nodes, size), ?assertEqual(Before + 1, After). %% only the root, no proposed children @@ -1715,7 +1747,7 @@ firing_propose_multiplicity_bounded(Config) -> environment, "OBpropose", Owner, Bolt, propose, {3, 3}, undefined, #{name_pattern => "Spare {i}"}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(3, length(Outs)), ?assertEqual([1, 2, 3], [maps:get(index, O) || O <- Outs]), ?assertEqual(["Spare 1", "Spare 2", "Spare 3"], @@ -1731,7 +1763,7 @@ firing_propose_multiplicity_unbounded(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {1, unbounded}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(1, length(Outs)), [#{index := Idx, status := proposed, max := Max}] = Outs, ?assertEqual(1, Idx), @@ -1746,7 +1778,7 @@ firing_propose_on_path_cut(Config) -> {Owner, _Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "selfpropose", Owner, Owner, propose, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual([], Report). %%----------------------------------------------------------------------------- @@ -1756,7 +1788,7 @@ firing_propose_summarize(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {2, 2}), - {ok, _Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(#{fired => 0, failed => 0, not_attempted => 0, proposed => 2, connected => 0, required => 0, not_connected => 0}, graphdb_instance:summarize(Report)). @@ -1776,7 +1808,7 @@ firing_propose_with_mandatory_and_auto(Config) -> environment, "aut", Owner, Widget, auto, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "pro", Owner, Gizmo, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), %% two children materialised (mandatory Bolt + auto Widget), Gizmo is not {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(2, length(Kids)), @@ -1795,7 +1827,7 @@ firing_propose_owner_is_materialised_child(Config) -> environment, "OB", Owner, Bolt, mandatory, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "BWpropose", Bolt, Widget, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), %% the materialised mandatory child {ok, [BoltInst]} = graphdb_instance:children(Root), BoltNref = element(2, BoltInst), @@ -1818,7 +1850,7 @@ firing_propose_carries_max(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OBp3-5", Owner, Bolt, propose, {3, 5}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(3, length(Outs)), ?assertEqual([1, 2, 3], [maps:get(index, O) || O <- Outs]), ?assert(lists:all(fun(O) -> maps:get(max, O) =:= 5 end, Outs)), @@ -1834,7 +1866,7 @@ firing_propose_min_zero_surfaces_none(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBp0-3", Owner, Bolt, propose, {0, 3}), - {ok, _Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(0, maps:get(proposed, graphdb_instance:summarize(Report))). %%----------------------------------------------------------------------------- @@ -1845,7 +1877,7 @@ firing_mandatory_mints_min(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OB2-5", Owner, Bolt, mandatory, {2, 5}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), Fired = [O || O <- Outs, maps:get(status, O) =:= fired], ?assertEqual(2, length(Fired)), ?assertEqual([1, 2], [maps:get(index, O) || O <- Fired]). @@ -1857,7 +1889,7 @@ firing_mandatory_min_zero_mints_none(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OB0-3", Owner, Bolt, mandatory, {0, 3}), - {ok, _Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), ?assertEqual(#{fired => 0, failed => 0, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, graphdb_instance:summarize(Report)). @@ -1871,7 +1903,7 @@ firing_mandatory_min_unbounded_mints_min(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OB1-U", Owner, Bolt, mandatory, {1, unbounded}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), Fired = [O || O <- Outs, maps:get(status, O) =:= fired], ?assertEqual(1, length(Fired)), ?assert(lists:all(fun(O) -> @@ -1886,7 +1918,7 @@ firing_auto_mints_min(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OBauto2-5", Owner, Bolt, auto, {2, 5}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance("car", Owner, 5), + graphdb_instance:create_instance(sess(), "car", Owner, 5), Fired = [O || O <- Outs, maps:get(status, O) =:= fired], ?assertEqual(2, length(Fired)). @@ -1897,7 +1929,7 @@ firing_auto_min_zero_unbounded(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBauto0-U", Owner, Bolt, auto, {0, unbounded}), - {ok, _Root, Report} = graphdb_instance:create_instance("car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), Outs = lists:append([maps:get(outcomes, RR) || RR <- Report]), ?assertEqual([], [O || O <- Outs, maps:get(reason, O, none) =:= unbounded_multiplicity_not_fireable]), @@ -1987,7 +2019,7 @@ firing_conn_report_only_mandatory(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car1", Src, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), ?assert(is_integer(Root)), %% create succeeded ?assertEqual([], b4_conn_targets(Root, Char)), %% nothing connected O = b4_single_outcome(Report), @@ -2001,14 +2033,14 @@ firing_conn_report_only_auto(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, auto, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance("car1", Src, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), ?assertEqual(not_connected, maps:get(status, b4_single_outcome(Report))). firing_conn_report_only_propose(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car1", Src, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), ?assertEqual([], b4_conn_targets(Root, Char)), ?assertEqual(proposed, maps:get(status, b4_single_outcome(Report))). @@ -2018,7 +2050,7 @@ firing_conn_explicit_defer(_Config) -> {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), R = fun(_Ctx) -> defer end, - {ok, Root, Report} = graphdb_instance:create_instance("car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual([], b4_conn_targets(Root, Char)), ?assertEqual(required, maps:get(status, b4_single_outcome(Report))). @@ -2027,7 +2059,7 @@ firing_conn_summarize(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance("car1", Src, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), S = graphdb_instance:summarize(Report), ?assertEqual(1, maps:get(required, S)), ?assertEqual(0, maps:get(connected, S)), @@ -2046,7 +2078,7 @@ firing_conn_mandatory_connected(_Config) -> environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), Target = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Target]} end, - {ok, Root, Report} = graphdb_instance:create_instance("car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual([Target], b4_conn_targets(Root, Char)), %% forward arc ?assertEqual([Root], b4_conn_targets(Target, Recip)), %% reverse arc O = b4_single_outcome(Report), @@ -2063,7 +2095,7 @@ firing_conn_mandatory_shortfall_fails(_Config) -> R = fun(_Ctx) -> {connect, []} end, Before = mnesia:table_info(nodes, size), {error, {mandatory_connection_unsatisfied, RuleNref}, Report} = - graphdb_instance:create_instance("car1", Src, 5, R), + graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual(Before, mnesia:table_info(nodes, size)), %% nothing written ?assert(lists:any( fun(#{outcomes := Os}) -> @@ -2080,7 +2112,7 @@ firing_conn_mandatory_invalid_target_fails(_Config) -> R = fun(_Ctx) -> {connect, [Wrong]} end, Before = mnesia:table_info(nodes, size), {error, {invalid_connection_target, _}, _Report} = - graphdb_instance:create_instance("car1", Src, 5, R), + graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual(Before, mnesia:table_info(nodes, size)). %% multiplicity {1,2}: resolver returns 3 valid -> exactly 2 written (cap=Max). @@ -2092,7 +2124,7 @@ firing_conn_mandatory_caps_at_max(_Config) -> T2 = b4_target_instance("m2", Tgt), T3 = b4_target_instance("m3", Tgt), R = fun(_Ctx) -> {connect, [T1, T2, T3]} end, - {ok, Root, _Report} = graphdb_instance:create_instance("car1", Src, 5, R), + {ok, Root, _Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual(2, length(b4_conn_targets(Root, Char))). %% rollback cause is discriminable: a class carrying BOTH a mandatory composition @@ -2115,7 +2147,7 @@ firing_conn_rollback_discriminable_composition(_Config) -> Mfr = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Mfr]} end, {error, {class_not_instantiable, Abstract}, Report} = - graphdb_instance:create_instance("car1", Src, 5, R), + graphdb_instance:create_instance(sess(), "car1", Src, 5, R), %% the lone failed outcome is a COMPOSITION culprit: carries no connection keys Failed = [O || #{outcomes := Os} <- Report, #{status := failed} = O <- Os], ?assertEqual(1, length(Failed)), @@ -2140,7 +2172,7 @@ firing_conn_rollback_discriminable_connection(_Config) -> R = fun(_Ctx) -> {connect, []} end, %% shortfall Before = mnesia:table_info(nodes, size), {error, {mandatory_connection_unsatisfied, _}, Report} = - graphdb_instance:create_instance("car1", Src, 5, R), + graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual(Before, mnesia:table_info(nodes, size)), %% lone failed outcome is a CONNECTION culprit (has characterization); %% the composition Bolt rule is not_attempted. @@ -2167,7 +2199,7 @@ firing_conn_descendant_in_root_txn(_Config) -> environment, "BM", Bolt, Char, Recip, Tgt, mandatory, {1, 1}), Mfr = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Mfr]} end, - {ok, Root, Report} = graphdb_instance:create_instance("car1", Owner, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Owner, 5, R), {ok, [BoltInst]} = graphdb_instance:children(Root), BoltNref = element(2, BoltInst), ?assertEqual([Mfr], b4_conn_targets(BoltNref, Char)), @@ -2189,7 +2221,7 @@ firing_conn_auto_connected(_Config) -> environment, "car-made-by", Src, Char, Recip, Tgt, auto, {1, 1}), Target = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Target]} end, - {ok, Root, Report} = graphdb_instance:create_instance("car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual([Target], b4_conn_targets(Root, Char)), ?assertEqual(connected, maps:get(status, b4_single_outcome(Report))). @@ -2201,7 +2233,7 @@ firing_conn_auto_invalid_survives(_Config) -> {ok, Other} = graphdb_class:create_class("Other", 3), Wrong = b4_target_instance("wrong", Other), R = fun(_Ctx) -> {connect, [Wrong]} end, - {ok, Root, Report} = graphdb_instance:create_instance("car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assert(is_integer(Root)), ?assertEqual([], b4_conn_targets(Root, Char)), ?assertEqual(failed, maps:get(status, b4_single_outcome(Report))). @@ -2219,7 +2251,7 @@ firing_conn_subclass_target_accepted(_Config) -> environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), Target = b4_target_instance("acme", SubMfr), R = fun(_Ctx) -> {connect, [Target]} end, - {ok, Root, Report} = graphdb_instance:create_instance("car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual([Target], b4_conn_targets(Root, Char)), ?assertEqual(connected, maps:get(status, b4_single_outcome(Report))). @@ -2231,7 +2263,7 @@ firing_conn_missing_target_fails(_Config) -> R = fun(_Ctx) -> {connect, [999999]} end, Before = mnesia:table_info(nodes, size), {error, {invalid_connection_target, {target_not_found, 999999}}, _Report} = - graphdb_instance:create_instance("car1", Src, 5, R), + graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual(Before, mnesia:table_info(nodes, size)). %% a non-instance target (a class nref) on a mandatory rule fails the create. @@ -2242,7 +2274,7 @@ firing_conn_non_instance_target_fails(_Config) -> R = fun(_Ctx) -> {connect, [Tgt]} end, %% Tgt is a class, not an instance Before = mnesia:table_info(nodes, size), {error, {invalid_connection_target, {target_not_an_instance, Tgt}}, _R} = - graphdb_instance:create_instance("car1", Src, 5, R), + graphdb_instance:create_instance(sess(), "car1", Src, 5, R), ?assertEqual(Before, mnesia:table_info(nodes, size)). %% resolver-supplied per-direction AVPs are stamped on the written arc. @@ -2254,7 +2286,7 @@ firing_conn_resolver_avps_stamped(_Config) -> FwdAVP = #{attribute => Char, value => "fwd-meta"}, RevAVP = #{attribute => Recip, value => "rev-meta"}, R = fun(_Ctx) -> {connect, [{Target, {[FwdAVP], [RevAVP]}}]} end, - {ok, Root, _Report} = graphdb_instance:create_instance("car1", Src, 5, R), + {ok, Root, _Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), Fwd = b4_conn_arc(Root, Char), Rev = b4_conn_arc(Target, Recip), ?assert(lists:member(FwdAVP, Fwd#relationship.avps)), @@ -2281,7 +2313,7 @@ b4_inst_attr() -> %% make a pre-existing target instance of class Tgt, parented at Projects (5). b4_target_instance(Name, Tgt) -> - {ok, Nref, _} = graphdb_instance:create_instance(Name, Tgt, 5), + {ok, Nref, _} = graphdb_instance:create_instance(sess(), Name, Tgt, 5), Nref. %% make a (Source, Target, Char, Recip) connection fixture; returns nrefs. @@ -2318,7 +2350,7 @@ b5_create_instance_5_accepts_resolvers(_Config) -> Conn = fun(_Ctx) -> defer end, Conflict = graphdb_rules:default_conflict_resolver(), {ok, Root, Report} = - graphdb_instance:create_instance("car", Vehicle, 5, Conn, Conflict), + graphdb_instance:create_instance(sess(), "car", Vehicle, 5, Conn, Conflict), {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(1, length(Kids)), ?assertEqual(#{fired => 1, failed => 0, not_attempted => 0, proposed => 0, @@ -2334,7 +2366,7 @@ b5_default_resolver_single_rule_unchanged(_Config) -> {ok, Engine} = graphdb_class:create_class("Engine", 3), {ok, _} = graphdb_rules:create_composition_rule( environment, "VE", Vehicle, Engine, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("car", Vehicle, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Vehicle, 5), {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(1, length(Kids)), ?assertEqual(1, length(Report)). @@ -2350,7 +2382,7 @@ b5_firing_same_level_mode_priority(_Config) -> environment, "CN-prop", Cell, Nucleus, propose, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "CN-mand", Cell, Nucleus, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance("c1", Cell, 5), + {ok, Root, Report} = graphdb_instance:create_instance(sess(), "c1", Cell, 5), {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(1, length(Kids)), %% exactly one Nucleus minted #{fired := 1, proposed := 0} = @@ -2368,7 +2400,7 @@ b5_firing_cross_level_shadow(_Config) -> environment, "CE", Car, Engine, mandatory, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "VE", Vehicle, Engine, mandatory, {1, 1}), - {ok, Root, _Report} = graphdb_instance:create_instance("car", Car, 5), + {ok, Root, _Report} = graphdb_instance:create_instance(sess(), "car", Car, 5), {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(1, length(Kids)). @@ -2387,7 +2419,7 @@ b5_custom_resolver_pure_additive(_Config) -> Additive = fun(#{rules := R}) -> R end, Conn = fun(_Ctx) -> defer end, {ok, Root, _Report} = - graphdb_instance:create_instance("car", Car, 5, Conn, Additive), + graphdb_instance:create_instance(sess(), "car", Car, 5, Conn, Additive), {ok, Kids} = graphdb_instance:children(Root), ?assertEqual(2, length(Kids)). %% additive: both fire @@ -2401,8 +2433,8 @@ b5_custom_resolver_pure_additive(_Config) -> re_setup() -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), #{class => ClassNref, tmpl => DefaultTmpl, a => A, b => B, @@ -2420,41 +2452,65 @@ re_count(A, Char, B) -> remove_relationship_basic(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), ?assertEqual(1, re_count(A, Char, B)), ?assertEqual(1, re_count(B, Recip, A)), - ok = graphdb_instance:remove_relationship(A, Char, B), + ok = graphdb_instance:remove_relationship(sess(), A, Char, B), ?assertEqual(0, re_count(A, Char, B)), ?assertEqual(0, re_count(B, Recip, A)). remove_relationship_not_found(_Config) -> #{a := A, b := B, char := Char} = re_setup(), ?assertEqual({error, relationship_not_found}, - graphdb_instance:remove_relationship(A, Char, B)). + graphdb_instance:remove_relationship(sess(), A, Char, B)). + +%% SP1: a project op requires a valid session. A non-session term is +%% rejected before any store access -- covers the tier-2 (plain-function) +%% session gate. +remove_relationship_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_session}, + graphdb_instance:remove_relationship(not_a_session, 1, 2, 3)). + +%% SP1: covers the gen_server-wrapper session gate (a distinct code path +%% from the tier-2 gate above): with_session/2 short-circuits before the +%% gen_server:call. +add_relationship_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_session}, + graphdb_instance:add_relationship(not_a_session, 1, 2, 3, 4)). + +%% SP1: the update-* family (tier-2) also rejects a bad session. +update_relationship_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_session}, + graphdb_instance:update_relationship(not_a_session, 1, 2, 3, [])). + +%% SP1: add_class_membership (gen_server-wrapper) also rejects a bad session. +add_class_membership_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_session}, + graphdb_instance:add_class_membership(not_a_session, 1, 2)). remove_relationship_ambiguous(_Config) -> #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), {ok, DefaultTmpl} = graphdb_class:default_template(Class), {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), - ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl), - ok = graphdb_instance:add_relationship(A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, AltTmpl), ?assertMatch({error, {ambiguous_relationship, [_, _]}}, - graphdb_instance:remove_relationship(A, Char, B)). + graphdb_instance:remove_relationship(sess(), A, Char, B)). remove_relationship_disambiguate_by_template(_Config) -> #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), {ok, DefaultTmpl} = graphdb_class:default_template(Class), {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), - ok = graphdb_instance:add_relationship(A, Char, B, Recip, DefaultTmpl), - ok = graphdb_instance:add_relationship(A, Char, B, Recip, AltTmpl), - ok = graphdb_instance:remove_relationship(A, Char, B, DefaultTmpl), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:remove_relationship(sess(), A, Char, B, DefaultTmpl), %% one edge (the AltTmpl one) remains in each direction ?assertEqual(1, re_count(A, Char, B)), ?assertEqual(1, re_count(B, Recip, A)). remove_relationship_dangling_half_edge(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), %% manually delete the reverse row, leaving a half-edge {atomic, ok} = mnesia:transaction(fun() -> Rows = mnesia:index_read(relationships, B, #relationship.source_nref), @@ -2464,7 +2520,7 @@ remove_relationship_dangling_half_edge(_Config) -> mnesia:delete_object(relationships, Rev, write) end), ?assertMatch({error, {dangling_half_edge, _}}, - graphdb_instance:remove_relationship(A, Char, B)), + graphdb_instance:remove_relationship(sess(), A, Char, B)), %% the forward row is NOT deleted -- rollback left it intact ?assertEqual(1, re_count(A, Char, B)). @@ -2487,8 +2543,8 @@ re_avps(A, Char, B) -> update_relationship_single_direction(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), {ok, Note} = graphdb_attr:create_literal_attribute("note", string), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), - ok = graphdb_instance:update_relationship(A, Char, B, + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:update_relationship(sess(), A, Char, B, [#{attribute => Note, value => "fwd"}]), ?assert(lists:member(#{attribute => Note, value => "fwd"}, re_avps(A, Char, B))), @@ -2502,9 +2558,9 @@ update_relationship_single_direction(_Config) -> update_relationship_reverse_direction(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), {ok, Note} = graphdb_attr:create_literal_attribute("note", string), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), %% name the reverse direction from the other endpoint: (T, R, S) - ok = graphdb_instance:update_relationship(B, Recip, A, + ok = graphdb_instance:update_relationship(sess(), B, Recip, A, [#{attribute => Note, value => "rev"}]), ?assert(lists:member(#{attribute => Note, value => "rev"}, re_avps(B, Recip, A))), @@ -2513,24 +2569,24 @@ update_relationship_reverse_direction(_Config) -> update_relationship_protects_template(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), ?assertEqual({error, {protected_relationship_avp, ?ARC_TEMPLATE}}, - graphdb_instance:update_relationship(A, Char, B, + graphdb_instance:update_relationship(sess(), A, Char, B, [#{attribute => ?ARC_TEMPLATE, value => 7}])). update_relationship_not_found(_Config) -> #{a := A, b := B, char := Char} = re_setup(), {ok, Note} = graphdb_attr:create_literal_attribute("note", string), ?assertEqual({error, relationship_not_found}, - graphdb_instance:update_relationship(A, Char, B, + graphdb_instance:update_relationship(sess(), A, Char, B, [#{attribute => Note, value => "x"}])). update_relationship_both_directions(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), {ok, FAttr} = graphdb_attr:create_literal_attribute("fwd_meta", string), {ok, RAttr} = graphdb_attr:create_literal_attribute("rev_meta", string), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), - ok = graphdb_instance:update_relationship_both(A, Char, B, + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:update_relationship_both(sess(), A, Char, B, {[#{attribute => FAttr, value => "F"}], [#{attribute => RAttr, value => "R"}]}), FwdAVPs = re_avps(A, Char, B), @@ -2539,3 +2595,50 @@ update_relationship_both_directions(_Config) -> ?assertNot(lists:member(#{attribute => RAttr, value => "R"}, FwdAVPs)), ?assert(lists:member(#{attribute => RAttr, value => "R"}, RevAVPs)), ?assertNot(lists:member(#{attribute => FAttr, value => "F"}, RevAVPs)). + + +%%============================================================================= +%% Proxy recognizer test cases +%%============================================================================= + +%%----------------------------------------------------------------------------- +%% A node carrying the "Remote Reference" class membership and both proxy AVPs +%% is recognised as a proxy; coordinates are extracted correctly. +%%----------------------------------------------------------------------------- +proxy_recognizer_identifies_proxy(_Config) -> + {ok, #{remote_project := RP, remote_nref := RN}} = graphdb_attr:seeded_nrefs(), + RRClass = graphdb_instance:remote_reference_class(), + Node = #node{nref = 999999001, kind = instance, classes = [RRClass], + attribute_value_pairs = + [#{attribute => RP, value => 5}, + #{attribute => RN, value => 42}]}, + ?assert(graphdb_instance:is_proxy(Node)), + ?assertEqual({ok, #{remote_project => 5, remote_nref => 42}}, + graphdb_instance:proxy_coordinates(Node)). + +%%----------------------------------------------------------------------------- +%% A plain instance with no class membership and no AVPs is not a proxy. +%%----------------------------------------------------------------------------- +proxy_recognizer_rejects_plain_instance(_Config) -> + Node = #node{nref = 999999002, kind = instance, classes = [], + attribute_value_pairs = []}, + ?assertNot(graphdb_instance:is_proxy(Node)), + ?assertEqual(not_a_proxy, graphdb_instance:proxy_coordinates(Node)). + +%%--------------------------------------------------------------------- +%% sess() -> Session +%% +%% SP1 test helper: returns a project session, memoised per test-case +%% process. Registers a project under Projects (nref 5) on first use and +%% opens a session against it; subsequent calls in the same process reuse it. +%%--------------------------------------------------------------------- +sess() -> + case get(sp1_session) of + undefined -> + {ok, P} = graphdb_project:register_project("SP1 test session"), + {ok, S} = graphdb_project:open_session(P), + put(sp1_session, S), + S; + S -> + S + end. diff --git a/apps/graphdb/test/graphdb_mgr_SUITE.erl b/apps/graphdb/test/graphdb_mgr_SUITE.erl index 38dc7f8..f933d50 100644 --- a/apps/graphdb/test/graphdb_mgr_SUITE.erl +++ b/apps/graphdb/test/graphdb_mgr_SUITE.erl @@ -694,7 +694,7 @@ create_class_delegates(_Config) -> %%----------------------------------------------------------------------------- create_instance_delegates(_Config) -> {ok, ClassNref} = graphdb_class:create_class("TestClass2", 3), - {ok, Nref, _} = graphdb_mgr:create_instance("TestInst", ClassNref, 5), + {ok, Nref, _} = graphdb_mgr:create_instance(sess(), "TestInst", ClassNref, 5), ?assert(is_integer(Nref)), {ok, Node} = graphdb_mgr:get_node(Nref), ?assertEqual(instance, Node#node.kind). @@ -706,14 +706,14 @@ create_instance_delegates(_Config) -> add_relationship_delegates(_Config) -> %% Create a class and two instances {ok, ClassNref} = graphdb_class:create_class("RelClass", 3), - {ok, InstA, _} = graphdb_instance:create_instance("A", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("B", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), %% Create a reciprocal relationship attribute pair (char/reciprocal nrefs) {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), %% Delegate through mgr ?assertEqual(ok, - graphdb_mgr:add_relationship(InstA, CharNref, InstB, RecipNref)), + graphdb_mgr:add_relationship(sess(), InstA, CharNref, InstB, RecipNref)), %% Verify the arc is readable {ok, Rels} = graphdb_mgr:get_relationships(InstA), Targets = [R#relationship.target_nref || R <- Rels, @@ -1030,8 +1030,8 @@ mutate_empty_batch(_Config) -> %%----------------------------------------------------------------------------- mutate_single_add_relationship(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MClassAR", 3), - {ok, InstA, _} = graphdb_instance:create_instance("MA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("MB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MB", ClassNref, 5), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MKnows", "MKnownBy", instance), @@ -1067,9 +1067,9 @@ mutate_single_retire_and_unretire(_Config) -> %%----------------------------------------------------------------------------- mutate_mixed_all_succeed(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MMixed", 3), - {ok, InstA, _} = graphdb_instance:create_instance("MMA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("MMB", ClassNref, 5), - {ok, InstC, _} = graphdb_instance:create_instance("MMC", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MMA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MMB", ClassNref, 5), + {ok, InstC, _} = graphdb_instance:create_instance(sess(), "MMC", ClassNref, 5), {ok, {Ch1, Re1}} = graphdb_attr:create_relationship_attribute_pair("MM1", "MM1r", instance), {ok, {Ch2, Re2}} = @@ -1093,8 +1093,8 @@ mutate_mixed_all_succeed(_Config) -> %%----------------------------------------------------------------------------- mutate_atomic_rollback(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MRollback", 3), - {ok, InstA, _} = graphdb_instance:create_instance("MRA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("MRB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MRA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MRB", ClassNref, 5), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MRKnows", "MRKnownBy", instance), @@ -1115,8 +1115,8 @@ mutate_atomic_rollback(_Config) -> %%----------------------------------------------------------------------------- mutate_read_your_writes_rollback(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MRYW", 3), - {ok, InstA, _} = graphdb_instance:create_instance("MRYWA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("MRYWB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MRYWA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MRYWB", ClassNref, 5), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MRYWK", "MRYWKr", instance), @@ -1135,8 +1135,8 @@ mutate_read_your_writes_rollback(_Config) -> %%----------------------------------------------------------------------------- mutate_malformed_term(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MBad", 3), - {ok, InstA, _} = graphdb_instance:create_instance("MBadA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("MBadB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MBadA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MBadB", ClassNref, 5), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MBadK", "MBadKr", instance), @@ -1170,8 +1170,8 @@ mutate_permanent_tier_guard(_Config) -> mutate_add_relationship_explicit_template(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MTmplClass", 3), {ok, AltTmpl} = graphdb_class:add_template(ClassNref, "msocial"), - {ok, A, _} = graphdb_instance:create_instance("MTA", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("MTB", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "MTA", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "MTB", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MTKnows", "MTKnownBy", instance), @@ -1195,8 +1195,8 @@ mutate_add_relationship_explicit_template(_Config) -> mutate_add_relationship_with_avps(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MAvpClass", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance("MAvA", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance("MAvB", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "MAvA", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "MAvB", ClassNref, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MAvKnows", "MAvKnownBy", instance), @@ -1231,7 +1231,7 @@ mutate_add_relationship_with_avps(_Config) -> %%----------------------------------------------------------------------------- mutate_single_update_node_avps(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MUAClass", 3), - {ok, Inst, _} = graphdb_instance:create_instance("MUAInst", ClassNref, 5), + {ok, Inst, _} = graphdb_instance:create_instance(sess(), "MUAInst", ClassNref, 5), {ok, Attr} = graphdb_attr:create_literal_attribute("MUAAttr", string), ?assertEqual({ok, [ok]}, graphdb_mgr:mutate([{update_node_avps, Inst, @@ -1244,8 +1244,8 @@ mutate_single_update_node_avps(_Config) -> %%----------------------------------------------------------------------------- mutate_mixed_add_rel_and_update_avps(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MMUAClass", 3), - {ok, InstA, _} = graphdb_instance:create_instance("MMUAA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("MMUAB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MMUAA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MMUAB", ClassNref, 5), {ok, {Ch, Re}} = graphdb_attr:create_relationship_attribute_pair("MMUAk", "MMUAkb", instance), @@ -1267,8 +1267,8 @@ mutate_mixed_add_rel_and_update_avps(_Config) -> %%----------------------------------------------------------------------------- mutate_update_avps_rollback(_Config) -> {ok, ClassNref} = graphdb_class:create_class("MUARbClass", 3), - {ok, InstA, _} = graphdb_instance:create_instance("MUARbA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance("MUARbB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MUARbA", ClassNref, 5), + {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MUARbB", ClassNref, 5), {ok, {Ch, Re}} = graphdb_attr:create_relationship_attribute_pair("MUARbk", "MUARbkb", instance), @@ -1315,7 +1315,7 @@ mutate_update_avps_not_found(_Config) -> ua_setup(Name) -> {ok, ClassNref} = graphdb_class:create_class("UAClass" ++ Name, 3), {ok, InstNref, _} = - graphdb_instance:create_instance("UAInst" ++ Name, ClassNref, 5), + graphdb_instance:create_instance(sess(), "UAInst" ++ Name, ClassNref, 5), {ok, AttrNref} = graphdb_attr:create_literal_attribute("UAAttr" ++ Name, string), {InstNref, AttrNref}. @@ -1488,12 +1488,12 @@ mutate_conn_count(Source, Char, Target) -> mutate_remove_relationship(_Config) -> {ok, Class} = graphdb_class:create_class("MRemoveOrg", 3), - {ok, A, _} = graphdb_instance:create_instance("MRA", Class, 5), - {ok, B, _} = graphdb_instance:create_instance("MRB", Class, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "MRA", Class, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "MRB", Class, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MRKnows", "MRKnownBy", instance), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), ?assertEqual(1, mutate_conn_count(A, Char, B)), ?assertEqual({ok, [ok]}, graphdb_mgr:mutate([{remove_relationship, A, Char, B}])), @@ -1503,13 +1503,13 @@ mutate_remove_relationship(_Config) -> mutate_update_relationship(_Config) -> {ok, Class} = graphdb_class:create_class("MUpdOrg", 3), - {ok, A, _} = graphdb_instance:create_instance("MUA", Class, 5), - {ok, B, _} = graphdb_instance:create_instance("MUB", Class, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "MUA", Class, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "MUB", Class, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MUKnows", "MUKnownBy", instance), {ok, Note} = graphdb_attr:create_literal_attribute("munote", string), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), ?assertEqual({ok, [ok, ok]}, graphdb_mgr:mutate([ {update_relationship, A, Char, B, [#{attribute => Note, value => "f"}]}, {update_relationship_both, A, Char, B, @@ -1519,12 +1519,12 @@ mutate_update_relationship(_Config) -> mutate_mixed_rollback(_Config) -> {ok, Class} = graphdb_class:create_class("MMixOrg", 3), - {ok, A, _} = graphdb_instance:create_instance("MMA", Class, 5), - {ok, B, _} = graphdb_instance:create_instance("MMB", Class, 5), + {ok, A, _} = graphdb_instance:create_instance(sess(), "MMA", Class, 5), + {ok, B, _} = graphdb_instance:create_instance(sess(), "MMB", Class, 5), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MMKnows", "MMKnownBy", instance), - ok = graphdb_instance:add_relationship(A, Char, B, Recip), + ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), %% second mutation removes a non-existent edge -> whole batch rolls back ?assertEqual({error, relationship_not_found}, graphdb_mgr:mutate([ {remove_relationship, A, Char, B}, @@ -1533,3 +1533,21 @@ mutate_mixed_rollback(_Config) -> ?assertEqual(1, mutate_conn_count(A, Char, B)), ?assertEqual(1, mutate_conn_count(B, Recip, A)), ok = graphdb_mgr:verify_caches(). + +%%--------------------------------------------------------------------- +%% sess() -> Session +%% +%% SP1 test helper: returns a project session, memoised per test-case +%% process. Registers a project under Projects (nref 5) on first use and +%% opens a session against it; subsequent calls in the same process reuse it. +%%--------------------------------------------------------------------- +sess() -> + case get(sp1_session) of + undefined -> + {ok, P} = graphdb_project:register_project("SP1 test session"), + {ok, S} = graphdb_project:open_session(P), + put(sp1_session, S), + S; + S -> + S + end. diff --git a/apps/graphdb/test/graphdb_ns_tests.erl b/apps/graphdb/test/graphdb_ns_tests.erl new file mode 100644 index 0000000..c376dc1 --- /dev/null +++ b/apps/graphdb/test/graphdb_ns_tests.erl @@ -0,0 +1,24 @@ +-module(graphdb_ns_tests). +-include_lib("eunit/include/eunit.hrl"). + +namespace_of_environment_roles_test() -> + [ ?assertEqual(environment, graphdb_ns:namespace_of(R)) + || R <- [characterization, reciprocal, avp_attribute, + node_classes, taxonomy_parent] ]. + +namespace_of_project_roles_test() -> + ?assertEqual(project, graphdb_ns:namespace_of(compositional_parent)). + +namespace_of_home_roles_test() -> + [ ?assertEqual(home, graphdb_ns:namespace_of(R)) + || R <- [node_nref, source_nref] ]. + +target_namespace_instance_is_project_test() -> + ?assertEqual(project, graphdb_ns:target_namespace(instance)). + +target_namespace_others_are_environment_test() -> + [ ?assertEqual(environment, graphdb_ns:target_namespace(K)) + || K <- [category, attribute, class] ]. + +namespace_of_unknown_role_crashes_test() -> + ?assertError(function_clause, graphdb_ns:namespace_of(bogus_role)). diff --git a/apps/graphdb/test/graphdb_project_SUITE.erl b/apps/graphdb/test/graphdb_project_SUITE.erl new file mode 100644 index 0000000..9316014 --- /dev/null +++ b/apps/graphdb/test/graphdb_project_SUITE.erl @@ -0,0 +1,280 @@ +%%--------------------------------------------------------------------- +%% Copyright (c) 2008 SeerStone, Inc. +%% Copyright (c) 2026 David W. Thomas +%% SPDX-License-Identifier: GPL-2.0-or-later +%%--------------------------------------------------------------------- +%% Author: David W. Thomas +%% Created: 2026-06-29 +%% Description: Common Test integration suite for graphdb_project. +%% Each test case gets its own isolated temp directory +%% with a fresh Mnesia database and nref allocator. +%% The full worker stack is started so graphdb_bootstrap +%% loads the scaffold; graphdb_project (a plain module, +%% not a gen_server) is then exercised directly. +%%--------------------------------------------------------------------- +%% Revision History +%%--------------------------------------------------------------------- +%% Rev PA1 Date: 2026-06-29 Author: David W. Thomas +%% Initial implementation: SP1 project registry tests. +%%--------------------------------------------------------------------- +-module(graphdb_project_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). +-include_lib("graphdb/include/graphdb_nrefs.hrl"). + + +%%--------------------------------------------------------------------- +%% Record definitions (match graphdb internal records — no shared hrl) +%%--------------------------------------------------------------------- +-record(node, { + nref, + kind, + parents = [], + classes = [], + attribute_value_pairs +}). + + +%%--------------------------------------------------------------------- +%% Common Test callbacks +%%--------------------------------------------------------------------- +-export([ + all/0, + suite/0, + init_per_suite/1, + end_per_suite/1, + init_per_group/2, + end_per_group/2, + init_per_testcase/2, + end_per_testcase/2 +]). + +%%--------------------------------------------------------------------- +%% Test cases +%%--------------------------------------------------------------------- +-export([ + register_project_creates_child_of_projects/1, + is_project_false_for_non_child/1, + open_session_on_registered_project/1, + open_session_rejects_non_project/1 +]). + + +%%============================================================================= +%% Common Test Callbacks +%%============================================================================= + +suite() -> + [{timetrap, {seconds, 30}}]. + +all() -> + [register_project_creates_child_of_projects, + is_project_false_for_non_child, + open_session_on_registered_project, + open_session_rejects_non_project]. + + +%%----------------------------------------------------------------------------- +%% init_per_suite/1 +%%----------------------------------------------------------------------------- +init_per_suite(Config) -> + {ok, OrigCwd} = file:get_cwd(), + ok = ensure_loaded(graphdb), + PrivDir = code:priv_dir(graphdb), + BootstrapFile = filename:join(PrivDir, "bootstrap.terms"), + true = filelib:is_file(BootstrapFile), + [{orig_cwd, OrigCwd}, {bootstrap_file, BootstrapFile} | Config]. + +end_per_suite(_Config) -> + ok. + + +%%----------------------------------------------------------------------------- +%% init_per_group/2 +%%----------------------------------------------------------------------------- +init_per_group(_Group, Config) -> + Config. + + +%%----------------------------------------------------------------------------- +%% end_per_group/2 +%%----------------------------------------------------------------------------- +end_per_group(_Group, _Config) -> + ok. + + +%%----------------------------------------------------------------------------- +%% init_per_testcase/2 +%%----------------------------------------------------------------------------- +init_per_testcase(_TC, Config) -> + Config1 = setup_isolated_env(Config), + BootstrapFile = proplists:get_value(bootstrap_file, Config), + application:set_env(seerstone_graph_db, bootstrap_file, BootstrapFile), + %% Start workers in dependency order (mirrors graphdb_instance_SUITE) + {ok, _} = rel_id_server:start_link(), + graphdb_nref:set_permanent_phase(), + {ok, _} = graphdb_nref:start_link(), + {ok, _} = graphdb_mgr:start_link(), + {ok, _} = graphdb_attr:start_link(), + {ok, _} = graphdb_class:start_link(), + {ok, _} = graphdb_instance:start_link(), + {ok, _} = graphdb_rules:start_link(), + %% Mirror production graphdb:start/2: flip to runtime tier after all + %% workers have seeded so that register_project allocates runtime nrefs. + ok = graphdb_nref:set_runtime_phase(), + Config1. + + +%%----------------------------------------------------------------------------- +%% end_per_testcase/2 +%%----------------------------------------------------------------------------- +end_per_testcase(TC, Config) -> + verify_cache_invariant(TC), + catch gen_server:stop(graphdb_rules), + catch gen_server:stop(graphdb_instance), + catch gen_server:stop(graphdb_class), + catch gen_server:stop(graphdb_attr), + catch gen_server:stop(graphdb_mgr), + catch gen_server:stop(graphdb_nref), + catch persistent_term:erase({graphdb_nref, phase}), + catch gen_server:stop(rel_id_server), + catch application:stop(nref), + catch mnesia:stop(), + catch dets:close(nref_server), + catch dets:close(nref_allocator), + catch dets:close(rel_id_server), + + OrigCwd = proplists:get_value(orig_cwd, Config), + ok = file:set_cwd(OrigCwd), + + TmpDir = proplists:get_value(tmp_dir, Config), + delete_dir_recursive(TmpDir), + + application:unset_env(seerstone_graph_db, bootstrap_file), + application:unset_env(mnesia, dir), + ok. + + +%%============================================================================= +%% Test Cases +%%============================================================================= + +%%----------------------------------------------------------------------------- +%% register_project creates a child-of-Projects instance node. +%%----------------------------------------------------------------------------- +register_project_creates_child_of_projects(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + ?assert(is_integer(P)), + ?assert(graphdb_project:is_project(P)), + {ok, #node{kind = Kind, parents = Parents}} = graphdb_mgr:get_node(P), + ?assertEqual(instance, Kind), + ?assert(lists:member(?NREF_PROJECTS, Parents)). + +%%----------------------------------------------------------------------------- +%% is_project returns false for a node that is NOT under Projects. +%%----------------------------------------------------------------------------- +is_project_false_for_non_child(_Config) -> + ?assertNot(graphdb_project:is_project(?NREF_CLASSES)). + +%%----------------------------------------------------------------------------- +%% open_session returns {ok, Session} for a registered project. +%%----------------------------------------------------------------------------- +open_session_on_registered_project(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, S} = graphdb_project:open_session(P), + ?assertEqual(P, graphdb_project:session_project(S)). + +%%----------------------------------------------------------------------------- +%% open_session returns {error, not_a_project} for a non-project nref. +%%----------------------------------------------------------------------------- +open_session_rejects_non_project(_Config) -> + ?assertEqual({error, not_a_project}, + graphdb_project:open_session(?NREF_CLASSES)). + + +%%============================================================================= +%% Internal Helpers +%%============================================================================= + +%%----------------------------------------------------------------------------- +%% ensure_loaded(App) -> ok +%%----------------------------------------------------------------------------- +ensure_loaded(App) -> + case application:load(App) of + ok -> ok; + {error, {already_loaded, App}} -> ok + end. + + +%%----------------------------------------------------------------------------- +%% setup_isolated_env(Config) -> Config1 +%%----------------------------------------------------------------------------- +setup_isolated_env(Config) -> + OrigCwd = proplists:get_value(orig_cwd, Config), + Unique = integer_to_list(erlang:unique_integer([positive, monotonic])), + TmpDir = filename:join([OrigCwd, "_build", "test", "ct_scratch", + "project_" ++ Unique]), + MnesiaDir = filename:join(TmpDir, "mnesia"), + ok = filelib:ensure_dir(filename:join(MnesiaDir, "x")), + + ok = file:set_cwd(TmpDir), + application:set_env(mnesia, dir, MnesiaDir), + {ok, _} = application:ensure_all_started(nref), + + [{tmp_dir, TmpDir}, {mnesia_dir, MnesiaDir} | Config]. + + +%%----------------------------------------------------------------------------- +%% verify_cache_invariant(TC) -> ok +%%----------------------------------------------------------------------------- +verify_cache_invariant(TC) -> + case mnesia:system_info(is_running) of + yes -> + case graphdb_mgr:verify_caches() of + ok -> ok; + {error, Mismatches} -> + ct:pal("Cache invariant failed in ~p:~n~p", + [TC, Mismatches]), + ct:fail({cache_invariant_failed, TC, Mismatches}) + end; + _ -> ok + end. + + +-define(SCRATCH_SENTINEL, "_build/test/ct_scratch/"). +-define(DIR_PREFIX, "project_"). + + +%%----------------------------------------------------------------------------- +%% delete_dir_recursive(Dir) -> ok | error({unsafe_delete, Dir}) +%%----------------------------------------------------------------------------- +delete_dir_recursive(Dir) -> + case is_safe_scratch_dir(Dir) of + true -> do_delete_dir(Dir); + false -> error({unsafe_delete, Dir}) + end. + +is_safe_scratch_dir(Dir) -> + Abs = filename:absname(Dir), + IsAbsolute = (Abs =:= Dir), + ContainsSentinel = (string:find(Dir, ?SCRATCH_SENTINEL) =/= nomatch), + Leaf = filename:basename(Dir), + HasPrefix = lists:prefix(?DIR_PREFIX, Leaf), + IsAbsolute andalso ContainsSentinel andalso HasPrefix. + +do_delete_dir(Dir) -> + case filelib:is_dir(Dir) of + true -> + {ok, Entries} = file:list_dir(Dir), + lists:foreach(fun(E) -> + Path = filename:join(Dir, E), + case filelib:is_dir(Path) of + true -> do_delete_dir(Path); + false -> file:delete(Path) + end + end, Entries), + file:del_dir(Dir); + false -> + ok + end. diff --git a/apps/graphdb/test/graphdb_query_SUITE.erl b/apps/graphdb/test/graphdb_query_SUITE.erl index ee965d3..6041bb0 100644 --- a/apps/graphdb/test/graphdb_query_SUITE.erl +++ b/apps/graphdb/test/graphdb_query_SUITE.erl @@ -548,7 +548,7 @@ q3_class_not_found(_Config) -> %%--------------------------------------------------------------------- q4_describes_instance_with_class(_Config) -> {ok, Vehicle} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Taurus, _} = graphdb_instance:create_instance( + {ok, Taurus, _} = graphdb_instance:create_instance(sess(), "Taurus", Vehicle, ?NREF_PROJECTS), {ok, R} = graphdb_query:execute_query( #q_describe{nref = Taurus, labels = default}), @@ -562,7 +562,7 @@ q4_resolves_inherited_attributes(_Config) -> ok = graphdb_class:add_qualifying_characteristic(Vehicle, WeightA), %% Bind a class-level value (Task 0 adds bind_qc_value/3) ok = graphdb_class:bind_qc_value(Vehicle, WeightA, 3500), - {ok, Taurus, _} = graphdb_instance:create_instance( + {ok, Taurus, _} = graphdb_instance:create_instance(sess(), "Taurus", Vehicle, ?NREF_PROJECTS), {ok, R} = graphdb_query:execute_query( #q_describe{nref = Taurus, labels = default}), @@ -574,15 +574,15 @@ q4_resolves_inherited_attributes(_Config) -> q4_outgoing_and_incoming_connections(_Config) -> {ok, Mfr} = graphdb_class:create_class("Manufacturer", ?NREF_CLASSES), {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Ford, _} = graphdb_instance:create_instance( + {ok, Ford, _} = graphdb_instance:create_instance(sess(), "Ford", Mfr, ?NREF_PROJECTS), - {ok, Tau, _} = graphdb_instance:create_instance( + {ok, Tau, _} = graphdb_instance:create_instance(sess(), "Taurus", Veh, ?NREF_PROJECTS), %% create_relationship_attribute/3 atomically creates BOTH directions %% in one call and returns {ok, {FwdNref, RevNref}}. {ok, {MakesA, MadeByA}} = graphdb_attr:create_relationship_attribute_pair( "makes", "made_by", instance), - ok = graphdb_instance:add_relationship(Ford, MakesA, Tau, MadeByA), + ok = graphdb_instance:add_relationship(sess(), Ford, MakesA, Tau, MadeByA), {ok, R} = graphdb_query:execute_query( #q_describe{nref = Tau, labels = default}), Outgoing = maps:get(outgoing_connections, R), @@ -598,9 +598,9 @@ q4_outgoing_and_incoming_connections(_Config) -> q4_compositional_ancestors(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Car, _} = graphdb_instance:create_instance( + {ok, Car, _} = graphdb_instance:create_instance(sess(), "Car", Veh, ?NREF_PROJECTS), - {ok, Engine, _} = graphdb_instance:create_instance( + {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine", Veh, Car), {ok, R} = graphdb_query:execute_query( #q_describe{nref = Engine, labels = default}), @@ -617,9 +617,9 @@ q4_instance_not_found(_Config) -> %%--------------------------------------------------------------------- q5_lists_direct_instances(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Tau, _} = graphdb_instance:create_instance( + {ok, Tau, _} = graphdb_instance:create_instance(sess(), "Taurus", Veh, ?NREF_PROJECTS), - {ok, Acc, _} = graphdb_instance:create_instance( + {ok, Acc, _} = graphdb_instance:create_instance(sess(), "Accord", Veh, ?NREF_PROJECTS), {ok, Insts} = graphdb_query:execute_query( #q_instances_of{class = Veh, recursive = false}), @@ -629,7 +629,7 @@ q5_lists_direct_instances(_Config) -> q5_recursive_includes_subclass_instances(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), {ok, Car} = graphdb_class:create_class("Car", Veh), - {ok, Tau, _} = graphdb_instance:create_instance( + {ok, Tau, _} = graphdb_instance:create_instance(sess(), "Taurus", Car, ?NREF_PROJECTS), {ok, Insts} = graphdb_query:execute_query( #q_instances_of{class = Veh, recursive = true}), @@ -638,7 +638,7 @@ q5_recursive_includes_subclass_instances(_Config) -> q5_non_recursive_excludes_subclasses(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), {ok, Car} = graphdb_class:create_class("Car", Veh), - {ok, Tau, _} = graphdb_instance:create_instance( + {ok, Tau, _} = graphdb_instance:create_instance(sess(), "Taurus", Car, ?NREF_PROJECTS), {ok, Insts} = graphdb_query:execute_query( #q_instances_of{class = Veh, recursive = false}), @@ -707,9 +707,9 @@ q6_arc_kind_filter(_Config) -> %% B (child) -> A (parent) via composition; restricting to taxonomy %% yields no_path because the path is purely compositional. {ok, Cls} = graphdb_class:create_class("Cls", ?NREF_CLASSES), - {ok, A, _} = graphdb_instance:create_instance( + {ok, A, _} = graphdb_instance:create_instance(sess(), "A", Cls, ?NREF_PROJECTS), - {ok, B, _} = graphdb_instance:create_instance("B", Cls, A), + {ok, B, _} = graphdb_instance:create_instance(sess(), "B", Cls, A), {ok, [_|_]} = graphdb_query:execute_query( #q_find_path{from = B, to = A, @@ -742,3 +742,21 @@ resume_against_refreshed_session_fails(_Config) -> S2 = graphdb_query:refresh(S1), ?assertEqual({error, snapshot_expired}, graphdb_query:resume(Cont, S2)). + +%%--------------------------------------------------------------------- +%% sess() -> Session +%% +%% SP1 test helper: returns a project session, memoised per test-case +%% process. Registers a project under Projects (nref 5) on first use and +%% opens a session against it; subsequent calls in the same process reuse it. +%%--------------------------------------------------------------------- +sess() -> + case get(sp1_session) of + undefined -> + {ok, P} = graphdb_project:register_project("SP1 test session"), + {ok, S} = graphdb_project:open_session(P), + put(sp1_session, S), + S; + S -> + S + end. diff --git a/docs/Architecture.md b/docs/Architecture.md index ecec0b9..d6fd33c 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -211,13 +211,15 @@ Every `kind = connection` arc carries a `Template` AVP — `#{attribute semantic context. The AVP attribute is bootstrap-seeded at nref 31; it is forbidden on relationships of any other kind. Template nodes are compositional children of class nodes (see §3 cache field -sources). API: `graphdb_instance:add_relationship/4,5`. - -Connection edges are mutated through `graphdb_instance` -(connection-arcs only — these never touch the `parents`/`classes` caches): -`remove_relationship/3,4` deletes **both** directed rows of a logical edge -atomically; `update_relationship/4,5` and `update_relationship_both/4,5` edit -the per-direction AVP metadata, reusing the slice-B AVP merge grammar. Remove +sources). API: `graphdb_instance:add_relationship/5,6,7` (session-first — SP1). + +Connection edges are mutated through `graphdb_instance` (connection-arcs +only — these never touch the `parents`/`classes` caches; all take a project +`Session` first arg — SP1 — and reject an invalid one with +`{error, invalid_session}`): `remove_relationship/4,5` deletes **both** +directed rows of a logical edge atomically; `update_relationship/5,6` and +`update_relationship_both/5,6` edit the per-direction AVP metadata, reusing the +slice-B AVP merge grammar. Remove is logical-edge-level; AVP update is directed-row-level (the `(S,C,T)` triple names one directed row — name `(T,R,S)` to edit the reverse). The `Template` AVP is protected from edit. Since nothing dedups connection edges at write @@ -328,6 +330,11 @@ 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. +This routing table is the code contract of the pure module `graphdb_ns` +(`namespace_of/1`, `target_namespace/1` → `environment | project | home`) — +see the SP1 model below. Against today's single store it is behaviour- +preserving; SP2 gives it physical teeth. + Every `graphdb_attr` creator takes an explicit, validated `ParentNref` (must name an existing `kind=attribute` node); the named functions (`create_name_attribute`, `create_literal_attribute`, @@ -355,6 +362,39 @@ Visibility of the anchor node is governed by ACL AVPs on that node restriction; owner-specific projects have a permissioned ACL. The node always exists regardless of its visibility. +### Reference & namespace model (SP1) + +The environment/project separation is a four-sub-project program +(design: `designs/project-env-reference-namespace-model-design.md`; tracking: +`../TASKS.md` → *Multi-project sessions*). **SP1 is implemented at the API/code +layer only — no `node`/`relationship` record changes:** + +- **`graphdb_ns`** — pure namespace-resolution module encoding the routing + table above; every nref field resolves to `environment | project | home`. +- **`graphdb_project`** — project registry (`register_project/1`, + `is_project/1`) creating the nref-5 anchor, plus the project **session** + (`open_session/1`, `session_project/1`, `require_session/1`) and the + canonical project-scoped relationship API surface. A session is an opaque + value threaded as data — the workers are shared singletons, so project + context cannot be ambient. +- **Required session on the project write path** — `create_instance`, + `add_relationship`, `remove_relationship`, `update_relationship`(`_both`), + and `add_class_membership` take a `Session` first arg and reject a missing/ + invalid one with `{error, invalid_session}`. +- **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 + `graphdb_instance:is_proxy/1` / `proxy_coordinates/1`. Representation only; + creation/dereference are SP2/SP3. +- **Namespace-agnostic in SP1** — `mutate/1` and the instance reads + (`get_instance` / `children` / `compositional_ancestors` / `resolve_value`), + like `get_node` / `get_relationships`, are not session-gated: `mutate/1` is a + mixed env/project batch, and the reads are consumed by `graphdb_query`. + Their per-namespace routing lands in SP2. + +SP2 (physical per-project store + allocator-from-1), SP3 (distribution / +residency + proxy dereference), and SP4 (migration) remain. + --- ## 7. nref Allocation diff --git a/docs/designs/project-env-reference-namespace-model-design.md b/docs/designs/project-env-reference-namespace-model-design.md new file mode 100644 index 0000000..3e7993b --- /dev/null +++ b/docs/designs/project-env-reference-namespace-model-design.md @@ -0,0 +1,246 @@ +# Project/Environment Separation — SP1: Reference & Namespace Model + +**Status:** Design (approved for planning) +**Date:** 2026-06-29 +**Scope:** Sub-project 1 of the project/environment separation program. + +## 1. Context — why this exists + +The codebase documents a two-database architecture (a shared **environment** +/ ontology, and per-project **instance spaces**) but implements a single +physical Mnesia store: one `nodes` table, one `relationships` table, one +allocator (`graphdb_nref`, runtime tier ≥ `?NREF_START`). Instances allocate +from the same environment runtime tier as everything else. The "project +database, allocator from 1" and "`target_kind` routes `target_nref` to +environment-or-project" descriptions were never implemented — `target_kind` +today is *validation* (`check_target_kind` confirms a target's `kind` matches +the arc label), not *routing*. + +Consequence: a bare integer nref carries no database identity. This is +harmless while there is one store, but it is a latent correctness defect — +the moment a project namespace begins at 1, project-5 and environment-5 +collide as the same primary key, and `check_target_kind` reads the wrong +node. + +### Driving reasons for real separation + +1. **Isolation** of instance knowledge-bases — commercial, private, + restricted-access groups, domains, privacy. +2. **Scale** — the universal volume of instances precludes one database for + all; projects partition by domain. +3. **Physical location / residency** — a project's physical home is governed + by (1) and (2): a company's own data center isolated from the world, a + family project isolated for privacy, etc. + +These point at **hard physical separation**: each project is its own database, +on its own node, potentially in its own data center, isolated from every +other project and from the public world. The only shared artifact is the +global **environment** ontology (categories, attributes, classes, +arc-labels). + +### Program decomposition + +This is a distributed, multi-tenant, data-residency architecture — too large +for one spec. It decomposes into four sub-projects, each its own +spec → plan → build cycle: + +| # | Sub-project | Establishes | +| --- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Reference & namespace model** | Every nref field has a declared namespace; resolution is total and explicit; a project-session surfaces in the API. *(this spec)* | +| 2 | **Physical project store** | Separate Mnesia table set / schema per project; per-project allocator from 1; session binds to a project. | +| 3 | **Distribution & residency** | Projects on separate nodes / locations; environment reachability or replication at each location; air-gap and access-control boundary. | +| 4 | **Migration** | Move existing instances out of the shared environment tables into project storage; reassign their nrefs. | + +This spec covers **SP1 only**. + +## 2. Goal and guiding constraint + +Establish, **at the API/code layer only**, that every nref reference has a +well-defined namespace and is resolved correctly — so the codebase stops +assuming a single global nref space. + +**Guiding constraint: no `node` / `relationship` record changes.** The +namespace of every reference is derivable from context (field role, plus the +arc-label's `target_kind` for the one polymorphic field). A reference whose +namespace is *not* derivable from context would be the only "valid reason" to +revisit the records — none has surfaced. + +SP1 is **behavior-preserving against today's single store**: it installs the +resolution seam and the project-session that SP2+ give physical teeth. +"Project-local" resolves to today's tables until SP2 exists; the *contract* is +correct from day one. + +## 3. Namespace is a binary, derived not stored + +Every structural (graph-traversable) nref field resolves to exactly one of +`{environment, current-project}`. The namespace is determined by the field's +**role**, plus the arc-label's `target_kind` for the single polymorphic field +(`target_nref`). + +### Field-role namespace map + +| Field | Namespace | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `node.nref` | the node's own DB — environment node → environment; instance → project-local | +| `node.classes` | environment (instances point at environment class nodes) | +| `node.parents` — compositional | project-local (instance part-of instance) | +| `node.parents` — taxonomy | environment (class / attribute is-a) | +| `relationship.characterization` | environment (arc label) — always | +| `relationship.reciprocal` | environment (arc label) — always | +| `relationship.target_nref` | **routed** by the arc-label's `target_kind`: `instance` → project-local; `category`/`attribute`/`class` → environment | +| `relationship.source_nref` | the row's home DB | +| AVP `attribute` keys | environment — always | +| AVP values that are nrefs (e.g. template, `reciprocal_nref`) | environment, per the attribute's definition | + +This table is authoritative. The resolution seam (§7) is the code expression +of it. + +## 4. Cross-project links are indirected, never structural + +Projects *can* relate to other projects, but **never via a direct structural +reference**. A cross-project link is an ordinary in-project arc to a **local +proxy node**. The proxy carries the remote coordinates as **AVP payload**, not +as a traversable reference: + +| AVP | Meaning | +| ---------------- | ----------------------------------------------------------------------------------------------- | +| `remote_project` | environment nref of the target project's registry node under nref 5 | +| `remote_nref` | the target node's integer **in that project's space** — payload, meaningful only on dereference | + +Because the remote coordinates are *data* (AVP values already accept arbitrary +terms), no structural field ever crosses a project boundary and the +`{environment, current-project}` binary holds. The "tree rooted at a +remote-project node with proxy descendants" is an organizational pattern +layered on this one primitive — not a separate mechanism. + +### Proxy representation + +A proxy is a regular **instance** node that is a member of a new +environment-seeded class **"Remote Reference"** (under Classes, nref 3). +**No new `kind` atom; no record change.** + +### Proxy — SP1 scope boundary + +SP1 defines the proxy **representation contract** only — what a proxy node is +and what AVPs it carries. **Proxy-creation API and dereference are deferred**: +they are meaningless without remote access (SP2/SP3). See §9. + +## 5. Project identity + +A project is identified by a **registry node under the environment `Projects` +category (bootstrap nref 5)**. Project identity *is* that environment nref. + +For SP1, registration under nref 5 is **mandatory and public**. Private +*environment overlays* that hide a project's registry node (the overlay +mechanism already used for languages, applied to project privacy) are a +deferred future direction. + +## 6. Project session + +A project op travels with a **project-session value**, because the workers +(`graphdb_mgr`, `graphdb_instance`, …) are shared singleton gen_servers +serving every caller: "current project" cannot be ambient worker state, and a +caller-side process dictionary would not reach the worker. The binding **must +travel as data on the call.** + +`open_session(ProjectNref)` validates the registry node exists under nref 5 +and returns an **opaque project-session value** — a plain value, not a process +(it runs in the caller's process, consistent with the transaction seam). + +Today the value wraps essentially `#{project => Nref}`. It is the **single +growth point** where SP2/SP3 attach a connection handle, access context, +snapshot, and residency information **without signature churn**. It rhymes +conceptually with the `graphdb_query` session; unification with that session +is deferred, not assumed. + +## 7. Resolution seam + +A single resolution primitive expresses the field-role map (§3): given a +session and a field role (plus `target_kind` for `target_nref`), it reads from +the correct store. Against today's single store it always resolves to the one +table set — the seam is **behavior-preserving** but is the **correctness +boundary** SP2 fills in. No structural code outside the seam should assume a +global nref space. + +`graphdb_ns` is **intentionally unused by production code in SP1**: it is the +pure *classifier* that the SP2 store-router will consume. Shipping it now fixes +the namespace contract as a testable unit (exhaustive table-driven tests over +§3) before the router exists — it is not dead code. + +The per-operation session gate (`graphdb_project:require_session/1`) is +**shape-only**: it accepts any well-formed `#{kind := project_session, …}` +value, including one naming a project that no longer exists. Registry-existence +is validated once by `open_session/1`; re-checking it on every operation would +cost a store read per call for no SP1 benefit (the session is inert). SP2, when +the session binds to physical storage, is the natural point to revisit this. + +## 8. Environment-op vs project-op split + +The env/project line decides which APIs take a session: + +| Operates on | Examples | Project session? | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| **Environment (shared ontology)** | `create_attribute`, `create_class`, `add_qualifying_characteristic`, taxonomy/composition arcs *between classes or attributes* (parent/child), `create_*_rule`, language registration | No — environment context | +| **Project (instance space)** | `create_instance`, compositional hierarchy, class membership, **connection** arcs among instances, slice-E `remove_relationship` / `update_relationship` on those connection arcs | Yes — bound to one project | + +A project op reads environment nodes freely; it just cannot *write* the shared +ontology through a project session. + +### Relationship-mutation relocation + +Relationship mutation currently lives in `graphdb_instance`, but +taxonomy/composition arcs on classes and attributes are relationships too. +Mutation **splits along the env/project line, not under "instance"**: +taxonomy/composition arc mutation is an environment op; connection-arc +mutation (slice E) is a project op. + +*Recommended placement:* keep tier-1 in-transaction primitives where they are; +reorganize the **public, session-threaded** surface along the env/project +split. Precise module layout is settled in the implementation plan. + +## 9. Scope boundary + +**SP1 delivers:** + +- the namespace resolution contract + field-role map (§3, §7); +- the project-session value + `open_session` (§6); +- session-threading of project-side APIs (§8); +- the proxy-node representation contract (§4); +- the environment/project API reorganization, including relationship-mutation + relocation (§8). + +**SP1 does *not* deliver** (later sub-projects): + +- physical project storage / separate table set per project (SP2); +- per-project allocator from 1 (SP2); +- proxy-node creation API and remote dereference (SP3); +- distribution / residency / environment replication (SP3); +- migration of existing instances out of the shared tables (SP4). + +## 10. Deferred open questions + +- **Proxy explosion / federation vs materialization** — what happens when a + significant fraction of a remote project is proxied locally (volume, + staleness, sync). A dereference/federation concern, SP2–SP3. +- **Private environment overlays** — a private overlay containing a project's + registry node under nref 5; the language-overlay mechanism applied to + project privacy. +- **Session unification** — whether the project-session and the + `graphdb_query` session converge into one concept. +- **Multi-project sessions (forward compatibility)** — SP1 binds a session to + a single project, but nothing forecloses several. The session value is + **fully encapsulated**: only `graphdb_project` constructs and inspects it + (`open_session/1`, `session_project/1`, `require_session/1`); every write op + gates through `require_session/1` and no code assumes single-project-ness, so + widening the shape (`project => Nref` → a working set + a current/default) is + a localized change. The remaining work is **semantic, not mechanical**, and + lands with SP2: (1) **write targeting** — which open project a new write lands + in (a `current`/default project plus a per-op override, or an explicit per-op + project); (2) **rule/overlay priority** — a declared precedence across open + projects (env → A → B), already anticipated in `TASKS.md` → *Multi-project + sessions*. Resolving **existing** data does **not** get harder: because no + structural reference crosses a project boundary (cross-project links are proxy + nodes), a row's project-local nrefs belong to its home project, so + reads/traversal stay well-defined per-row-home even with several projects + open. "Multiple open" is a working-set/targeting convenience for a principal + with access to each; it does not dissolve the isolation boundary. diff --git a/docs/diagrams/ontology-tree.md b/docs/diagrams/ontology-tree.md index a6cf40f..82ce69b 100644 --- a/docs/diagrams/ontology-tree.md +++ b/docs/diagrams/ontology-tree.md @@ -1,6 +1,6 @@ # Ontology Tree — Bootstrap + Runtime Init Seeds -**Status:** current as of 2026-06-17 (post soft-retire Task 1 — `retired` lifecycle marker added). +**Status:** current as of 2026-06-29 (SP1 Task 4 — "Remote Reference" proxy class + `remote_project`/`remote_nref` literal attributes added). This diagram is the **organisational shape of the environment ontology** immediately after `application:start(database)` finishes. It captures: @@ -80,6 +80,8 @@ graph LR NAT["attribute_type
(runtime, attribute)"]:::attr NIN["instantiable
(runtime, attribute)"]:::attr NRE["retired
(runtime, attribute)"]:::attr + NRP["remote_project
(runtime, attribute)"]:::attr + NRN["remote_nref
(runtime, attribute)"]:::attr NBL["base_language
(runtime, attribute)"]:::attr NPL["project_language
(runtime, attribute)"]:::attr NRCC["child_class_nref
(runtime, attribute)"]:::attr @@ -111,10 +113,11 @@ graph LR NRAT["applies_to (rule arc)
(runtime, attribute)"]:::attr NRAB["applied_by (rule arc)
(runtime, attribute)"]:::attr - %% --- Classes sub-tree (F4 Phase A rule meta-ontology) --- + %% --- Classes sub-tree (F4 Phase A rule meta-ontology + SP1 proxy contract) --- NRULE["Rule (abstract)
(runtime, class)"]:::cls NCMPR["CompositionRule
(runtime, class)"]:::cls NCONR["ConnectionRule
(runtime, class)"]:::cls + NRRR["Remote Reference
(runtime, class)"]:::cls %% --- Languages sub-tree --- N32["Human Languages
(32, category)"]:::cat @@ -170,6 +173,8 @@ graph LR NAL ==> NAT NAL ==> NIN NAL ==> NRE + NAL ==> NRP + NAL ==> NRN NLL ==> NBL NLL ==> NPL NRL ==> NRCC @@ -200,10 +205,11 @@ graph LR N16 ==> NRAT N16 ==> NRAB - %% --- Taxonomy: Classes — F4 Phase A rule meta-ontology --- + %% --- Taxonomy: Classes — F4 Phase A rule meta-ontology + SP1 proxy --- N3 ==> NRULE NRULE ==> NCMPR NRULE ==> NCONR + N3 ==> NRRR ``` ## Legend @@ -248,13 +254,15 @@ Subtree → arc kind: Runtime sub-group / attribute / class nrefs sit at 10000+ and are not enumerated here (they shift between sessions); the L7 Attribute -Literals sub-group (6 literals: `literal_type`, `target_kind`, -`relationship_avp`, `attribute_type`, `instantiable`, `retired`) and -Language Literals sub-group are seeded by `graphdb_attr:init/1` and -`graphdb_language:init/1`, and the F4 Rule Literals sub-group (8 -literals, including `reciprocal_nref` added in B4) plus the `Rule` / -`CompositionRule` / `ConnectionRule` meta-classes and the `applies_to` -/ `applied_by` pair are seeded by `graphdb_rules:init/1`. +Literals sub-group (8 literals: `literal_type`, `target_kind`, +`relationship_avp`, `attribute_type`, `instantiable`, `retired`, +`remote_project`, `remote_nref`) and Language Literals sub-group are +seeded by `graphdb_attr:init/1` and `graphdb_language:init/1`; the +F4 Rule Literals sub-group (8 literals, including `reciprocal_nref` +added in B4) plus the `Rule` / `CompositionRule` / `ConnectionRule` +meta-classes and the `applies_to` / `applied_by` pair are seeded by +`graphdb_rules:init/1`; the `Remote Reference` class (SP1 Task 4) is +seeded by `graphdb_instance:init/1`. ## Maintenance diff --git a/docs/superpowers/plans/2026-06-29-sp1-reference-namespace-model.md b/docs/superpowers/plans/2026-06-29-sp1-reference-namespace-model.md new file mode 100644 index 0000000..ea01e74 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-sp1-reference-namespace-model.md @@ -0,0 +1,474 @@ +# SP1 — Reference & Namespace Model 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:** Establish, at the API/code layer only, that every nref reference has a derived namespace and that project operations require a session opened against a registered project — behavior-preserving against today's single Mnesia store. + +**Architecture:** A pure `graphdb_ns` module encodes the field-role namespace map (the crown jewel). A plain `graphdb_project` module owns project lifecycle (`register_project`) and the project session (`open_session`/`session_project`) plus the relocated project-scoped relationship-mutation surface. Proxy nodes (cross-project links as local AVP-payload nodes) get a seeded "Remote Reference" environment class + `remote_project`/`remote_nref` literal attributes + a recognizer — representation contract only, no creation/deref. Project write ops and project-specific instance reads take a **required** `Session` first argument. + +**Tech Stack:** Erlang/OTP 28.5, Mnesia, rebar3 3.27 (`./rebar3`), Common Test + EUnit. + +**Source spec:** `docs/designs/project-env-reference-namespace-model-design.md` + +## Global Constraints + +- **No `node` / `relationship` record changes.** Namespace is derived from field role + `target_kind`, never a new stored field. +- **HARD TABS** in all `apps/graphdb/` source and test files. +- **Module header pattern** (copyright, author, revision, NYI/UEM macros, explicit `-export`) on any new module — mirror an existing `graphdb_*` module. +- **Behavior-preserving** against the single store: every existing test must still pass after each task (project-op callers updated to open a session, but outcomes unchanged). +- **LOAD-BEARING INVARIANT:** never call a gen_server (`graphdb_nref:get_next/0`, `rel_id_server:get_id_pair/0`, `graphdb_attr`/`graphdb_class` calls) inside an Mnesia transaction fun — allocate/resolve outside, then enter the txn. +- **Required session:** a project op given a missing/invalid session returns `{error, invalid_session}` (or crashes on a non-session term per the function's guard) — never silently proceeds. +- Invoke rebar3 as plain `./rebar3 ...`. Fast CT: `make test-ct-parallel`. +- Commit trailers on every commit: + `Co-Authored-By: Claude Opus 4.8 ` and + `Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF`. + +## Milestones (PR-sized groupings) + +- **Milestone A — Mechanisms (Tasks 1–4):** additive, low-risk, fully testable. Shippable as one PR; nothing else is threaded yet. +- **Milestone B — Required-session threading & reorganization (Tasks 5–8):** threads the session through the project surface and relocates relationship mutation. Shippable as a second PR. + +--- + +## Task 1: `graphdb_ns` — pure namespace resolution module + +**Files:** +- Create: `apps/graphdb/src/graphdb_ns.erl` +- Test: `apps/graphdb/test/graphdb_ns_tests.erl` + +**Interfaces:** +- Produces: + - `graphdb_ns:namespace_of(Role) -> environment | project | home` where + `Role :: characterization | reciprocal | avp_attribute | node_classes | + taxonomy_parent | compositional_parent | node_nref | source_nref` + - `graphdb_ns:target_namespace(TargetKind) -> environment | project` where + `TargetKind :: category | attribute | class | instance` + +- [ ] **Step 1: Write the failing tests** (exhaustive, table-driven over the §3 field-role map) + +```erlang +%%% File: apps/graphdb/test/graphdb_ns_tests.erl +-module(graphdb_ns_tests). +-include_lib("eunit/include/eunit.hrl"). + +namespace_of_environment_roles_test() -> + [ ?assertEqual(environment, graphdb_ns:namespace_of(R)) + || R <- [characterization, reciprocal, avp_attribute, + node_classes, taxonomy_parent] ]. + +namespace_of_project_roles_test() -> + ?assertEqual(project, graphdb_ns:namespace_of(compositional_parent)). + +namespace_of_home_roles_test() -> + [ ?assertEqual(home, graphdb_ns:namespace_of(R)) + || R <- [node_nref, source_nref] ]. + +target_namespace_instance_is_project_test() -> + ?assertEqual(project, graphdb_ns:target_namespace(instance)). + +target_namespace_others_are_environment_test() -> + [ ?assertEqual(environment, graphdb_ns:target_namespace(K)) + || K <- [category, attribute, class] ]. + +namespace_of_unknown_role_crashes_test() -> + ?assertError(function_clause, graphdb_ns:namespace_of(bogus_role)). +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./rebar3 eunit --module=graphdb_ns_tests` +Expected: FAIL — `graphdb_ns` undefined. + +- [ ] **Step 3: Write `graphdb_ns.erl`** (use the standard module header from any `graphdb_*` file; body below — HARD TABS) + +```erlang +-module(graphdb_ns). +-export([namespace_of/1, target_namespace/1]). + +%% namespace_of(Role) -> environment | project | home +%% Encodes docs/designs/project-env-reference-namespace-model-design.md §3. +%% `home` = same store as the containing record (node's own DB / row's home). +namespace_of(characterization) -> environment; +namespace_of(reciprocal) -> environment; +namespace_of(avp_attribute) -> environment; +namespace_of(node_classes) -> environment; +namespace_of(taxonomy_parent) -> environment; +namespace_of(compositional_parent) -> project; +namespace_of(node_nref) -> home; +namespace_of(source_nref) -> home. + +%% target_namespace(TargetKind) -> environment | project +%% The single routed field (relationship.target_nref): project iff instance. +target_namespace(instance) -> project; +target_namespace(category) -> environment; +target_namespace(attribute) -> environment; +target_namespace(class) -> environment. +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `./rebar3 eunit --module=graphdb_ns_tests` +Expected: PASS (6 tests). + +- [ ] **Step 5: Update anatomy + commit** + +```bash +# Add graphdb_ns.erl + graphdb_ns_tests.erl entries to .wolf/anatomy.md (do NOT git add .wolf) +git add apps/graphdb/src/graphdb_ns.erl apps/graphdb/test/graphdb_ns_tests.erl +git commit -m "SP1: graphdb_ns pure namespace resolution module + +" +``` + +--- + +## Task 2: Project registry — `register_project/1` + +**Files:** +- Create: `apps/graphdb/src/graphdb_project.erl` +- Test: `apps/graphdb/test/graphdb_project_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_nref:get_next/0`, `rel_id_server:get_id_pair/0`, + `graphdb_mgr:transaction/1`, macros `?NREF_PROJECTS`, `?ARC_CAT_CHILD`, + `?ARC_CAT_PARENT`, `?NAME_ATTR_INSTANCE` from `graphdb_nrefs.hrl`, + `#node{}` / `#relationship{}` from the graphdb records header. +- Produces: + - `graphdb_project:register_project(Name :: string()) -> {ok, ProjectNref} | {error, term()}` + - `graphdb_project:is_project(Nref) -> boolean()` (true iff `Nref` is a child of `?NREF_PROJECTS`) + +**Design note (flagged at handoff):** the project registry node is +`kind = instance`, attached as a child of the `Projects` category (nref 5) via +the category composition arc labels (`?ARC_CAT_CHILD` / `?ARC_CAT_PARENT`), +carrying a single instance-name AVP. No separate "Project" class in SP1 +(YAGNI). It receives a runtime environment nref (`graphdb_nref:get_next/0`). +Adding a child under category 5 does not mutate the category node itself +(only relationship rows + the child's record), so the category-immutability +guard is not engaged. + +- [ ] **Step 1: Write the failing test** + +```erlang +register_project_creates_child_of_projects(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + ?assert(is_integer(P)), + ?assert(graphdb_project:is_project(P)), + {ok, #node{kind = Kind, parents = Parents}} = graphdb_mgr:get_node(P), + ?assertEqual(instance, Kind), + ?assert(lists:member(?NREF_PROJECTS, Parents)). + +is_project_false_for_non_child(_Config) -> + ?assertNot(graphdb_project:is_project(?NREF_CLASSES)). +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_project_SUITE` +Expected: FAIL — `graphdb_project` undefined. + +- [ ] **Step 3: Implement `register_project/1` + `is_project/1`** (mirror the `ensure_literal_seed/2` attach pattern in `graphdb_language.erl:411-446`, but with category composition arcs and `kind = composition`) + +```erlang +register_project(Name) when is_list(Name) -> + Nref = graphdb_nref:get_next(), + {Id1, Id2} = rel_id_server:get_id_pair(), + NameAVP = #{attribute => ?NAME_ATTR_INSTANCE, value => Name}, + Node = #node{nref = Nref, kind = instance, + parents = [?NREF_PROJECTS], + attribute_value_pairs = [NameAVP]}, + P2C = #relationship{id = Id1, kind = composition, + source_nref = ?NREF_PROJECTS, + characterization = ?ARC_CAT_CHILD, + target_nref = Nref, reciprocal = ?ARC_CAT_PARENT, + avps = []}, + C2P = #relationship{id = Id2, kind = composition, + source_nref = Nref, + characterization = ?ARC_CAT_PARENT, + target_nref = ?NREF_PROJECTS, reciprocal = ?ARC_CAT_CHILD, + avps = []}, + Fun = fun() -> + ok = mnesia:write(nodes, Node, write), + ok = mnesia:write(relationships, P2C, write), + ok = mnesia:write(relationships, C2P, write), + Nref + end, + graphdb_mgr:transaction(Fun). + +is_project(Nref) -> + case graphdb_mgr:get_node(Nref) of + {ok, #node{parents = Parents}} -> lists:member(?NREF_PROJECTS, Parents); + _ -> false + end. +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `./rebar3 ct --suite=apps/graphdb/test/graphdb_project_SUITE` +Expected: PASS. + +- [ ] **Step 5: Update anatomy + commit** (add `graphdb_project.erl` + suite to `.wolf/anatomy.md`; do NOT git add `.wolf`). + +--- + +## Task 3: Project session — `open_session/1` / `session_project/1` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_project.erl` +- Test: `apps/graphdb/test/graphdb_project_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_project:is_project/1` (Task 2). +- Produces: + - `graphdb_project:open_session(ProjectNref) -> {ok, Session} | {error, not_a_project}` where `Session` is an opaque value. + - `graphdb_project:session_project(Session) -> ProjectNref` + - `Session` is a map `#{kind => project_session, project => Nref}`, constructed only by `open_session/1`. Treat as opaque; later sub-projects add keys. + +- [ ] **Step 1: Write the failing tests** + +```erlang +open_session_on_registered_project(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, S} = graphdb_project:open_session(P), + ?assertEqual(P, graphdb_project:session_project(S)). + +open_session_rejects_non_project(_Config) -> + ?assertEqual({error, not_a_project}, + graphdb_project:open_session(?NREF_CLASSES)). +``` + +- [ ] **Step 2: Run to verify failure** — `open_session` undefined. + +- [ ] **Step 3: Implement** + +```erlang +open_session(ProjectNref) -> + case is_project(ProjectNref) of + true -> {ok, #{kind => project_session, project => ProjectNref}}; + false -> {error, not_a_project} + end. + +session_project(#{kind := project_session, project := Nref}) -> Nref. +``` + +- [ ] **Step 4: Run to verify pass** — PASS. + +- [ ] **Step 5: Commit.** + +--- + +## Task 4: Proxy representation contract — "Remote Reference" class + recognizer + +**Files:** +- Modify: `apps/graphdb/src/graphdb_attr.erl` (seed `remote_project`, `remote_nref` literal attributes in `init/1`; expose via `seeded_nrefs/0`) +- Modify: `apps/graphdb/src/graphdb_instance.erl` (seed "Remote Reference" class in `init/1`; add `is_proxy/1`, `proxy_coordinates/1`) +- Modify: `docs/diagrams/ontology-tree.md` (**mandatory** — a new environment node is seeded at init) +- Test: `apps/graphdb/test/graphdb_instance_SUITE.erl` (proxy recognizer cases) + +**Interfaces:** +- Consumes: `graphdb_attr:create_value_attribute/4` (literal seeds), + `graphdb_class:create_class/3`, `graphdb_attr:seeded_nrefs/0`. +- Produces: + - `graphdb_instance:is_proxy(#node{}) -> boolean()` (true iff the node is a member of the "Remote Reference" class) + - `graphdb_instance:proxy_coordinates(#node{}) -> {ok, #{remote_project => integer(), remote_nref => integer()}} | not_a_proxy` + - `graphdb_attr:seeded_nrefs/0` map gains keys `remote_project`, `remote_nref`. + +**Design note (flagged at handoff):** proxy AVP keys `remote_project` +(environment nref of the target project registry node) and `remote_nref` +(target's integer in that project's space — payload) are seeded **literal +attributes**, mirroring `target_kind`. The "Remote Reference" class is seeded +under `?NREF_CLASSES` via the find-first-else-create pattern. SP1 ships the +**representation contract + recognizer only** — no proxy creation API, no +dereference. + +- [ ] **Step 1: Write the failing tests** (build a node by hand carrying the two AVPs + Remote Reference class membership; assert recognizer accepts it and rejects a plain instance) + +```erlang +proxy_recognizer_identifies_proxy(_Config) -> + {ok, #{remote_project := RP, remote_nref := RN}} = graphdb_attr:seeded_nrefs(), + RRClass = graphdb_instance:remote_reference_class(), %% accessor added in step 3 + Node = #node{nref = 999999001, kind = instance, classes = [RRClass], + attribute_value_pairs = + [#{attribute => RP, value => 5}, + #{attribute => RN, value => 42}]}, + ?assert(graphdb_instance:is_proxy(Node)), + ?assertEqual({ok, #{remote_project => 5, remote_nref => 42}}, + graphdb_instance:proxy_coordinates(Node)). + +proxy_recognizer_rejects_plain_instance(_Config) -> + Node = #node{nref = 999999002, kind = instance, classes = [], + attribute_value_pairs = []}, + ?assertNot(graphdb_instance:is_proxy(Node)), + ?assertEqual(not_a_proxy, graphdb_instance:proxy_coordinates(Node)). +``` + +- [ ] **Step 2: Run to verify failure** — seeds/recognizer absent. + +- [ ] **Step 3: Implement the seeds + recognizer** + + 1. In `graphdb_attr:init/1`, seed `remote_project` and `remote_nref` as literal attributes (same call shape used for `target_kind`), cache their nrefs in state, and add them to the `seeded_nrefs/0` reply map. + 2. In `graphdb_instance:init/1`, after the existing `graphdb_attr:seeded_nrefs/0` read, ensure the "Remote Reference" class exists under `?NREF_CLASSES` (find-first-else-`graphdb_class:create_class/3`); cache its nref in `#state`. Add accessor `remote_reference_class/0` (gen_server call returning the cached nref) and the two proxy-attr nrefs. + 3. Add the pure recognizers: + +```erlang +is_proxy(#node{classes = Classes}) -> + lists:member(remote_reference_class(), Classes). + +proxy_coordinates(#node{attribute_value_pairs = AVPs} = N) -> + case is_proxy(N) of + false -> not_a_proxy; + true -> + {ok, #{remote_project := RP, remote_nref := RN}} = + graphdb_attr:seeded_nrefs(), + {ok, #{remote_project => avp_value(AVPs, RP), + remote_nref => avp_value(AVPs, RN)}} + end. +%% avp_value/2: reuse the module's existing find_avp_value/2 helper. +``` + +- [ ] **Step 4: Run to verify pass** — PASS. Then run the full instance suite to confirm the new init seed broke nothing: `./rebar3 ct --suite=apps/graphdb/test/graphdb_instance_SUITE`. + +- [ ] **Step 5: Update `docs/diagrams/ontology-tree.md`** — add the "Remote Reference" class node under Classes (nref 3) and the two literal attributes under the Literals subtree, in the Mermaid block. + +- [ ] **Step 6: Update anatomy + commit.** This closes **Milestone A**. + +--- + +## Task 5: Thread required session into relationship mutation + relocate surface + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` (tier-2 public functions gain `Session`) +- Modify: `apps/graphdb/src/graphdb_project.erl` (re-export the project-scoped relationship-mutation surface; add `require_session/1`) +- Modify: `apps/graphdb/test/graphdb_instance_SUITE.erl`, `apps/graphdb/test/graphdb_mgr_SUITE.erl` (callers open a session) + +**Interfaces:** +- Consumes: `graphdb_project:session_project/1`, `graphdb_project:open_session/1`. +- Produces (new public signatures — `Session` is always the **first** arg): + - `add_relationship(Session, S, C, T, R)` / `/6` (+template) / `/7` (+AVPs) + - `remove_relationship(Session, S, C, T)` / `/5` (+template) + - `update_relationship(Session, S, C, T, Updates)` / `/6` (+template) + - `update_relationship_both(Session, S, C, T, {Fwd, Rev})` / `/6` (+template) + - `add_class_membership(Session, InstanceNref, ClassNref)` + - `graphdb_project:require_session(Session) -> ok | {error, invalid_session}` — returns `ok` for a well-formed `#{kind := project_session}` map, else `{error, invalid_session}`. + +**Transformation pattern** (apply to each tier-2 function listed above): +add `Session` as the first parameter; as the first action, `case graphdb_project:require_session(Session) of {error, _}=E -> E; ok -> end`. The tier-1 `*_in_txn` primitives are **unchanged** (no session). The single-store resolution is identity, so the body is otherwise untouched — behavior-preserving. + +- [ ] **Step 1: Write the failing tests** + +```erlang +remove_relationship_requires_session(_Config) -> + %% existing remove_relationship_basic setup, but call with a session: + {ok, P} = graphdb_project:register_project("T5"), + {ok, S} = graphdb_project:open_session(P), + %% ... build a connection edge ... + ?assertEqual(ok, graphdb_instance:remove_relationship(S, Src, Char, Tgt)). + +remove_relationship_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_session}, + graphdb_instance:remove_relationship(not_a_session, 1, 2, 3)). +``` + +- [ ] **Step 2: Run to verify failure** — arity/clause mismatch. + +- [ ] **Step 3: Implement** `require_session/1` in `graphdb_project`, apply the transformation pattern to each tier-2 function, and re-export them from `graphdb_project` (thin wrappers delegating to `graphdb_instance`, establishing the project-side API home per the env/project split). + +- [ ] **Step 4: Update all existing callers/tests** of these functions to open a session first. Enumerate sites: + +Run: `grep -rn "remove_relationship\|update_relationship\|add_relationship\|add_class_membership" apps/graphdb/test apps/graphdb/src | grep -v _in_txn` +Update each call site to thread a session (tests: `open_session` in the relevant `init_per_testcase`/setup helper; `graphdb_rules` firing callers: see Task 6). + +- [ ] **Step 5: Run to verify pass** — `make test-ct-parallel` plus `./rebar3 eunit`. All green. + +- [ ] **Step 6: Commit.** + +--- + +## Task 6: Thread required session into `create_instance` + rule-firing propagation + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` (`create_instance/3,4,5` → `/4,5,6` with `Session` first) +- Modify: `apps/graphdb/src/graphdb_rules.erl` (composition/connection firing creates child instances — thread the session through `plan_*`/firing call paths that reach `create_instance`/`add_relationship`) +- Modify: `apps/graphdb/test/graphdb_instance_SUITE.erl`, `apps/graphdb/test/graphdb_rules_SUITE.erl` + +**Interfaces:** +- Produces: + - `create_instance(Session, Name, ClassNref, ParentNref)` / `/5` (+ConnResolver) / `/6` (+ConflictResolver) + - Internal firing helpers that materialize children gain a `Session` parameter so the same project is used end-to-end. Exact internal signatures: enumerate with `grep -n "create_instance\|add_relationship" apps/graphdb/src/graphdb_rules.erl` and thread `Session` down each path. + +**Why this is its own task:** `create_instance` is the deepest project op — the rule-firing engine creates mandatory child instances and mandatory connections internally. Because the session is **required**, any firing sub-path not threaded will *crash* (no session to pass), and the existing `graphdb_rules_SUITE` firing tests catch it once updated. That loud failure is the point. + +- [ ] **Step 1: Write the failing test** (a `create_instance` with a session that fires a composition rule minting a child; assert the child exists — proving the session reached the firing engine) + +```erlang +create_instance_with_session_fires_children(_Config) -> + {ok, P} = graphdb_project:register_project("T6"), + {ok, S} = graphdb_project:open_session(P), + %% class with a mandatory composition rule (reuse existing rules-suite setup) + {ok, Nref, _Report} = graphdb_instance:create_instance(S, "Car", CarClass, Root), + ?assert(is_integer(Nref)). + +create_instance_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_session}, + graphdb_instance:create_instance(bad, "X", 1, 2)). +``` + +- [ ] **Step 2: Run to verify failure.** + +- [ ] **Step 3: Implement** — add `Session` first arg + `require_session/1` guard to `create_instance/4,5,6`; thread `Session` through every `graphdb_rules` firing path that reaches `create_instance`/`add_relationship`. Re-export `create_instance` from `graphdb_project`. + +- [ ] **Step 4: Update callers/tests** — every `create_instance(` call site in `apps/graphdb/test` and `apps/graphdb/src` opens/threads a session. `grep -rn "create_instance(" apps/graphdb`. + +- [ ] **Step 5: Run to verify pass** — `make test-ct-parallel` + `./rebar3 eunit`. All green. + +- [ ] **Step 6: Commit.** + +--- + +## Task 7: Thread session into `mutate/1` project ops + project instance reads + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` (`mutate/1` → `mutate/2` taking `Session`; project-op mutation tuples dispatch with the session) +- Modify: `apps/graphdb/src/graphdb_instance.erl` (instance reads `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2` gain `Session` first arg) +- Modify: `apps/graphdb/test/graphdb_mgr_SUITE.erl`, `apps/graphdb/test/graphdb_instance_SUITE.erl` + +**Interfaces:** +- Produces: + - `graphdb_mgr:mutate(Session, [Mutation]) -> {ok, [Result]} | {error, term()}` — validates the session once up front; all project-op mutation tuples in the batch run under that project. + - `get_instance(Session, Nref)`, `children(Session, Nref)`, `compositional_ancestors(Session, Nref)`, `resolve_value(Session, Nref, AttrNref)`. + +**Scope boundary (per spec §7/§9):** the polymorphic low-level readers +`graphdb_mgr:get_node/1` and `get_relationships/1,2` remain +namespace-agnostic in SP1 (single store) and are **not** session-gated — they +read whichever store exists; their namespace-correct routing lands in SP2. +`graphdb_ns` documents the intended routing. + +- [ ] **Step 1: Write the failing tests** (`mutate/2` with a session over a batch of project-op mutations; a project read with a session). +- [ ] **Step 2: Run to verify failure.** +- [ ] **Step 3: Implement** `mutate/2` (validate session once, then the existing prepare/dispatch flow unchanged) and the session-first instance reads with `require_session/1` guards. +- [ ] **Step 4: Update callers/tests** — `grep -rn "mutate(\|get_instance(\|children(\|compositional_ancestors(\|resolve_value(" apps/graphdb`; thread a session. +- [ ] **Step 5: Run to verify pass** — `make test-ct-parallel` + `./rebar3 eunit`. All green. +- [ ] **Step 6: Commit.** + +--- + +## Task 8: Documentation + +**Files:** +- Modify: `docs/Architecture.md` (project-session + env/project API split; relationship-mutation relocation; `graphdb_ns` / `graphdb_project` modules; proxy representation contract) +- Modify: `apps/graphdb/CLAUDE.md` (worker responsibilities: new modules, session-threaded signatures, proxy seeds) +- Modify: `CLAUDE.md` (project root — "Cross-database nref resolution" note now backed by `graphdb_ns`; project session) +- Modify: `TASKS.md` (mark SP1 IMPLEMENTED; record SP2/SP3/SP4 follow-ups from spec §9–§10) + +- [ ] **Step 1:** Update `docs/Architecture.md` — new modules, the env/project API split, session-required project ops, proxy contract. Architectural altitude only. +- [ ] **Step 2:** Update `apps/graphdb/CLAUDE.md` worker table + `graphdb_instance`/`graphdb_mgr` API descriptions + the two new modules. +- [ ] **Step 3:** Update root `CLAUDE.md` cross-database resolution paragraph and the supervision/module notes. +- [ ] **Step 4:** Update `TASKS.md` — SP1 IMPLEMENTED; add SP2 (physical store + allocator-from-1), SP3 (distribution/residency + proxy creation/deref), SP4 (migration), plus deferred open questions (proxy explosion, private environment overlays, session unification). +- [ ] **Step 5:** Run `python3 ~/.claude/scripts/align_md_tables.py` on any edited markdown with tables. Commit. This closes **Milestone B**. + +--- + +## Self-Review + +- **Spec coverage:** §3 map → Task 1; §4 proxy contract → Task 4; §5 registry → Task 2; §6 session → Task 3 + threading Tasks 5–7; §7 resolution seam → Task 1 (+ §7 read-routing boundary noted in Task 7); §8 env/project split + relocation → Tasks 5–7; §9 scope (proxy create/deref deferred) → honored (Task 4 contract-only); §10 deferred items → Task 8 TASKS.md. All covered. +- **Placeholders:** none — every code step shows code; threading tasks give the exact transformation + a `grep` to enumerate sites (mechanical, not vague). +- **Type consistency:** `Session` is the first arg everywhere; `require_session/1`, `session_project/1`, `open_session/1`, `is_project/1`, `register_project/1`, `is_proxy/1`, `proxy_coordinates/1`, `namespace_of/1`, `target_namespace/1` used consistently across tasks.