From b80ebad42b5c65a2905777986f2ca6f73d822088 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 13:11:07 +0930 Subject: [PATCH 1/5] target side working using cypher --- .../validate_relationship_permitted.ex | 76 +++++++++++++++++-- .../extension/relationship_dsl_test.exs | 14 ++++ 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/lib/diffo/provider/changes/validate_relationship_permitted.ex b/lib/diffo/provider/changes/validate_relationship_permitted.ex index b2d6777..770871f 100644 --- a/lib/diffo/provider/changes/validate_relationship_permitted.ex +++ b/lib/diffo/provider/changes/validate_relationship_permitted.ex @@ -11,7 +11,7 @@ defmodule Diffo.Provider.Changes.ValidateRelationshipPermitted do case Ash.Changeset.get_argument(changeset, :relationships) do nil -> changeset [] -> changeset - rels -> validate_source_roles(changeset, rels) + rels -> changeset |> validate_source_roles(rels) |> validate_target_roles(rels) end end @@ -19,25 +19,85 @@ defmodule Diffo.Provider.Changes.ValidateRelationshipPermitted do permitted = changeset.resource.permitted_source_roles() Enum.reduce(rels, changeset, fn rel, cs -> - role = Map.get(rel, :alias) || Map.get(rel, "alias") - - case check_permitted(role, permitted) do + case check_permitted(rel_alias(rel), permitted, :source) do :ok -> cs {:error, msg} -> Ash.Changeset.add_error(cs, msg) end end) end - defp check_permitted(_role, :all), do: :ok + defp validate_target_roles(changeset, rels) do + spec_id_to_module = build_spec_id_map(changeset.domain) + + Enum.reduce(rels, changeset, fn rel, cs -> + target_id = Map.get(rel, :id) || Map.get(rel, "id") + + case resolve_target_module(target_id, spec_id_to_module, changeset.domain) do + {:ok, module} -> + case check_permitted(rel_alias(rel), module.permitted_target_roles(), :target) do + :ok -> cs + {:error, msg} -> Ash.Changeset.add_error(cs, msg) + end + + :error -> + Ash.Changeset.add_error( + cs, + "could not resolve target resource for id #{inspect(target_id)}" + ) + end + end) + end + + # Builds a map of %{spec_uuid => module} from all Instance resource modules in the + # domain that have both permitted_target_roles/0 and specification/0 baked by the + # provider extension. Used for O(1) module lookup after resolving the target's spec id. + defp build_spec_id_map(domain) do + domain + |> Ash.Domain.Info.resources() + |> Enum.filter( + &(function_exported?(&1, :permitted_target_roles, 0) and + function_exported?(&1, :specification, 0)) + ) + |> Map.new(fn module -> {module.specification()[:id], module} end) + end + + # Fetches the specification UUID for the target instance via a direct Cypher query, + # then does an O(1) lookup in spec_id_to_module to find the resource module. + defp resolve_target_module(id, spec_id_to_module, _domain) do + case AshNeo4j.Cypher.run( + "MATCH (n:Instance {uuid: $uuid})-[:SPECIFIED_BY]->(s) RETURN s.uuid AS spec_id", + %{"uuid" => id} + ) do + {:ok, %{results: [%{"spec_id" => spec_uuid} | _]}} -> + case Map.get(spec_id_to_module, spec_uuid) do + nil -> :error + module -> {:ok, module} + end + + {:ok, %{results: []}} -> + :error - defp check_permitted(_role, :none), + {:error, _} -> + :error + end + end + + defp rel_alias(rel), do: Map.get(rel, :alias) || Map.get(rel, "alias") + + defp check_permitted(_role, :all, _direction), do: :ok + + defp check_permitted(_role, :none, :source), do: {:error, "relationships are not permitted as source on this resource"} - defp check_permitted(role, roles) when is_list(roles) do + defp check_permitted(_role, :none, :target), + do: {:error, "relationships are not permitted as target on this resource"} + + defp check_permitted(role, roles, direction) when is_list(roles) do if role in roles do :ok else - {:error, "relationship role #{inspect(role)} is not permitted as source on this resource"} + {:error, + "relationship role #{inspect(role)} is not permitted as #{direction} on this resource"} end end end diff --git a/test/provider/extension/relationship_dsl_test.exs b/test/provider/extension/relationship_dsl_test.exs index 4397495..1e8d200 100644 --- a/test/provider/extension/relationship_dsl_test.exs +++ b/test/provider/extension/relationship_dsl_test.exs @@ -180,5 +180,19 @@ defmodule Diffo.Provider.Extension.RelationshipDslTest do assert {:error, error} = result assert Exception.message(error) =~ "not permitted as source" end + + test "relate action fails when target permits :none" do + {:ok, shelf1} = Parties.build_shelf_with_installer() + {:ok, shelf2} = Parties.build_shelf_with_installer() + + # ShelfInstance has target :none — being related to as target should fail + rel = %RelStruct{id: shelf2.id, alias: :connects, type: :service, direction: :forward} + + result = Diffo.Test.Servo.relate_shelf(shelf1, %{relationships: [rel]}) + + assert {:error, error} = result + assert Exception.message(error) =~ "not permitted as target" + end + end end From dab6c0f0f634402e0e36a24c6b727028875a8767 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 13:52:09 +0930 Subject: [PATCH 2/5] agents insight --- AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ba72f96..33968b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,25 @@ Spark runs two separate pipelines during compilation, in this order: **Current state:** `TransformBehaviour` is misregistered under `persisters:` — a known issue tracked for refactoring. New transformers go under `transformers:`. +## Raising upstream bugs + +When a bug is found in a dependency (e.g. AshNeo4j, Bolty), raise a GitHub issue on that +repository. Use **diffo issue #125** as the style reference: + +- **## Description** — explain what the system does, what the code path is, and where it + breaks. Include a short code snippet if it makes the failure concrete. +- **## What we need** — state the correct behaviour plainly. +- **## Why it matters** — explain the practical impact on Diffo and why fixing it unblocks + real work. +- Optionally add **## A possible direction** if there is a plausible fix worth suggesting. + +Do not use a step-by-step reproduction template; write in the same explanatory prose style +as #125. + +Once the issue is raised, stop. Do not attempt to locate or fix the root cause in the +dependency — the upstream maintainers have the full context of their own codebase; you do +not. Add any useful hypotheses as a follow-up comment on the issue, then leave it with them. + ## Common agent mistakes - Using old `structure do` / top-level `instances do` — use `provider do` only. From b57e0b6cbb98d0fb322465eb8257ee5ec42100c5 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Tue, 19 May 2026 15:30:00 +0930 Subject: [PATCH 3/5] agents and deps with failing tests --- AGENTS.md | 7 ++++ .../instance/extension/characteristic.ex | 34 ++++++++++++++++--- .../components/instance/extension/feature.ex | 31 +++++++++++++++-- .../instance/extension/specification.ex | 9 +++-- lib/diffo/provider/components/party_ref.ex | 16 +++------ mix.exs | 2 +- mix.lock | 12 +++---- 7 files changed, 85 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 33968b2..9b58c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,13 @@ on [Ash Framework](https://www.ash-hq.org/) + [AshNeo4j](https://github.com/diff 2. Read `CLAUDE.md` — dependency usage rules (Ash, Elixir, OTP, AshNeo4j, Spark). 3. Consult the skill at `.claude/skills/diffo-framework/` for Ash ecosystem patterns. +## Updating dependencies + +When updating a dependency (e.g. bumping `ash_neo4j`, `ash`, `spark` in `mix.exs`), always +run `mix usage_rules.sync` immediately after `mix deps.get`. Dependencies publish their own +usage rules; syncing pulls those changes into `CLAUDE.md` so you are working from the +up-to-date guidance before touching any code. + ## Project structure ``` diff --git a/lib/diffo/provider/components/instance/extension/characteristic.ex b/lib/diffo/provider/components/instance/extension/characteristic.ex index b746dfc..a331175 100644 --- a/lib/diffo/provider/components/instance/extension/characteristic.ex +++ b/lib/diffo/provider/components/instance/extension/characteristic.ex @@ -7,8 +7,9 @@ defmodule Diffo.Provider.Instance.Characteristic do require Logger alias Diffo.Provider - alias Diffo.Provider.Instance alias Diffo.Type.Value + alias AshNeo4j.Resource.Info, as: Neo4jInfo + alias AshNeo4j.Neo4jHelper @doc """ Struct for a Characteristic @@ -66,10 +67,35 @@ defmodule Diffo.Provider.Instance.Characteristic do def relate_instance(result, changeset) when is_struct(result) and is_struct(changeset, Ash.Changeset) do characteristics = Ash.Changeset.get_argument(changeset, :characteristics) + relate_to_instance(result, characteristics) + end - Provider.relate_instance_characteristics(%Instance{id: result.id}, %{ - characteristics: characteristics - }) + # Directly create HAS edges in Neo4j rather than going through manage_relationship. + # manage_relationship on a has_many triggers accessing_from updates on each + # Characteristic, which break because Ash.Resource.Info.reverse_relationship + # finds no path back to the concrete resource (ShelfInstance etc.) — Characteristic's + # belongs_to :instance targets the generic Diffo.Provider.Instance, not the + # domain-specific subtype. + defp relate_to_instance(result, nil), do: {:ok, result} + defp relate_to_instance(result, []), do: {:ok, result} + + defp relate_to_instance(result, char_ids) do + instance_label_pair = Neo4jInfo.label_pair(result.__struct__) + char_label = Neo4jInfo.label(Diffo.Provider.Characteristic) + + Enum.reduce_while(char_ids, {:ok, result}, fn char_id, acc -> + case Neo4jHelper.relate_nodes( + instance_label_pair, + %{uuid: result.id}, + char_label, + %{uuid: char_id}, + :HAS, + :outgoing + ) do + {:ok, _} -> {:cont, acc} + {:error, error} -> {:halt, {:error, error}} + end + end) end @doc """ diff --git a/lib/diffo/provider/components/instance/extension/feature.ex b/lib/diffo/provider/components/instance/extension/feature.ex index f0c8c75..9e1c723 100644 --- a/lib/diffo/provider/components/instance/extension/feature.ex +++ b/lib/diffo/provider/components/instance/extension/feature.ex @@ -7,8 +7,9 @@ defmodule Diffo.Provider.Instance.Feature do require Logger alias Diffo.Provider - alias Diffo.Provider.Instance alias Diffo.Type.Value + alias AshNeo4j.Resource.Info, as: Neo4jInfo + alias AshNeo4j.Neo4jHelper @doc """ Struct for a Feature @@ -94,7 +95,33 @@ defmodule Diffo.Provider.Instance.Feature do def relate_instance(result, changeset) when is_struct(result) and is_struct(changeset, Ash.Changeset) do features = Ash.Changeset.get_argument(changeset, :features) - Provider.relate_instance_features(%Instance{id: result.id}, %{features: features}) + relate_to_instance(result, features) + end + + # Directly create HAS edges rather than going through manage_relationship, + # for the same reason as Characteristic: the accessing_from path breaks because + # Feature's belongs_to :instance targets Diffo.Provider.Instance, not the + # domain-specific concrete resource (ShelfInstance etc.). + defp relate_to_instance(result, nil), do: {:ok, result} + defp relate_to_instance(result, []), do: {:ok, result} + + defp relate_to_instance(result, feature_ids) do + instance_label_pair = Neo4jInfo.label_pair(result.__struct__) + feature_label = Neo4jInfo.label(Diffo.Provider.Feature) + + Enum.reduce_while(feature_ids, {:ok, result}, fn feature_id, acc -> + case Neo4jHelper.relate_nodes( + instance_label_pair, + %{uuid: result.id}, + feature_label, + %{uuid: feature_id}, + :HAS, + :outgoing + ) do + {:ok, _} -> {:cont, acc} + {:error, error} -> {:halt, {:error, error}} + end + end) end defimpl String.Chars do diff --git a/lib/diffo/provider/components/instance/extension/specification.ex b/lib/diffo/provider/components/instance/extension/specification.ex index 25f892b..a446272 100644 --- a/lib/diffo/provider/components/instance/extension/specification.ex +++ b/lib/diffo/provider/components/instance/extension/specification.ex @@ -7,7 +7,6 @@ defmodule Diffo.Provider.Instance.Specification do require Logger alias Diffo.Provider - alias Diffo.Provider.Instance @doc """ Struct for a Specification @@ -48,7 +47,13 @@ defmodule Diffo.Provider.Instance.Specification do def relate_instance(result, changeset) when is_struct(result) and is_struct(changeset, Ash.Changeset) do specified_by = Ash.Changeset.get_argument(changeset, :specified_by) - Provider.respecify_instance(%Instance{id: result.id}, %{specified_by: specified_by}) + + # Clear specification_id so manage_relationship sees nil→id (add only, no spurious remove). + # action_helper pre-sets specification_id before calling us, which would make + # Ash treat old==new and generate an empty-argument remove that fails. + %{result | specification_id: nil} + |> Ash.Changeset.for_update(:specify, %{specified_by: specified_by}) + |> Ash.update() end defimpl String.Chars do diff --git a/lib/diffo/provider/components/party_ref.ex b/lib/diffo/provider/components/party_ref.ex index b5569c9..95ee843 100644 --- a/lib/diffo/provider/components/party_ref.ex +++ b/lib/diffo/provider/components/party_ref.ex @@ -51,17 +51,11 @@ defmodule Diffo.Provider.PartyRef do create :create do description "creates a party ref, relating an instance, place or source party to a party" - accept [:role] - - argument :instance_id, :uuid - argument :place_id, :string - argument :source_party_id, :string - argument :party_id, :string - - change manage_relationship(:instance_id, :instance, type: :append_and_remove) - change manage_relationship(:place_id, :place, type: :append_and_remove) - change manage_relationship(:source_party_id, :source_party, type: :append_and_remove) - change manage_relationship(:party_id, :party, type: :append_and_remove) + # IDs accepted directly as attributes so AshNeo4j's create_from_attributes path + # builds graph edges using the single labels in the relate DSL (:Instance, :Party, :Place). + # manage_relationship would fail: it looks up the generic Diffo.Provider.Instance/Party + # by label_pair, which doesn't match domain-specific subtypes (ShelfInstance, Person, etc.). + accept [:role, :instance_id, :place_id, :source_party_id, :party_id] end read :list do diff --git a/mix.exs b/mix.exs index 485f949..e22ab76 100644 --- a/mix.exs +++ b/mix.exs @@ -124,7 +124,7 @@ defmodule Diffo.MixProject do {:ash_outstanding, "~> 0.2.3"}, {:ash_jason, "~> 3.0"}, {:ash_state_machine, "~> 0.2.12"}, - {:ash_neo4j, ash_neo4j_version("~> 0.5")}, + {:ash_neo4j, ash_neo4j_version("~> 0.6")}, {:bolty, ">= 0.0.12"}, {:ash, ash_version("~> 3.0 and >= 3.24.2")}, {:uuid, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index 90e01e6..44d757b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,11 @@ %{ - "ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"}, + "ash": {:hex, :ash, "3.25.2", "d23c52a9f823e98895d0cf1dc8bbf5d22943ffa45ba087e583d94bb05d205b2e", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4e3fb9252719dd3fec84610a5a19e309f298265076da23c0bef21de237e98bb"}, "ash_jason": {:hex, :ash_jason, "3.1.0", "84a88dfe5e25a20d55cf2d2664885cd086fa45871e8777aedc3ad96a282e2a6f", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.1.21 and < 3.0.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "71e6bbc421fb2cf7079f8804814145cca458116c839fc798f9606b806e07eb2b"}, - "ash_neo4j": {:hex, :ash_neo4j, "0.5.1", "cc42a577bb1608ad576872babd3a774cc3bbb540f7e8cee2208562fb203aae59", [:mix], [{:ash, ">= 3.24.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:bolty, ">= 0.0.12", [hex: :bolty, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:usage_rules, "~> 1.2", [hex: :usage_rules, repo: "hexpm", optional: true]}], "hexpm", "ccd993b5856923122784d8fd8090c98f7996f72718f88e649b68fb3fc4fa776d"}, + "ash_neo4j": {:hex, :ash_neo4j, "0.6.0", "8814efcd122d83a6bf6734b2c8ab9119deb9ab5412e267e6f71a4627db9ccf63", [:mix], [{:ash, ">= 3.24.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:bolty, ">= 0.0.12", [hex: :bolty, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.7.0", [hex: :spark, repo: "hexpm", optional: false]}, {:usage_rules, "~> 1.2", [hex: :usage_rules, repo: "hexpm", optional: true]}], "hexpm", "2cceba9ce60331fa73b256503484119f7b578c2a87b4bfc0a6c3545ae853ac36"}, "ash_outstanding": {:hex, :ash_outstanding, "0.2.4", "c72b91f1b8e4859fb033eddf66d0ba36cfd8af0c2a9748c7ef9e6ccfdb5d093d", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:outstanding, "~> 0.2.4", [hex: :outstanding, repo: "hexpm", optional: false]}], "hexpm", "64ba8f582ce69c9050352c75f0895db186c7a56f35039dab34c8e1ab7516f9ce"}, "ash_state_machine": {:hex, :ash_state_machine, "0.2.13", "e1c368ebf01ef73477739ee76d53e513d073b141ec11e7bf7f91d8f2d8fc9569", [:mix], [{:ash, ">= 3.4.66 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "aa21c92a8950850df69b5205bf41efc1e502f5ab839425ba08561f0421c9f226"}, "bolty": {:hex, :bolty, "0.0.12", "5311de46c29c71000c51cfb23fc181359daa49cedb9c8c4ba1e245f3e54079ae", [:mix], [{:db_connection, "~> 2.7.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "0760661dd2f0ba9f2901448c1be00fc1ed228780644ba21a2400d0662595ee10"}, - "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, + "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, @@ -19,23 +19,23 @@ "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, + "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "outstanding": {:hex, :outstanding, "0.2.5", "2f40416eb9617748cb1f8ae4c8ed94515d731f9c4fcee4f902355d30bc0792cc", [:mix], [], "hexpm", "bb47a210f0d2804ea6b8477fa6f4d15e8c58c18acee79d8e06c9296e6dd004cd"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, + "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"}, - "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, + "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, From 70de7187b2f42452a8526c1cd8d09be4d4ee5e8a Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Tue, 19 May 2026 23:39:58 +0930 Subject: [PATCH 4/5] ash_neo4j 0.6.0 upgrade + domain extension pattern --- AGENTS.md | 72 +++++++++++++++++-- lib/diffo/provider/components/party_ref.ex | 16 +++-- lib/diffo/provider/domain_fragment.ex | 33 +++++++++ test/diffo_test.exs | 1 + test/provider/characteristic_test.exs | 1 + .../defined_simple_relationship_test.exs | 1 + test/provider/entity_ref_test.exs | 1 + test/provider/entity_test.exs | 1 + test/provider/event_test.exs | 1 + test/provider/extension/assigner_test.exs | 1 + .../extension/characteristic_test.exs | 1 + test/provider/extension/feature_test.exs | 1 + test/provider/extension/info_test.exs | 1 + .../extension/instance_transformer_test.exs | 1 + .../extension/instance_verifier_test.exs | 1 + test/provider/extension/party_test.exs | 1 + .../extension/party_transformer_test.exs | 1 + .../extension/party_verifier_test.exs | 1 + test/provider/extension/place_test.exs | 1 + .../extension/place_transformer_test.exs | 1 + .../extension/place_verifier_test.exs | 1 + .../extension/relationship_dsl_test.exs | 1 + .../provider/extension/specification_test.exs | 1 + test/provider/external_identifier_test.exs | 1 + test/provider/feature_test.exs | 1 + test/provider/instance_test.exs | 1 + test/provider/instance_util_test.exs | 1 + test/provider/note_test.exs | 1 + test/provider/party_ref_test.exs | 1 + test/provider/party_test.exs | 1 + test/provider/place_ref_test.exs | 1 + test/provider/place_test.exs | 1 + test/provider/process_status_test.exs | 1 + test/provider/reference_test.exs | 1 + test/provider/relationship_test.exs | 1 + test/provider/specification_test.exs | 1 + test/provider/versioning_test.exs | 1 + test/support/nbn.ex | 3 +- test/support/servo.ex | 3 +- test/type/dynamic_test.exs | 1 + test/type/primitive_test.exs | 1 + test/type/unwrap_test.exs | 1 + test/type/value_test.exs | 1 + usage-rules.md | 70 ++++++++++++++++-- 44 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 lib/diffo/provider/domain_fragment.ex diff --git a/AGENTS.md b/AGENTS.md index 9b58c3d..52f4e6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,15 +173,72 @@ provider do end ``` +## Three usage scenarios + +Diffo supports three distinct usage patterns. Every test is tagged with one or more of these +atoms — absence of all three means the test has not yet been classified. + +| Tag | Scenario | Description | +|-----|----------|-------------| +| `:provider_only` | Vanilla Provider | Uses `Diffo.Provider` resources as-is. No custom domains, no extensions. Good for basic TMF inventory and for introducing Diffo incrementally. | +| `:provider_extended` | Extended within Provider | New resource types defined inside `Diffo.Provider` itself, extending base fragments (e.g. `DefinedSimpleRelationship`). Pain point: external users can't add to the Provider domain without forking Diffo. | +| `:domain_extended` | True domain extension | The **recommended pattern**. An external domain (e.g. `MyApp.SRM`) owns resources using `BaseInstance`, `BaseParty`, `BasePlace`, and `BaseCharacteristic` fragments. Exposes its own API; consumers need not know about Diffo internals. | + +Tests may carry `:provider_extended` and `:domain_extended` together when they span both. +`:provider_only` is mutually exclusive with the other two. + +## Domain extension pattern (scenario 3) + +Any domain whose resources carry `belongs_to :instance, Diffo.Provider.Instance` (or +`belongs_to :party, Diffo.Provider.Party`) and use `manage_relationship` to relate them +**must** include `Diffo.Provider.DomainFragment`: + +```elixir +defmodule MyApp.SRM do + use Ash.Domain, fragments: [Diffo.Provider.DomainFragment] + ... +end +``` + +**Why this is necessary.** AshNeo4j 0.6.0 matches nodes using +`label_pair = [domain_label, module_label]`. `Ash.get(Diffo.Provider.Instance, uuid)` builds +`MATCH (n:Provider:Instance {uuid: $uuid})`. A `ShelfInstance` node in `MyApp.SRM` has +labels `[:SRM, :ShelfInstance, :Instance]` — `:Provider` is absent, so the lookup returns +not-found and `manage_relationship` fails. + +`Diffo.Provider.DomainFragment` tells AshNeo4j to write `:Provider` as an extra label on +every node in the domain at CREATE time. `ShelfInstance` then carries +`[:SRM, :ShelfInstance, :Instance, :Provider]`. Neo4j matches nodes that have **all** +specified labels regardless of extras, so `MATCH (n:Provider:Instance {uuid: $uuid})` finds +it. `label_pair` for direct reads on `ShelfInstance` is still `[:SRM, :ShelfInstance]` — +its own-domain reads remain correctly scoped. + +### has_many and the accessing_from path + +A separate constraint applies when a `has_many` relationship uses `manage_relationship` on +the source side: AshNeo4j 0.6.0's `accessing_from` path calls +`Ash.Resource.Info.reverse_relationship/2`, which does a strict type-equality check. If +`Characteristic.belongs_to :instance` targets `Diffo.Provider.Instance` but the actual +source is `ShelfInstance`, the check fails and the edge is not created. + +The fix used in Diffo's extension helpers (`Characteristic.relate_instance`, +`Feature.relate_instance`) is to bypass `manage_relationship` on the source side entirely +and call `AshNeo4j.Neo4jHelper.relate_nodes/6` directly, using the concrete +`result.__struct__` label pair. See +`lib/diffo/provider/components/instance/extension/characteristic.ex` and `feature.ex`. + ## Running tests Integration tests require a running Neo4j instance. ```sh -mix test # full suite -mix test test/provider/extension/ # extension tests only -mix test path/to/test.exs:LINE # single test -mix test --max-failures 5 # stop early +mix test # full suite +mix test --only domain_extended # scenario 3 tests only +mix test --only provider_only # vanilla provider tests only +mix test --only provider_extended # extended-within-provider tests only +mix test test/provider/extension/ # extension directory only +mix test path/to/test.exs:LINE # single test +mix test --max-failures 5 # stop early ``` ## Module naming and Neo4j labels @@ -256,3 +313,10 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i Run `mix format` afterward to verify. - Editing content between `` markers in `CLAUDE.md` — that is auto-generated by `mix usage_rules.sync`. +- Forgetting `Diffo.Provider.DomainFragment` on a scenario 3 domain — any domain whose + resources relate back to Provider base types (`belongs_to :instance, Diffo.Provider.Instance` + etc.) via `manage_relationship` will get `Ash.Error.Query.NotFound` at runtime without it. + See the **Domain extension pattern** section above. +- Bypassing `manage_relationship` by replacing `argument + manage_relationship` with bare + `accept` for relationship IDs in scenario 3 resources — the correct fix is the domain + fragment, not removing the relationship management. diff --git a/lib/diffo/provider/components/party_ref.ex b/lib/diffo/provider/components/party_ref.ex index 95ee843..b5569c9 100644 --- a/lib/diffo/provider/components/party_ref.ex +++ b/lib/diffo/provider/components/party_ref.ex @@ -51,11 +51,17 @@ defmodule Diffo.Provider.PartyRef do create :create do description "creates a party ref, relating an instance, place or source party to a party" - # IDs accepted directly as attributes so AshNeo4j's create_from_attributes path - # builds graph edges using the single labels in the relate DSL (:Instance, :Party, :Place). - # manage_relationship would fail: it looks up the generic Diffo.Provider.Instance/Party - # by label_pair, which doesn't match domain-specific subtypes (ShelfInstance, Person, etc.). - accept [:role, :instance_id, :place_id, :source_party_id, :party_id] + accept [:role] + + argument :instance_id, :uuid + argument :place_id, :string + argument :source_party_id, :string + argument :party_id, :string + + change manage_relationship(:instance_id, :instance, type: :append_and_remove) + change manage_relationship(:place_id, :place, type: :append_and_remove) + change manage_relationship(:source_party_id, :source_party, type: :append_and_remove) + change manage_relationship(:party_id, :party, type: :append_and_remove) end read :list do diff --git a/lib/diffo/provider/domain_fragment.ex b/lib/diffo/provider/domain_fragment.ex new file mode 100644 index 0000000..eccc7d2 --- /dev/null +++ b/lib/diffo/provider/domain_fragment.ex @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.DomainFragment do + @moduledoc """ + Domain fragment for Ash domains that extend the Diffo Provider. + + Include this fragment in any domain whose resources need to participate in provider + polymorphism — i.e., where `belongs_to :instance, Diffo.Provider.Instance` or + `belongs_to :party, Diffo.Provider.Party` relationships must resolve via `manage_relationship`. + + Adding this fragment causes AshNeo4j to write `:Provider` as an additional label on every + node in the domain at CREATE time. Because AshNeo4j MATCH patterns include all node labels, + `Ash.get(Diffo.Provider.Instance, uuid)` (which matches on `[:Provider, :Instance]`) will + then find concrete instance nodes (e.g. `ShelfInstance`) that carry both `:Instance` (from + `BaseInstance`) and `:Provider` (from this fragment). + + ## Usage + + defmodule MyApp.SRM do + use Ash.Domain, fragments: [Diffo.Provider.DomainFragment] + ... + end + """ + use Spark.Dsl.Fragment, + of: Ash.Domain, + extensions: [AshNeo4j.DataLayer.Domain] + + neo4j do + label :Provider + end +end diff --git a/test/diffo_test.exs b/test/diffo_test.exs index 7df32d7..4804391 100644 --- a/test/diffo_test.exs +++ b/test/diffo_test.exs @@ -5,6 +5,7 @@ defmodule DiffoTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only doctest Diffo doctest Diffo.Unwrap doctest Diffo.Type.Primitive diff --git a/test/provider/characteristic_test.exs b/test/provider/characteristic_test.exs index c719c84..6fd8fb0 100644 --- a/test/provider/characteristic_test.exs +++ b/test/provider/characteristic_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.CharacteristicTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Test.Patch alias Diffo.Type.Value diff --git a/test/provider/defined_simple_relationship_test.exs b/test/provider/defined_simple_relationship_test.exs index 2e48b67..340c496 100644 --- a/test/provider/defined_simple_relationship_test.exs +++ b/test/provider/defined_simple_relationship_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.DefinedSimpleRelationshipTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_extended alias Diffo.Type.NameValuePrimitive alias Diffo.Type.Primitive diff --git a/test/provider/entity_ref_test.exs b/test/provider/entity_ref_test.exs index aafacff..ac726fc 100644 --- a/test/provider/entity_ref_test.exs +++ b/test/provider/entity_ref_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.EntityRefTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only use Outstand alias Diffo.Provider.Entity alias Diffo.Provider.EntityRef diff --git a/test/provider/entity_test.exs b/test/provider/entity_test.exs index 07d14bc..113119a 100644 --- a/test/provider/entity_test.exs +++ b/test/provider/entity_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.EntityTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only use Outstand setup do diff --git a/test/provider/event_test.exs b/test/provider/event_test.exs index f560585..1d6f341 100644 --- a/test/provider/event_test.exs +++ b/test/provider/event_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.EventTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only setup do AshNeo4j.Sandbox.checkout() diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index cd5fdbc..54de795 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Provider.Specification alias Diffo.Provider.Characteristic alias Diffo.Provider.Assignment diff --git a/test/provider/extension/characteristic_test.exs b/test/provider/extension/characteristic_test.exs index c961704..06dbad2 100644 --- a/test/provider/extension/characteristic_test.exs +++ b/test/provider/extension/characteristic_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.CharacteristicTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Test.Parties setup do diff --git a/test/provider/extension/feature_test.exs b/test/provider/extension/feature_test.exs index 1122379..93ca830 100644 --- a/test/provider/extension/feature_test.exs +++ b/test/provider/extension/feature_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.FeatureTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Test.Parties setup do diff --git a/test/provider/extension/info_test.exs b/test/provider/extension/info_test.exs index 3a508f4..297c17a 100644 --- a/test/provider/extension/info_test.exs +++ b/test/provider/extension/info_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.InfoTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Provider.Extension.Info diff --git a/test/provider/extension/instance_transformer_test.exs b/test/provider/extension/instance_transformer_test.exs index 779155e..f8702e7 100644 --- a/test/provider/extension/instance_transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true + @moduletag :domain_extended alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Instance.CardInstance diff --git a/test/provider/extension/instance_verifier_test.exs b/test/provider/extension/instance_verifier_test.exs index ac781ca..2ab889e 100644 --- a/test/provider/extension/instance_verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false + @moduletag :domain_extended alias Diffo.Test.Util describe "specification verifier" do diff --git a/test/provider/extension/party_test.exs b/test/provider/extension/party_test.exs index 34178bf..989defe 100644 --- a/test/provider/extension/party_test.exs +++ b/test/provider/extension/party_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.PartyTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo alias Diffo.Provider.Party.Extension.Info, as: PartyInfo diff --git a/test/provider/extension/party_transformer_test.exs b/test/provider/extension/party_transformer_test.exs index 4386133..ffa7589 100644 --- a/test/provider/extension/party_transformer_test.exs +++ b/test/provider/extension/party_transformer_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.PartyTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true + @moduletag :domain_extended alias Diffo.Test.Party.Organization alias Diffo.Test.Party.Person diff --git a/test/provider/extension/party_verifier_test.exs b/test/provider/extension/party_verifier_test.exs index a4bec88..022aa7b 100644 --- a/test/provider/extension/party_verifier_test.exs +++ b/test/provider/extension/party_verifier_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false + @moduletag :domain_extended alias Diffo.Test.Util describe "instances verifier" do diff --git a/test/provider/extension/place_test.exs b/test/provider/extension/place_test.exs index 9318745..76a1a86 100644 --- a/test/provider/extension/place_test.exs +++ b/test/provider/extension/place_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.PlaceTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo alias Diffo.Provider.Place.Extension.Info, as: PlaceInfo diff --git a/test/provider/extension/place_transformer_test.exs b/test/provider/extension/place_transformer_test.exs index e0137c6..2978794 100644 --- a/test/provider/extension/place_transformer_test.exs +++ b/test/provider/extension/place_transformer_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.PlaceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true + @moduletag :domain_extended alias Diffo.Test.Place.GeographicSite alias Diffo.Provider.Extension.InstanceRole diff --git a/test/provider/extension/place_verifier_test.exs b/test/provider/extension/place_verifier_test.exs index 3d10534..82d0adf 100644 --- a/test/provider/extension/place_verifier_test.exs +++ b/test/provider/extension/place_verifier_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false + @moduletag :domain_extended alias Diffo.Test.Util describe "instances verifier" do diff --git a/test/provider/extension/relationship_dsl_test.exs b/test/provider/extension/relationship_dsl_test.exs index 1e8d200..a3c4678 100644 --- a/test/provider/extension/relationship_dsl_test.exs +++ b/test/provider/extension/relationship_dsl_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.RelationshipDslTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Test.Util alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Instance.CardInstance diff --git a/test/provider/extension/specification_test.exs b/test/provider/extension/specification_test.exs index 6f843c4..9362f7a 100644 --- a/test/provider/extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Extension.SpecificationTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Test.Servo alias Diffo.Test.Instance.ShelfInstance diff --git a/test/provider/external_identifier_test.exs b/test/provider/external_identifier_test.exs index bb7487c..3c72b44 100644 --- a/test/provider/external_identifier_test.exs +++ b/test/provider/external_identifier_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.ExternalIdentifierTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Provider.ExternalIdentifier alias Diffo.Provider.Party diff --git a/test/provider/feature_test.exs b/test/provider/feature_test.exs index ad70b66..920e342 100644 --- a/test/provider/feature_test.exs +++ b/test/provider/feature_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.FeatureTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Type.Value diff --git a/test/provider/instance_test.exs b/test/provider/instance_test.exs index cbd1c75..dd2d052 100644 --- a/test/provider/instance_test.exs +++ b/test/provider/instance_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.InstanceTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Type.Value setup do diff --git a/test/provider/instance_util_test.exs b/test/provider/instance_util_test.exs index b8344b8..a5de12f 100644 --- a/test/provider/instance_util_test.exs +++ b/test/provider/instance_util_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.Instance.UtilTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Provider.Instance.Util diff --git a/test/provider/note_test.exs b/test/provider/note_test.exs index 4473475..b8931c1 100644 --- a/test/provider/note_test.exs +++ b/test/provider/note_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.NoteTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Provider.Party alias Diffo.Provider.Instance diff --git a/test/provider/party_ref_test.exs b/test/provider/party_ref_test.exs index 67f9bb6..c9e85d6 100644 --- a/test/provider/party_ref_test.exs +++ b/test/provider/party_ref_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.PartyRefTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only use Outstand setup do diff --git a/test/provider/party_test.exs b/test/provider/party_test.exs index a0e7569..758df0b 100644 --- a/test/provider/party_test.exs +++ b/test/provider/party_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.PartyTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only use Outstand setup do diff --git a/test/provider/place_ref_test.exs b/test/provider/place_ref_test.exs index 47ea2fe..3362b5b 100644 --- a/test/provider/place_ref_test.exs +++ b/test/provider/place_ref_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.PlaceRefTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only use Outstand setup do diff --git a/test/provider/place_test.exs b/test/provider/place_test.exs index 5980cfe..460ceff 100644 --- a/test/provider/place_test.exs +++ b/test/provider/place_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.PlaceTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only use Outstand setup do diff --git a/test/provider/process_status_test.exs b/test/provider/process_status_test.exs index 5bf7ef1..0a9dcca 100644 --- a/test/provider/process_status_test.exs +++ b/test/provider/process_status_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.ProcessStatusTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only setup do AshNeo4j.Sandbox.checkout() diff --git a/test/provider/reference_test.exs b/test/provider/reference_test.exs index 578b224..0646894 100644 --- a/test/provider/reference_test.exs +++ b/test/provider/reference_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.ReferenceTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Provider.Reference diff --git a/test/provider/relationship_test.exs b/test/provider/relationship_test.exs index fbb4733..df105b9 100644 --- a/test/provider/relationship_test.exs +++ b/test/provider/relationship_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.RelationshipTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Type.Value diff --git a/test/provider/specification_test.exs b/test/provider/specification_test.exs index be5d808..fd96002 100644 --- a/test/provider/specification_test.exs +++ b/test/provider/specification_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.SpecificationTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :provider_only setup do AshNeo4j.Sandbox.checkout() diff --git a/test/provider/versioning_test.exs b/test/provider/versioning_test.exs index 555350d..eebc066 100644 --- a/test/provider/versioning_test.exs +++ b/test/provider/versioning_test.exs @@ -5,6 +5,7 @@ defmodule Diffo.Provider.VersioningTest do @moduledoc false use ExUnit.Case, async: true + @moduletag :domain_extended alias Diffo.Test.Servo alias Diffo.Test.Instance.Broadband diff --git a/test/support/nbn.ex b/test/support/nbn.ex index 337aac4..adf2e61 100644 --- a/test/support/nbn.ex +++ b/test/support/nbn.ex @@ -10,7 +10,8 @@ defmodule Diffo.Test.Nbn do """ use Ash.Domain, otp_app: :diffo, - validate_config_inclusion?: false + validate_config_inclusion?: false, + fragments: [Diffo.Provider.DomainFragment] alias Diffo.Test.Party.Organization alias Diffo.Test.Party.Person diff --git a/test/support/servo.ex b/test/support/servo.ex index 7a2f49b..95a222f 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -10,7 +10,8 @@ defmodule Diffo.Test.Servo do """ use Ash.Domain, otp_app: :diffo, - validate_config_inclusion?: false + validate_config_inclusion?: false, + fragments: [Diffo.Provider.DomainFragment] alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Instance.CardInstance diff --git a/test/type/dynamic_test.exs b/test/type/dynamic_test.exs index 8989f73..12cb8b0 100644 --- a/test/type/dynamic_test.exs +++ b/test/type/dynamic_test.exs @@ -4,6 +4,7 @@ defmodule Diffo.Type.DynamicTest do use ExUnit.Case, async: true + @moduletag :domain_extended use Outstand alias Diffo.Type.Dynamic diff --git a/test/type/primitive_test.exs b/test/type/primitive_test.exs index d3ea968..f8590fe 100644 --- a/test/type/primitive_test.exs +++ b/test/type/primitive_test.exs @@ -4,6 +4,7 @@ defmodule Diffo.Type.PrimitiveTest do use ExUnit.Case, async: true + @moduletag :provider_only use Outstand alias Diffo.Type.Primitive diff --git a/test/type/unwrap_test.exs b/test/type/unwrap_test.exs index e89c5f7..07e90c2 100644 --- a/test/type/unwrap_test.exs +++ b/test/type/unwrap_test.exs @@ -4,6 +4,7 @@ defmodule Diffo.UnwrapTest do use ExUnit.Case, async: true + @moduletag :provider_only alias Diffo.Type.Primitive alias Diffo.Type.Value diff --git a/test/type/value_test.exs b/test/type/value_test.exs index eee3408..60b52e4 100644 --- a/test/type/value_test.exs +++ b/test/type/value_test.exs @@ -4,6 +4,7 @@ defmodule Diffo.Type.ValueTest do use ExUnit.Case, async: true + @moduletag :provider_only use Outstand alias Diffo.Type.Value diff --git a/usage-rules.md b/usage-rules.md index 1315f17..6491e7c 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -13,6 +13,47 @@ and Resource Management domains on top of a Neo4j graph database. It provides th fragments — `BaseInstance`, `BaseParty`, `BasePlace` — plus the unified `Diffo.Provider.Extension` DSL. Read these rules and the Ash/AshNeo4j usage rules **before** writing any domain code. +## The recommended usage pattern + +Build your own Ash domain. Do not add your resources to `Diffo.Provider` — that domain is +Diffo's internal plumbing and its API is intentionally closed. Your domain owns its own API, +which it exposes to consumers who need know nothing about Diffo or TMF internals. The Diffo +Provider is an implementation detail that your domain depends on, not something your consumers +touch directly. + +```elixir +defmodule MyApp.SRM do + use Ash.Domain, fragments: [Diffo.Provider.DomainFragment] + + resources do + resource MyApp.BroadbandService + resource MyApp.RSP + resource MyApp.GeographicSite + resource MyApp.SpeedCharacteristic + end +end +``` + +`Diffo.Provider.DomainFragment` is **required** for any domain whose resources use the Diffo +base fragments. It causes AshNeo4j to write `:Provider` as an additional label on every node +in your domain at CREATE time. Without it, Ash's relationship management cannot resolve your +concrete resource nodes (e.g. `BroadbandService`) through the provider base type lookups +(e.g. `Diffo.Provider.Instance`) that Diffo uses internally — the lookups will silently return +not-found and relationships will fail to be established. + +See `Diffo.Provider.DomainFragment` for the technical details. + +### Neo4j database access policy + +Neo4j Browser (or Neo4j Bloom) is an excellent way to **observe** your graph — explore +relationships, verify that nodes have the right labels and properties, debug unexpected +structure. Use it freely for this purpose. + +**All data reads and writes must go through Ash and AshNeo4j.** Do not issue Cypher queries +directly from application code, scripts, or migrations to mutate or authoritatively read data. +AshNeo4j manages label consistency, relationship integrity, and type casting; bypassing it +produces nodes that Ash cannot find or interpret correctly. + ## The three kinds of domain resource | Kind | Base fragment | Marker extension | @@ -30,7 +71,7 @@ Always start from the appropriate base fragment: ```elixir defmodule MyApp.BroadbandService do - use Ash.Resource, fragments: [Diffo.Provider.BaseInstance], domain: MyApp.Domain + use Ash.Resource, fragments: [Diffo.Provider.BaseInstance], domain: MyApp.SRM ... end ``` @@ -89,7 +130,7 @@ its own attributes, a `:value` calculation, and create/update actions: defmodule MyApp.SpeedCharacteristic do use Ash.Resource, fragments: [Diffo.Provider.BaseCharacteristic], - domain: MyApp.Domain + domain: MyApp.SRM attributes do attribute :downstream_mbps, :integer, public?: true @@ -397,9 +438,20 @@ label `:Card` and queries are ambiguous. Rename to `CardInstance` and `CardChara ## Complete example ```elixir +# Domain — include the fragment so manage_relationship resolves across domains +defmodule MyApp.SRM do + use Ash.Domain, fragments: [Diffo.Provider.DomainFragment] + + resources do + resource MyApp.BroadbandService + resource MyApp.RSP + resource MyApp.GeographicSite + end +end + # Instance resource defmodule MyApp.BroadbandService do - use Ash.Resource, fragments: [Diffo.Provider.BaseInstance], domain: MyApp.Domain + use Ash.Resource, fragments: [Diffo.Provider.BaseInstance], domain: MyApp.SRM resource do description "An ADSL broadband service" @@ -446,7 +498,7 @@ end # Party resource defmodule MyApp.RSP do - use Ash.Resource, fragments: [Diffo.Provider.BaseParty], domain: MyApp.Domain + use Ash.Resource, fragments: [Diffo.Provider.BaseParty], domain: MyApp.SRM resource do description "A Retail Service Provider" @@ -472,7 +524,7 @@ end # Place resource defmodule MyApp.GeographicSite do - use Ash.Resource, fragments: [Diffo.Provider.BasePlace], domain: MyApp.Domain + use Ash.Resource, fragments: [Diffo.Provider.BasePlace], domain: MyApp.SRM resource do description "A geographic site" @@ -499,6 +551,14 @@ end ## Common mistakes +- **Do not add your resources to `Diffo.Provider`** — that domain is closed. Build your own + domain using `fragments: [Diffo.Provider.DomainFragment]` and put your resources there. +- **Do not omit `Diffo.Provider.DomainFragment` from your domain** — without it, `manage_relationship` + calls on resources with `belongs_to :instance, Diffo.Provider.Instance` (and similar) will + fail at runtime with not-found errors because AshNeo4j cannot match your concrete nodes + through the provider base type label pair. See the **recommended usage pattern** section. +- **Do not issue Cypher queries directly from application code** — all reads and writes must + go through Ash and AshNeo4j. Neo4j Browser is for observation only. - **Do not use `structure do` or top-level `instances do`/`parties do`/`places do`** — these are the old pre-0.3.0 syntax. All declarations belong inside `provider do`. - **Do not use `party :role, Type, reference: true`** — use `party_ref :role, Type` instead. From fa3d802ddc7f59c6668bf9b495d9195c793d1cfc Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 00:25:44 +0930 Subject: [PATCH 5/5] refactor as Validator --- AGENTS.md | 11 +++- .../provider/components/base_instance.ex | 5 +- .../validate_relationship_permitted.ex | 56 ++++++++++--------- 3 files changed, 43 insertions(+), 29 deletions(-) rename lib/diffo/provider/{changes => validations}/validate_relationship_permitted.ex (57%) diff --git a/AGENTS.md b/AGENTS.md index 52f4e6f..1854507 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ lib/diffo/provider/ transform_relationships.ex # TransformRelationships — resolves relationships pipeline, bakes permitted_source_roles/0 and permitted_target_roles/0 verifiers/ verify_relationships.ex # Verifies relationship role declarations are atoms - changes/ + validations/ validate_relationship_permitted.ex # ValidateRelationshipPermitted — enforces relationships do policy on relate actions assigner/ assigner.ex # Diffo.Provider.Assigner — assign/3 (pools do) and assign/4 @@ -320,3 +320,12 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i - Bypassing `manage_relationship` by replacing `argument + manage_relationship` with bare `accept` for relationship IDs in scenario 3 resources — the correct fix is the domain fragment, not removing the relationship management. +- Writing `Ash.Resource.Validation` with fail-fast short-circuits between independent checks — + Ash uses Splode to accumulate errors, so all independent validations should run and all + errors should be collected before returning. Resist the imperative instinct to return on + the first failure; instead collect errors from every check and return the full list in one + `{:error, errors}`. Only short-circuit when a later check genuinely cannot run without the + earlier one succeeding (e.g. the earlier check resolves data the later check depends on). +- Using `Ash.Resource.Change` for pure permission or constraint checks — anything that only + decides valid/invalid with no side effects belongs in `Ash.Resource.Validation`, not a + change. Changes are for mutations. diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 108560c..4d2c028 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -461,7 +461,10 @@ defmodule Diffo.Provider.BaseInstance do changes do change Diffo.Provider.Instance.Extension.Changes.BuildBefore, on: [:create] change Diffo.Provider.Instance.Extension.Changes.BuildAfter, on: [:create] - change Diffo.Provider.Changes.ValidateRelationshipPermitted, on: [:update] + end + + validations do + validate Diffo.Provider.Validations.ValidateRelationshipPermitted, on: [:update] end actions do diff --git a/lib/diffo/provider/changes/validate_relationship_permitted.ex b/lib/diffo/provider/validations/validate_relationship_permitted.ex similarity index 57% rename from lib/diffo/provider/changes/validate_relationship_permitted.ex rename to lib/diffo/provider/validations/validate_relationship_permitted.ex index 770871f..3e7b18f 100644 --- a/lib/diffo/provider/changes/validate_relationship_permitted.ex +++ b/lib/diffo/provider/validations/validate_relationship_permitted.ex @@ -2,55 +2,60 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Provider.Changes.ValidateRelationshipPermitted do +defmodule Diffo.Provider.Validations.ValidateRelationshipPermitted do @moduledoc false - use Ash.Resource.Change + use Ash.Resource.Validation @impl true - def change(changeset, _opts, _context) do + def init(opts), do: {:ok, opts} + + @impl true + def validate(changeset, _opts, _context) do case Ash.Changeset.get_argument(changeset, :relationships) do - nil -> changeset - [] -> changeset - rels -> changeset |> validate_source_roles(rels) |> validate_target_roles(rels) + nil -> :ok + [] -> :ok + rels -> check(changeset, rels) + end + end + + defp check(changeset, rels) do + case source_errors(changeset, rels) ++ target_errors(changeset, rels) do + [] -> :ok + errors -> {:error, errors} end end - defp validate_source_roles(changeset, rels) do + defp source_errors(changeset, rels) do permitted = changeset.resource.permitted_source_roles() - Enum.reduce(rels, changeset, fn rel, cs -> + Enum.flat_map(rels, fn rel -> case check_permitted(rel_alias(rel), permitted, :source) do - :ok -> cs - {:error, msg} -> Ash.Changeset.add_error(cs, msg) + :ok -> [] + {:error, msg} -> [[field: :relationships, message: msg]] end end) end - defp validate_target_roles(changeset, rels) do + defp target_errors(changeset, rels) do spec_id_to_module = build_spec_id_map(changeset.domain) - Enum.reduce(rels, changeset, fn rel, cs -> + Enum.flat_map(rels, fn rel -> target_id = Map.get(rel, :id) || Map.get(rel, "id") - case resolve_target_module(target_id, spec_id_to_module, changeset.domain) do + case resolve_target_module(target_id, spec_id_to_module) do {:ok, module} -> case check_permitted(rel_alias(rel), module.permitted_target_roles(), :target) do - :ok -> cs - {:error, msg} -> Ash.Changeset.add_error(cs, msg) + :ok -> [] + {:error, msg} -> [[field: :relationships, message: msg]] end :error -> - Ash.Changeset.add_error( - cs, - "could not resolve target resource for id #{inspect(target_id)}" - ) + [[field: :relationships, message: "could not resolve target resource for id #{inspect(target_id)}"] + ] end end) end - # Builds a map of %{spec_uuid => module} from all Instance resource modules in the - # domain that have both permitted_target_roles/0 and specification/0 baked by the - # provider extension. Used for O(1) module lookup after resolving the target's spec id. defp build_spec_id_map(domain) do domain |> Ash.Domain.Info.resources() @@ -61,9 +66,7 @@ defmodule Diffo.Provider.Changes.ValidateRelationshipPermitted do |> Map.new(fn module -> {module.specification()[:id], module} end) end - # Fetches the specification UUID for the target instance via a direct Cypher query, - # then does an O(1) lookup in spec_id_to_module to find the resource module. - defp resolve_target_module(id, spec_id_to_module, _domain) do + defp resolve_target_module(id, spec_id_to_module) do case AshNeo4j.Cypher.run( "MATCH (n:Instance {uuid: $uuid})-[:SPECIFIED_BY]->(s) RETURN s.uuid AS spec_id", %{"uuid" => id} @@ -96,8 +99,7 @@ defmodule Diffo.Provider.Changes.ValidateRelationshipPermitted do if role in roles do :ok else - {:error, - "relationship role #{inspect(role)} is not permitted as #{direction} on this resource"} + {:error, "relationship role #{inspect(role)} is not permitted as #{direction} on this resource"} end end end