From 23a3f4d25cc54284d38b4e67d162f2f4a3a60cb0 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 23:31:04 +0930 Subject: [PATCH 01/23] improve verify_characterisic, fix shelf bug --- .../assigner/assignable_characteristic.ex | 14 ++++- .../components/calculations/free_values.ex | 32 ++++++++++ lib/diffo/provider/extension/info.ex | 7 +++ lib/diffo/provider/extension/pool.ex | 4 +- .../verifiers/verify_characteristics.ex | 38 ++++++++---- .../extension/verifiers/verify_features.ex | 40 ++++++++----- .../extension/instance_transformer_test.exs | 5 +- .../extension/instance_verifier_test.exs | 58 +++++++++++++++++++ .../resource/instance/shelf_instance.ex | 10 +++- 9 files changed, 173 insertions(+), 35 deletions(-) create mode 100644 lib/diffo/provider/components/calculations/free_values.ex diff --git a/lib/diffo/provider/assigner/assignable_characteristic.ex b/lib/diffo/provider/assigner/assignable_characteristic.ex index 3e4b568..afb3d45 100644 --- a/lib/diffo/provider/assigner/assignable_characteristic.ex +++ b/lib/diffo/provider/assigner/assignable_characteristic.ex @@ -47,6 +47,12 @@ defmodule Diffo.Provider.AssignableCharacteristic do default :lowest constraints one_of: [:lowest, :highest, :random] end + + attribute :thing, :atom do + description "the kind of item being assigned (e.g. :slot, :port); set from the pool declaration at build time" + public? true + allow_nil? true + end end calculations do @@ -60,11 +66,15 @@ defmodule Diffo.Provider.AssignableCharacteristic do public? true argument :thing, :atom, allow_nil?: false end + + calculate :free, :integer, Diffo.Provider.Calculations.FreeValues do + public? true + end end actions do create :create do - accept [:name, :first, :last, :assignable_type, :algorithm] + accept [:name, :first, :last, :assignable_type, :algorithm, :thing] argument :instance_id, :uuid argument :feature_id, :uuid change manage_relationship(:instance_id, :instance, type: :append) @@ -77,7 +87,7 @@ defmodule Diffo.Provider.AssignableCharacteristic do end preparations do - prepare build(load: [:value]) + prepare build(load: [:value, :free]) end jason do diff --git a/lib/diffo/provider/components/calculations/free_values.ex b/lib/diffo/provider/components/calculations/free_values.ex new file mode 100644 index 0000000..2ed048d --- /dev/null +++ b/lib/diffo/provider/components/calculations/free_values.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.FreeValues do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, _opts, _context) do + Enum.map(records, fn + %{thing: nil} -> + nil + + record -> + count = + Diffo.Provider.AssignedToRelationship + |> Ash.Query.filter_input( + source_id: record.instance_id, + pool: record.name, + thing: record.thing + ) + |> Ash.read!(domain: Diffo.Provider) + |> length() + + record.last - record.first + 1 - count + end) + end +end diff --git a/lib/diffo/provider/extension/info.ex b/lib/diffo/provider/extension/info.ex index 2617ef5..d388e7d 100644 --- a/lib/diffo/provider/extension/info.ex +++ b/lib/diffo/provider/extension/info.ex @@ -27,4 +27,11 @@ defmodule Diffo.Provider.Extension.Info do Code.ensure_loaded?(module) and Diffo.Provider.Place.Extension in Ash.Resource.Info.extensions(module) end + + @doc "Returns true if the module is a BaseCharacteristic-derived resource (or Characteristic itself)" + @spec characteristic?(module()) :: boolean() + def characteristic?(module) do + Code.ensure_loaded?(module) and + Diffo.Provider.Characteristic.Extension in Ash.Resource.Info.extensions(module) + end end diff --git a/lib/diffo/provider/extension/pool.ex b/lib/diffo/provider/extension/pool.ex index 53a2ebc..e35a56e 100644 --- a/lib/diffo/provider/extension/pool.ex +++ b/lib/diffo/provider/extension/pool.ex @@ -10,9 +10,9 @@ defmodule Diffo.Provider.Extension.Pool do @doc "Creates AssignableCharacteristic nodes for each declared pool during the build action" def create_pools(result, pools) when is_struct(result) and is_list(pools) do - Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name}, {:ok, acc} -> + Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name, thing: thing}, {:ok, acc} -> case Diffo.Provider.AssignableCharacteristic - |> Ash.Changeset.for_create(:create, %{name: name, instance_id: acc.id}) + |> Ash.Changeset.for_create(:create, %{name: name, thing: thing, instance_id: acc.id}) |> Ash.create() do {:ok, _} -> {:cont, {:ok, acc}} {:error, error} -> {:halt, {:error, error}} diff --git a/lib/diffo/provider/extension/verifiers/verify_characteristics.ex b/lib/diffo/provider/extension/verifiers/verify_characteristics.ex index bf05037..9cf34c2 100644 --- a/lib/diffo/provider/extension/verifiers/verify_characteristics.ex +++ b/lib/diffo/provider/extension/verifiers/verify_characteristics.ex @@ -3,11 +3,12 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Extension.Verifiers.VerifyCharacteristics do - @moduledoc "Verifies characteristic names are unique and value_type modules exist" + @moduledoc "Verifies characteristic names are unique and value_type modules exist and extend BaseCharacteristic" use Spark.Dsl.Verifier alias Spark.Dsl.Verifier alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info @impl true def verify(dsl_state) do @@ -30,17 +31,30 @@ defmodule Diffo.Provider.Extension.Verifiers.VerifyCharacteristics do Enum.reduce(characteristics, [], fn char, acc -> case module_from_value_type(char.value_type) do {:ok, module} -> - if Code.ensure_loaded?(module) do - acc - else - [ - DslError.exception( - module: resource, - path: [:provider, :characteristics, char.name], - message: "characteristics: value_type #{inspect(module)} does not exist" - ) - | acc - ] + cond do + !Code.ensure_loaded?(module) -> + [ + DslError.exception( + module: resource, + path: [:provider, :characteristics, char.name], + message: "characteristics: value_type #{inspect(module)} does not exist" + ) + | acc + ] + + !Info.characteristic?(module) -> + [ + DslError.exception( + module: resource, + path: [:provider, :characteristics, char.name], + message: + "characteristics: value_type #{inspect(module)} does not extend BaseCharacteristic" + ) + | acc + ] + + true -> + acc end :error -> diff --git a/lib/diffo/provider/extension/verifiers/verify_features.ex b/lib/diffo/provider/extension/verifiers/verify_features.ex index 882dcdc..cd1529f 100644 --- a/lib/diffo/provider/extension/verifiers/verify_features.ex +++ b/lib/diffo/provider/extension/verifiers/verify_features.ex @@ -3,11 +3,12 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Extension.Verifiers.VerifyFeatures do - @moduledoc "Verifies feature names are unique and feature characteristic value_type modules exist" + @moduledoc "Verifies feature names are unique and feature characteristic value_type modules exist and extend BaseCharacteristic" use Spark.Dsl.Verifier alias Spark.Dsl.Verifier alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info @impl true def verify(dsl_state) do @@ -45,18 +46,31 @@ defmodule Diffo.Provider.Extension.Verifiers.VerifyFeatures do Enum.reduce(feature.characteristics || [], [], fn char, inner_acc -> case module_from_value_type(char.value_type) do {:ok, module} -> - if Code.ensure_loaded?(module) do - inner_acc - else - [ - DslError.exception( - module: resource, - path: [:provider, :features, feature.name, :characteristics, char.name], - message: - "features: characteristic value_type #{inspect(module)} does not exist" - ) - | inner_acc - ] + cond do + !Code.ensure_loaded?(module) -> + [ + DslError.exception( + module: resource, + path: [:provider, :features, feature.name, :characteristics, char.name], + message: + "features: characteristic value_type #{inspect(module)} does not exist" + ) + | inner_acc + ] + + !Info.characteristic?(module) -> + [ + DslError.exception( + module: resource, + path: [:provider, :features, feature.name, :characteristics, char.name], + message: + "features: characteristic value_type #{inspect(module)} does not extend BaseCharacteristic" + ) + | inner_acc + ] + + true -> + inner_acc end :error -> diff --git a/test/provider/extension/instance_transformer_test.exs b/test/provider/extension/instance_transformer_test.exs index e08b364..779155e 100644 --- a/test/provider/extension/instance_transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -41,10 +41,9 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do test "bakes characteristics/0 onto the resource" do chars = ShelfInstance.characteristics() assert is_list(chars) - assert length(chars) == 3 + assert length(chars) == 2 names = Enum.map(chars, & &1.name) assert :shelf in names - assert :slots in names assert :shelves in names end @@ -54,7 +53,7 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "characteristics are also accessible via Info" do - assert length(Info.characteristics(ShelfInstance)) == 3 + assert length(Info.characteristics(ShelfInstance)) == 2 # Card has :card characteristic; :ports moved to pools do assert length(Info.characteristics(CardInstance)) == 1 end diff --git a/test/provider/extension/instance_verifier_test.exs b/test/provider/extension/instance_verifier_test.exs index 8fce63a..ac781ca 100644 --- a/test/provider/extension/instance_verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -217,6 +217,34 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end ) end + + test "value_type not extending BaseCharacteristic warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "characteristics: value_type Diffo.Test.Instance.ShelfInstance does not extend BaseCharacteristic", + fn -> + defmodule InvalidCharBaseType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with characteristic value_type that is not a BaseCharacteristic" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + characteristics do + characteristic :foo, Diffo.Test.Instance.ShelfInstance + end + end + end + end + ) + end end describe "features verifier" do @@ -312,6 +340,36 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end ) end + + test "feature characteristic value_type not extending BaseCharacteristic warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "features: characteristic value_type Diffo.Test.Instance.ShelfInstance does not extend BaseCharacteristic", + fn -> + defmodule InvalidFeatureCharBaseType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with feature characteristic value_type that is not a BaseCharacteristic" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + features do + feature :my_feature do + characteristic :baz, Diffo.Test.Instance.ShelfInstance + end + end + end + end + end + ) + end end describe "parties verifier" do diff --git a/test/support/resource/instance/shelf_instance.ex b/test/support/resource/instance/shelf_instance.ex index baeee6e..30b3e19 100644 --- a/test/support/resource/instance/shelf_instance.ex +++ b/test/support/resource/instance/shelf_instance.ex @@ -12,9 +12,9 @@ defmodule Diffo.Test.Instance.ShelfInstance do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Pool alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableValue alias Diffo.Test.Servo alias Diffo.Test.Characteristic.ShelfCharacteristic alias Diffo.Test.Characteristic.DeploymentClass @@ -53,10 +53,13 @@ defmodule Diffo.Test.Instance.ShelfInstance do characteristics do characteristic :shelf, ShelfCharacteristic - characteristic :slots, AssignableValue characteristic :shelves, {:array, ShelfCharacteristic} end + pools do + pool :slots, :slot + end + parties do party :facilitator, Organization party :overseer, Person @@ -97,6 +100,7 @@ defmodule Diffo.Test.Instance.ShelfInstance do change after_action(fn changeset, result, _context -> with {:ok, result} <- Characteristic.update_all(result, changeset, characteristics()), + {:ok, result} <- Pool.update_pools(result, changeset, pools()), {:ok, result} <- Servo.get_shelf_by_id(result.id), do: {:ok, result} end) @@ -118,7 +122,7 @@ defmodule Diffo.Test.Instance.ShelfInstance do argument :assignment, :struct, constraints: [instance_of: Assignment] change after_action(fn changeset, result, _context -> - with {:ok, result} <- Assigner.assign(result, changeset, :slots, :slot), + with {:ok, result} <- Assigner.assign(result, changeset, :slots), {:ok, result} <- Servo.get_shelf_by_id(result.id), do: {:ok, result} end) From fc216e2b0c845b1c2d83860482b8462703ecb2bc Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 06:58:41 +0930 Subject: [PATCH 02/23] defined_simple_relationship --- AGENTS.md | 14 +- lib/diffo/provider.ex | 6 + .../components/defined_simple_relationship.ex | 107 +++++++++++ lib/diffo/type/name_value_array_primitive.ex | 22 +++ lib/diffo/type/name_value_primitive.ex | 22 +++ .../defined_simple_relationship_test.exs | 168 ++++++++++++++++++ 6 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 lib/diffo/provider/components/defined_simple_relationship.ex create mode 100644 lib/diffo/type/name_value_array_primitive.ex create mode 100644 lib/diffo/type/name_value_primitive.ex create mode 100644 test/provider/defined_simple_relationship_test.exs diff --git a/AGENTS.md b/AGENTS.md index 25ffd94..57adba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,13 @@ on [Ash Framework](https://www.ash-hq.org/) + [AshNeo4j](https://github.com/diff ## Project structure ``` +lib/diffo/type/ + primitive.ex # Diffo.Type.Primitive — discriminated union of primitive Elixir types + value.ex # Diffo.Type.Value — union of Primitive and Dynamic + dynamic.ex # Diffo.Type.Dynamic — runtime-typed value (NewType with map storage) + name_value_primitive.ex # Diffo.Type.NameValuePrimitive — name/Primitive pair TypedStruct + name_value_array_primitive.ex # Diffo.Type.NameValueArrayPrimitive — name/[Primitive] pair TypedStruct + lib/diffo/provider/ extension.ex # Unified Spark DSL extension (provider do) extension/ @@ -46,7 +53,9 @@ lib/diffo/provider/ base_place.ex # Ash Fragment for Place resources components/ base_characteristic.ex # Ash Fragment for typed characteristic resources - base_relationship.ex # Ash Fragment for shared Relationship structure + base_relationship.ex # Ash Fragment for shared Relationship structure + defined_simple_relationship.ex # DefinedSimpleRelationship — relationship with one optional embedded characteristic, frozen at creation + relationship.ex # Relationship — mutable TMF service/resource relationship with graph Characteristic nodes calculations/ characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing @@ -54,6 +63,9 @@ lib/diffo/provider/ party/extension.ex # Thin marker place/extension.ex # Thin marker +test/provider/ + defined_simple_relationship_test.exs # Integration: DefinedSimpleRelationship create/destroy + DefinedCharacteristic encoding + test/provider/extension/ # All provider extension tests instance_transformer_test.exs party_transformer_test.exs diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index 65d16a6..cddd8f8 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -77,6 +77,12 @@ defmodule Diffo.Provider do define :delete_relationship, action: :destroy end + resource Diffo.Provider.DefinedSimpleRelationship do + define :create_defined_simple_relationship, action: :create + define :get_defined_simple_relationship_by_id, action: :read, get_by: :id + define :delete_defined_simple_relationship, action: :destroy + end + resource Diffo.Provider.AssignedToRelationship do define :create_assigned_to_relationship, action: :create_assignment define :get_assigned_to_relationship_by_id, action: :read, get_by: :id diff --git a/lib/diffo/provider/components/defined_simple_relationship.ex b/lib/diffo/provider/components/defined_simple_relationship.ex new file mode 100644 index 0000000..5872162 --- /dev/null +++ b/lib/diffo/provider/components/defined_simple_relationship.ex @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.DefinedSimpleRelationship do + @moduledoc """ + Ash Resource for a relationship with an optional single embedded characteristic, + set at creation and never changed. + + Extends `BaseRelationship` (source, target, type, timestamps). Optionally carries + one `DefinedCharacteristic` — a name/value pair stored directly on the Neo4j node. + The value is a `Diffo.Type.Primitive`, covering string, integer, float, boolean, + and temporal types. + + Actions: **create** and **destroy** only. No update, no relate/unrelate. Once + defined, the characteristic is closed — that is the commitment. + + Contrast with `Provider.Relationship` which allows mutable graph-based `Characteristic` + nodes to be added, removed, and updated over time. + + `DefinedSimpleRelationship` is a general Provider primitive for any relationship + whose characteristic is a commitment or promise made at creation time. + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseRelationship], + otp_app: :diffo, + domain: Diffo.Provider + + resource do + description "An Ash Resource for a relationship with a single optional characteristic, defined at creation and closed thereafter" + plural_name :defined_simple_relationships + end + + neo4j do + relate [ + {:source, :RELATES, :incoming, :Instance}, + {:target, :RELATES, :outgoing, :Instance} + ] + end + + jason do + pick [:alias, :type] + + customize fn result, record -> + target_type = Map.get(record, :target_type) + + reference = %Diffo.Provider.Reference{ + id: record.target_id, + href: Map.get(record, :target_href) + } + + list_name = + Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) + + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.suppress(:alias) + |> then(fn r -> + case Map.get(record, :characteristic) do + nil -> r + char -> Diffo.Util.set(r, list_name, [char]) + end + end) + end + + order [ + :alias, + :type, + :service, + :resource, + :serviceRelationshipCharacteristic, + :resourceRelationshipCharacteristic + ] + end + + actions do + create :create do + description "creates a defined simple relationship between a source and target instance" + accept [:alias, :type, :characteristic] + + argument :source_id, :uuid + argument :target_id, :string + + change manage_relationship(:source_id, :source, type: :append) + change manage_relationship(:target_id, :target, type: :append) + change Diffo.Changes.DetailRelationship + end + end + + attributes do + attribute :alias, :atom do + description "an optional alias for this relationship" + allow_nil? true + public? true + end + + attribute :characteristic, Diffo.Type.NameValuePrimitive do + description "an optional single defining characteristic, set at creation and closed thereafter" + allow_nil? true + public? true + end + end + + preparations do + prepare build(sort: [created_at: :asc]) + end +end diff --git a/lib/diffo/type/name_value_array_primitive.ex b/lib/diffo/type/name_value_array_primitive.ex new file mode 100644 index 0000000..1c3b45c --- /dev/null +++ b/lib/diffo/type/name_value_array_primitive.ex @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Type.NameValueArrayPrimitive do + @moduledoc """ + Ash TypedStruct for a named array of primitive values. + + A name/values pair where each value is a `Diffo.Type.Primitive` — covering string, + integer, float, boolean, date, time, datetime, and duration. + """ + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + jason do + order [:name, :values] + end + + typed_struct do + field :name, :atom, allow_nil?: false, description: "the name" + field :values, {:array, Diffo.Type.Primitive}, default: [], description: "the primitive values" + end +end diff --git a/lib/diffo/type/name_value_primitive.ex b/lib/diffo/type/name_value_primitive.ex new file mode 100644 index 0000000..75afeb9 --- /dev/null +++ b/lib/diffo/type/name_value_primitive.ex @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Type.NameValuePrimitive do + @moduledoc """ + Ash TypedStruct for a named primitive value. + + A name/value pair where the value is a `Diffo.Type.Primitive` — covering string, + integer, float, boolean, date, time, datetime, and duration. + """ + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + jason do + order [:name, :value] + end + + typed_struct do + field :name, :atom, allow_nil?: false, description: "the name" + field :value, Diffo.Type.Primitive, description: "the primitive value" + end +end diff --git a/test/provider/defined_simple_relationship_test.exs b/test/provider/defined_simple_relationship_test.exs new file mode 100644 index 0000000..2e48b67 --- /dev/null +++ b/test/provider/defined_simple_relationship_test.exs @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.DefinedSimpleRelationshipTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Diffo.Type.NameValuePrimitive + alias Diffo.Type.Primitive + + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + defp build_instances do + spec_a = Diffo.Provider.create_specification!(%{name: "accessEvc"}) + spec_b = Diffo.Provider.create_specification!(%{name: "aggregationEvc"}) + source = Diffo.Provider.create_instance!(%{specified_by: spec_a.id, name: "access1"}) + target = Diffo.Provider.create_instance!(%{specified_by: spec_b.id, name: "agg1"}) + {source, target} + end + + describe "DefinedSimpleRelationship create" do + test "creates a relationship with no characteristic" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + assert rel.type == :assignedTo + assert rel.source_id == source.id + assert rel.target_id == target.id + assert rel.characteristic == nil + end + + test "creates a relationship with an integer characteristic" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id, + characteristic: %NameValuePrimitive{ + name: :slot, + value: Primitive.wrap("integer", 7) + } + }) + + assert rel.characteristic.name == :slot + assert Diffo.Unwrap.unwrap(rel.characteristic.value) == 7 + end + + test "creates a relationship with a string characteristic" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :definedBy, + source_id: source.id, + target_id: target.id, + characteristic: %NameValuePrimitive{ + name: :bandwidth, + value: Primitive.wrap("string", "1000Mbps") + } + }) + + assert rel.characteristic.name == :bandwidth + assert Diffo.Unwrap.unwrap(rel.characteristic.value) == "1000Mbps" + end + + test "characteristic is persisted and reloaded correctly" do + {source, target} = build_instances() + + created = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id, + characteristic: %NameValuePrimitive{ + name: :slot, + value: Primitive.wrap("integer", 42) + } + }) + + reloaded = Diffo.Provider.get_defined_simple_relationship_by_id!(created.id) + + assert reloaded.characteristic.name == :slot + assert Diffo.Unwrap.unwrap(reloaded.characteristic.value) == 42 + end + + test "target_href and target_type are populated" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + assert rel.target_type == :service + assert is_binary(rel.target_href) + end + end + + describe "DefinedSimpleRelationship read" do + test "get by id returns the relationship" do + {source, target} = build_instances() + + created = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + fetched = Diffo.Provider.get_defined_simple_relationship_by_id!(created.id) + assert fetched.id == created.id + assert fetched.type == :assignedTo + end + end + + describe "DefinedSimpleRelationship destroy" do + test "destroys the relationship" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + Diffo.Provider.delete_defined_simple_relationship!(rel) + + assert_raise Ash.Error.Invalid, fn -> + Diffo.Provider.get_defined_simple_relationship_by_id!(rel.id) + end + end + end + + describe "NameValuePrimitive TypedStruct" do + test "new!/1 constructs with a Primitive value" do + char = NameValuePrimitive.new!(name: :slot, value: Primitive.wrap("integer", 7)) + assert char.name == :slot + assert Diffo.Unwrap.unwrap(char.value) == 7 + end + + test "new!/1 raises when name is nil" do + assert_raise Ash.Error.Invalid, fn -> + NameValuePrimitive.new!(name: nil, value: Primitive.wrap("string", "x")) + end + end + + test "Jason encoding produces name then unwrapped value" do + char = NameValuePrimitive.new!(name: :slot, value: Primitive.wrap("integer", 7)) + json = Jason.encode!(char) + assert json == ~s({"name":"slot","value":7}) + end + end +end From 45928442534e06c4ef02d927a4958d4d4f673266 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 07:35:21 +0930 Subject: [PATCH 03/23] refactored assigner using defined_simple_relationship --- AGENTS.md | 1 - lib/diffo/provider.ex | 6 - .../assigner/assigned_to_relationship.ex | 103 ------------------ lib/diffo/provider/assigner/assigner.ex | 51 +++++---- .../provider/components/base_instance.ex | 4 +- .../calculations/assigned_values.ex | 12 +- .../components/calculations/free_values.ex | 12 +- test/provider/extension/assigner_test.exs | 2 +- 8 files changed, 42 insertions(+), 149 deletions(-) delete mode 100644 lib/diffo/provider/assigner/assigned_to_relationship.ex diff --git a/AGENTS.md b/AGENTS.md index 57adba5..86b2964 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,6 @@ lib/diffo/provider/ assigner/ assigner.ex # Diffo.Provider.Assigner — assign/3 (pools do) and assign/4 assignable_characteristic.ex # AssignableCharacteristic — pool bounds + algorithm - assigned_to_relationship.ex # AssignedToRelationship — assignedTo edges (pool/thing/assigned) base_instance.ex # Ash Fragment for Instance resources base_party.ex # Ash Fragment for Party resources base_place.ex # Ash Fragment for Place resources diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index cddd8f8..4ee5324 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -83,12 +83,6 @@ defmodule Diffo.Provider do define :delete_defined_simple_relationship, action: :destroy end - resource Diffo.Provider.AssignedToRelationship do - define :create_assigned_to_relationship, action: :create_assignment - define :get_assigned_to_relationship_by_id, action: :read, get_by: :id - define :delete_assigned_to_relationship, action: :destroy - end - resource Diffo.Provider.AssignableCharacteristic do define :create_assignable_characteristic, action: :create define :get_assignable_characteristic_by_id, action: :read, get_by: :id diff --git a/lib/diffo/provider/assigner/assigned_to_relationship.ex b/lib/diffo/provider/assigner/assigned_to_relationship.ex deleted file mode 100644 index 1926d88..0000000 --- a/lib/diffo/provider/assigner/assigned_to_relationship.ex +++ /dev/null @@ -1,103 +0,0 @@ -# SPDX-FileCopyrightText: 2025 diffo contributors -# -# SPDX-License-Identifier: MIT - -defmodule Diffo.Provider.AssignedToRelationship do - @moduledoc """ - Ash Resource for a pool assignment relationship. - - Carries the assignment attributes (`pool`, `thing`, `assigned`) that link a - source instance to an assignee instance. Stored as an `:AssignedToRelationship` - Neo4j node, distinct from the `:Relationship` nodes used for TMF service/resource - relationships. Accessible on an instance via `instance.assignments`. - - Created by `Diffo.Provider.Assigner` via `Diffo.Provider.create_assigned_to_relationship/1`. - """ - use Ash.Resource, - fragments: [Diffo.Provider.BaseRelationship], - otp_app: :diffo, - domain: Diffo.Provider - - resource do - description "An Ash Resource for a pool assignment relationship" - plural_name :assigned_to_relationships - end - - neo4j do - relate [ - {:source, :RELATES, :incoming, :Instance}, - {:target, :RELATES, :outgoing, :Instance} - ] - end - - jason do - pick [:type] - - customize fn result, record -> - target_type = Map.get(record, :target_type) - - reference = %Diffo.Provider.Reference{ - id: record.target_id, - href: Map.get(record, :target_href) - } - - list_name = - Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) - - result - |> Diffo.Util.set(target_type, reference) - |> Diffo.Util.set(list_name, [%{name: record.thing, value: record.assigned}]) - end - - order [ - :type, - :service, - :resource, - :serviceRelationshipCharacteristic, - :resourceRelationshipCharacteristic - ] - end - - actions do - create :create_assignment do - description "creates an assignedTo relationship with pool/thing/assigned attributes" - accept [:pool, :thing, :assigned] - - argument :source_id, :uuid - argument :target_id, :string - - change set_attribute(:type, :assignedTo) - change manage_relationship(:source_id, :source, type: :append) - change manage_relationship(:target_id, :target, type: :append) - change Diffo.Changes.DetailRelationship - end - end - - attributes do - attribute :pool, :atom do - description "the pool name on the source instance" - allow_nil? true - public? true - end - - attribute :thing, :atom do - description "the kind of thing being assigned within the pool" - allow_nil? true - public? true - end - - attribute :assigned, :integer do - description "the assigned value from the pool" - allow_nil? true - public? true - end - end - - identities do - identity :unique_assignment, [:source_id, :target_id, :pool, :thing, :assigned] - end - - preparations do - prepare build(sort: [created_at: :asc]) - end -end diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index daf5d39..370c7c8 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -4,13 +4,15 @@ defmodule Diffo.Provider.Assigner do @moduledoc """ - Helper to perform Assignment using `Diffo.Provider.AssignedToRelationship`. + Helper to perform Assignment using `Diffo.Provider.DefinedSimpleRelationship`. - Assignment state is stored on `AssignedToRelationship` nodes (pool, thing, assigned), - distinct from regular TMF `Diffo.Provider.Relationship` nodes. + Each assignment is stored as a `DefinedSimpleRelationship` with `type: :assignedTo` + and a single `NameValuePrimitive` characteristic carrying the thing name and assigned value. """ alias Diffo.Provider.AssignableCharacteristic - alias Diffo.Provider.AssignedToRelationship + alias Diffo.Provider.DefinedSimpleRelationship + alias Diffo.Type.NameValuePrimitive + alias Diffo.Type.Primitive @doc """ Assign a thing using the pool declared via `pools do` on the instance module. @@ -63,13 +65,15 @@ defmodule Diffo.Provider.Assigner do end end - defp relate_is_assigned(result, pool, thing, value, assignee_id) - when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and + defp relate_is_assigned(result, _pool, thing, value, assignee_id) + when is_struct(result) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do - case Diffo.Provider.create_assigned_to_relationship(%{ - pool: pool, - thing: thing, - assigned: value, + case Diffo.Provider.create_defined_simple_relationship(%{ + type: :assignedTo, + characteristic: %NameValuePrimitive{ + name: thing, + value: Primitive.wrap("integer", value) + }, source_id: result.id, target_id: assignee_id }) do @@ -102,17 +106,22 @@ defmodule Diffo.Provider.Assigner do end end - defp find_assignment(source_id, target_id, pool, thing, value) do - AssignedToRelationship - |> Ash.Query.new() - |> Ash.Query.filter_input( - source_id: source_id, - target_id: target_id, - pool: pool, - thing: thing, - assigned: value - ) - |> Ash.read_one(domain: Diffo.Provider) + defp find_assignment(source_id, target_id, _pool, thing, value) do + case DefinedSimpleRelationship + |> Ash.Query.new() + |> Ash.Query.filter_input(source_id: source_id, target_id: target_id, type: :assignedTo) + |> Ash.read(domain: Diffo.Provider) do + {:ok, rels} -> + {:ok, + Enum.find(rels, fn rel -> + rel.characteristic && + rel.characteristic.name == thing && + Diffo.Unwrap.unwrap(rel.characteristic.value) == value + end)} + + {:error, error} -> + {:error, error} + end end defp next(instance, pool, thing) diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 13e6b90..996362e 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -188,7 +188,7 @@ defmodule Diffo.Provider.BaseInstance do {:process_statuses, :STATUSES, :incoming, :ProcessStatus}, {:forward_relationships, :RELATES, :outgoing, :Relationship}, {:reverse_relationships, :RELATES, :incoming, :Relationship}, - {:assignments, :RELATES, :outgoing, :AssignedToRelationship}, + {:assignments, :RELATES, :outgoing, :DefinedSimpleRelationship}, {:features, :HAS, :outgoing, :Feature}, {:characteristics, :HAS, :outgoing, :Characteristic}, {:entities, :RELATES, :outgoing, :EntityRef}, @@ -409,7 +409,7 @@ defmodule Diffo.Provider.BaseInstance do public? true end - has_many :assignments, Diffo.Provider.AssignedToRelationship do + has_many :assignments, Diffo.Provider.DefinedSimpleRelationship do description "the instance's outgoing pool assignment relationships" destination_attribute :source_id public? true diff --git a/lib/diffo/provider/components/calculations/assigned_values.ex b/lib/diffo/provider/components/calculations/assigned_values.ex index 6ac879f..3b8a982 100644 --- a/lib/diffo/provider/components/calculations/assigned_values.ex +++ b/lib/diffo/provider/components/calculations/assigned_values.ex @@ -14,15 +14,11 @@ defmodule Diffo.Provider.Calculations.AssignedValues do thing = context.arguments[:thing] Enum.map(records, fn record -> - Diffo.Provider.AssignedToRelationship - |> Ash.Query.new() - |> Ash.Query.filter_input( - source_id: record.instance_id, - pool: record.name, - thing: thing - ) + Diffo.Provider.DefinedSimpleRelationship + |> Ash.Query.filter_input(source_id: record.instance_id, type: :assignedTo) |> Ash.read!(domain: Diffo.Provider) - |> Enum.map(& &1.assigned) + |> Enum.filter(fn rel -> rel.characteristic && rel.characteristic.name == thing end) + |> Enum.map(fn rel -> Diffo.Unwrap.unwrap(rel.characteristic.value) end) end) end end diff --git a/lib/diffo/provider/components/calculations/free_values.ex b/lib/diffo/provider/components/calculations/free_values.ex index 2ed048d..af0de70 100644 --- a/lib/diffo/provider/components/calculations/free_values.ex +++ b/lib/diffo/provider/components/calculations/free_values.ex @@ -17,14 +17,12 @@ defmodule Diffo.Provider.Calculations.FreeValues do record -> count = - Diffo.Provider.AssignedToRelationship - |> Ash.Query.filter_input( - source_id: record.instance_id, - pool: record.name, - thing: record.thing - ) + Diffo.Provider.DefinedSimpleRelationship + |> Ash.Query.filter_input(source_id: record.instance_id, type: :assignedTo) |> Ash.read!(domain: Diffo.Provider) - |> length() + |> Enum.count(fn rel -> + rel.characteristic && rel.characteristic.name == record.thing + end) record.last - record.first + 1 - count end) diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index a24c229..cd5fdbc 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -190,7 +190,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assert length(card.assignments) == 1 - assigned_port = hd(card.assignments).assigned + assigned_port = Diffo.Unwrap.unwrap(hd(card.assignments).characteristic.value) {:ok, card} = Servo.assign_port(card, %{ From 4fa185d571d4ba7fe80e195dd3d09d7dcbdf2883 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 10:47:19 +0930 Subject: [PATCH 04/23] source side validation --- .formatter.exs | 8 + AGENTS.md | 45 ++++- .../dsls/DSL-Diffo.Provider.Extension.md | 72 +++++++ .../use_diffo_provider_extension.livemd | 20 +- .../assigner/assignable_characteristic.ex | 34 ++-- .../assignable_characteristic/value.ex | 12 +- lib/diffo/provider/assigner/assigner.ex | 2 +- .../validate_relationship_permitted.ex | 43 ++++ .../components/base_characteristic.ex | 1 - .../provider/components/base_instance.ex | 1 + .../provider/components/characteristic.ex | 6 +- lib/diffo/provider/extension.ex | 62 +++++- .../provider/extension/characteristic.ex | 1 - .../persisters/persist_specification.ex | 7 +- lib/diffo/provider/extension/pool.ex | 3 +- .../provider/extension/relationship_step.ex | 8 + .../transformers/transform_relationships.ex | 48 +++++ .../verifiers/verify_relationships.ex | 59 ++++++ lib/diffo/type/name_value_array_primitive.ex | 5 +- mix.exs | 3 +- .../extension/relationship_dsl_test.exs | 184 ++++++++++++++++++ .../provider/extension/specification_test.exs | 12 +- .../characteristic/card_characteristic.ex | 26 +-- .../card_characteristic/value.ex | 10 +- .../characteristic/deployment_class.ex | 24 +-- .../characteristic/deployment_class/value.ex | 10 +- .../characteristic/shelf_characteristic.ex | 26 +-- .../shelf_characteristic/value.ex | 10 +- .../resource/instance/card_instance.ex | 4 + .../resource/instance/shelf_instance.ex | 4 + usage-rules.md | 49 ++++- 31 files changed, 695 insertions(+), 104 deletions(-) create mode 100644 lib/diffo/provider/changes/validate_relationship_permitted.ex create mode 100644 lib/diffo/provider/extension/relationship_step.ex create mode 100644 lib/diffo/provider/extension/transformers/transform_relationships.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_relationships.ex create mode 100644 test/provider/extension/relationship_dsl_test.exs diff --git a/.formatter.exs b/.formatter.exs index c0ee74e..35bd347 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -15,6 +15,7 @@ spark_locals_without_parens = [ feature: 1, feature: 2, id: 1, + instance_ref: 2, is_enabled?: 1, major_version: 1, minor_version: 1, @@ -23,14 +24,21 @@ spark_locals_without_parens = [ parties: 3, party: 2, party: 3, + party_ref: 2, + party_ref: 3, patch_version: 1, place: 2, place: 3, + place_ref: 2, + place_ref: 3, places: 2, places: 3, + pool: 2, reference: 1, role: 2, role: 3, + source: 1, + target: 1, tmf_version: 1, type: 1, update: 1, diff --git a/AGENTS.md b/AGENTS.md index 86b2964..ba72f96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,14 @@ lib/diffo/provider/ place_declaration.ex # PlaceDeclaration struct party_role.ex # PartyRole struct (Party/Place kinds) place_role.ex # PlaceRole struct (Party/Place kinds) - persisters/ # Spark transformers — bake DSL state into module - transformers/ # TransformBehaviour — action argument injection - verifiers/ # Compile-time DSL correctness checks + relationship_step.ex # RelationshipStep struct — pipeline step for relationships do + persisters/ # Terminal bakers — run after all transformers; only read DSL state and bake module functions + transformers/ + 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/ + 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 assignable_characteristic.ex # AssignableCharacteristic — pool bounds + algorithm @@ -72,6 +77,7 @@ test/provider/extension/ # All provider extension tests instance_verifier_test.exs party_verifier_test.exs place_verifier_test.exs + relationship_dsl_test.exs # Transformer baking, verifier errors, integration enforcement party_test.exs # Integration: parties enforcement place_test.exs # Integration: places enforcement specification_test.exs # Integration: spec roundtrip @@ -112,6 +118,11 @@ provider do pool :vlans, :vlan end + relationships do + source [:provides, :requires] # pipeline — last step wins; omitting defaults to :none + target :all + end + features do feature :advanced_routing, is_enabled?: false do characteristic :policy, MyApp.RoutingPolicy @@ -179,6 +190,22 @@ whose names end in the same word get the same label, which causes read collision E.g. `Diffo.Test.Instance.CardInstance` → label `:CardInstance`, and `Diffo.Test.Characteristic.CardCharacteristic` → label `:CardCharacteristic` — no collision. +## Spark transformer vs persister pipeline + +Spark runs two separate pipelines during compilation, in this order: + +1. **Transformers** (`transformers:` in the extension) — run in dependency order via `before?`/`after?`. Can read and modify DSL state. May also call `Transformer.persist/3` to bake results — a transformer that had to compute something to do its job should persist that result rather than delegating to a separate persister. +2. **Persisters** (`persisters:` in the extension) — always run after ALL transformers from ALL extensions. `before?`/`after?` ordering works relative to other persisters only — ordering declarations targeting transformers are silently ignored. +3. **Verifiers** — read-only, run last. + +**Rules:** +- A module that injects into actions, modifies DSL state, or needs to order itself relative to Ash's own transformers belongs in `transformers:`. +- A module that only reads final DSL state and bakes module functions belongs in `persisters:`. +- A transformer that needs to expose baked state does not need a separate persister — call `Transformer.persist/3` inline and emit the module function via `Transformer.eval/3`. +- Do not put a transformer in `persisters:` hoping `after?` declarations will order it relative to transformers — those declarations are silently ignored across pipeline boundaries. + +**Current state:** `TransformBehaviour` is misregistered under `persisters:` — a known issue tracked for refactoring. New transformers go under `transformers:`. + ## Common agent mistakes - Using old `structure do` / top-level `instances do` — use `provider do` only. @@ -188,12 +215,18 @@ and `Diffo.Test.Characteristic.CardCharacteristic` → label `:CardCharacteristi - Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`. - Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically. - Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here. -- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship` — `AssignedToRelationship` is not a characteristic; use `pools do / pool :name, :thing / end` instead. -- Querying `Diffo.Provider.Relationship` for assignment records — assignment relationships are on `Diffo.Provider.AssignedToRelationship`; access them via `instance.assignments`. +- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship` — `AssignedToRelationship` no longer exists; use `pools do / pool :name, :thing / end` instead. +- Querying `Diffo.Provider.Relationship` for assignment records — assignments are stored as `Diffo.Provider.DefinedSimpleRelationship`; access them via `instance.assignments`. - Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly. - Calling `build_before/1` or `build_after/2` in actions — these run automatically. - Declaring `:specified_by`, `:features`, `:characteristics` as action arguments. +- Using module names (e.g. `MyApp.CardInstance`) as role values in `relationships do` — roles are atoms like `:provides`, not module references. +- Forgetting that `relationships do` omitted means `:none` for both source and target — any update action with `argument :relationships, {:array, :struct}` will fail unless the resource declares permissions. +- Thinking the Assigner requires `relationships do` permissions — it does not. The Assigner writes `DefinedSimpleRelationship` records directly via the Provider domain; `ValidateRelationshipPermitted` only runs on actions that carry `argument :relationships, {:array, :struct}`, which the Assigner's `assign_*` actions do not. - Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated; - run `mix spark.cheat_sheets` to regenerate it. + run `mix spark.cheat_sheets` to regenerate it. Whenever you add, rename, or remove a DSL + entity or section, also check `.formatter.exs` — new entity names must be added to + `spark_locals_without_parens` (with each arity) so the Spark formatter omits parentheses. + Run `mix format` afterward to verify. - Editing content between `` markers in `CLAUDE.md` — that is auto-generated by `mix usage_rules.sync`. diff --git a/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md index 091aea2..4df32ff 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -111,6 +111,9 @@ Provider DSL — structure, roles, and behaviour for this resource kind * [instances](#provider-instances) * role * instance_ref + * [relationships](#provider-relationships) + * source + * target * [behaviour](#provider-behaviour) * actions * create @@ -712,6 +715,75 @@ Declares a reference instance role — no direct edge created, reachable by grap Target: `Diffo.Provider.Extension.InstanceRole` +### provider.relationships +Relationship role permissions for this Instance — declares which aliases it may participate in as source or target. Omitting defaults to `:none` per direction. + +### Nested DSLs + * [source](#provider-relationships-source) + * [target](#provider-relationships-target) + + +### Examples +``` +relationships do + source [:provides, :requires] + target :all +end + +``` + + + + +### provider.relationships.source +```elixir +source roles +``` + + +Declares permitted source relationship roles — pipeline step, last declaration wins + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`roles`](#provider-relationships-source-roles){: #provider-relationships-source-roles .spark-required} | `any` | | `:all`, `:none`, or a list of role name atoms. | + + + + + + + +### provider.relationships.target +```elixir +target roles +``` + + +Declares permitted target relationship roles — pipeline step, last declaration wins + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`roles`](#provider-relationships-target-roles){: #provider-relationships-target-roles .spark-required} | `any` | | `:all`, `:none`, or a list of role name atoms. | + + + + + + + + ### provider.behaviour Defines the behavioural wiring for the Instance — actions, and in future triggers diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index 5c9514f..7094589 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -153,7 +153,7 @@ arguments automatically onto that action. Each characteristic is a dedicated Ash resource using the `Diffo.Provider.BaseCharacteristic` fragment. It carries direct typed attributes and a `:value` calculation that builds a companion `.Value` TypedStruct for ordered JSON encoding. The TypedStruct uses [AshJason.TypedStruct](https://hexdocs.pm/ash_jason/) to control field order in the JSON output. -For partial resource allocation and assignment we've created `Diffo.Provider.Assigner`. The host resource declares a `pools do` section listing each assignable pool by name and the kind of thing being assigned. Pool bounds (first/last value, algorithm) are set via a `:define` action. Each assignment is stored as a `Diffo.Provider.AssignedToRelationship` node (Neo4j label `:AssignmentRelationship`) carrying `pool`, `thing`, and the `assigned` value. These are distinct from regular TMF `Diffo.Provider.Relationship` nodes and are accessible on an instance via `instance.assignments`. +For partial resource allocation and assignment we've created `Diffo.Provider.Assigner`. The host resource declares a `pools do` section listing each assignable pool by name and the kind of thing being assigned. Pool bounds (first/last value, algorithm) are set via a `:define` action. Each assignment is stored as a `Diffo.Provider.DefinedSimpleRelationship` node carrying `type: :assignedTo` and a single `NameValuePrimitive` characteristic holding the thing name and assigned value. These are distinct from regular TMF `Diffo.Provider.Relationship` nodes and are accessible on an instance via `instance.assignments`. Let's imagine a Compute domain which operates GPU and NPU resources. We want to expose a Cluster composite resource which can be dynamically composed of a number of GPU and NPU cores. @@ -278,6 +278,11 @@ defmodule Diffo.Compute.Cluster do place :data_centre, Diffo.Compute.DataCentre end + relationships do + source :all + target :all + end + behaviour do actions do create :build @@ -426,6 +431,11 @@ defmodule Diffo.Compute.GPU do pool :cores, :core end + relationships do + source :all + target :all + end + behaviour do actions do create :build @@ -783,7 +793,7 @@ gpu_1 = Compute.assign_gpu_core!(gpu_1, assignment) gpu_2 = Compute.assign_gpu_core!(gpu_2, assignment) ``` -Now our cluster should have a core from each GPU. Check in the Neo4j browser for `:AssignmentRelationship` nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each carries `pool: :cores`, `thing: :core`, and the `assigned` integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_2). +Now our cluster should have a core from each GPU. Check in the Neo4j browser for `:DefinedSimpleRelationship` nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each has `type: :assignedTo` and a single characteristic carrying the thing name (`:core`) and the assigned integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_2). The GPU's `assignments` hold each assignment, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`: @@ -791,8 +801,8 @@ The GPU's `assignments` hold each assignment, showing the assigned core number i Jason.encode!(gpu_1, pretty: true) |> IO.puts ``` -Make sure you have a look at it in the neo4j browser. There should be `:AssignmentRelationship` nodes from each GPU resource instance to the `cluster_1` resource instance, each carrying the assigned core number. -There is no central assignment table — the `AssignedToRelationship` nodes ARE the assignments. They are separate from the regular `:Relationship` nodes used for TMF service/resource relationships, and are accessible in Elixir via `instance.assignments`. +Make sure you have a look at it in the neo4j browser. There should be `:DefinedSimpleRelationship` nodes from each GPU resource instance to the `cluster_1` resource instance, each carrying the assigned core number. +There is no central assignment table — the `DefinedSimpleRelationship` nodes ARE the assignments. They are separate from the regular `:Relationship` nodes used for TMF service/resource relationships, and are accessible in Elixir via `instance.assignments`. As an exercise, clone the GPU resource to create an NPU resource and assign some NPU cores from it to your cluster. Check that the assigned NPU cores are unique. @@ -805,7 +815,7 @@ In this tutorial you've used Diffo's unified `provider do` extension to define a - A composite Cluster resource that receives GPU cores via `Diffo.Provider.Assigner` - A GPU resource using `pools do` to declare the `:cores` assignable pool — `pool :cores, :core` replaces the old `characteristic :cores, AssignableValue` pattern -- Assignments stored on `Diffo.Provider.AssignedToRelationship` nodes (Neo4j label `:AssignmentRelationship`, distinct from TMF `:Relationship` nodes); accessible via `instance.assignments` +- Assignments stored as `Diffo.Provider.DefinedSimpleRelationship` records with `type: :assignedTo` (distinct from TMF `Relationship` nodes); accessible via `instance.assignments` - `Tenant` and `Engineer` Party kinds declared with `provider do` that express which instances they operate and manage - A `DataCentre` Place kind that declares the instances located at it diff --git a/lib/diffo/provider/assigner/assignable_characteristic.ex b/lib/diffo/provider/assigner/assignable_characteristic.ex index afb3d45..36fbee6 100644 --- a/lib/diffo/provider/assigner/assignable_characteristic.ex +++ b/lib/diffo/provider/assigner/assignable_characteristic.ex @@ -20,6 +20,20 @@ defmodule Diffo.Provider.AssignableCharacteristic do plural_name :assignable_characteristics end + actions do + create :create do + accept [:name, :first, :last, :assignable_type, :algorithm, :thing] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:first, :last, :assignable_type, :algorithm] + end + end + attributes do attribute :first, :integer do description "the first assignable value in the pool" @@ -56,13 +70,13 @@ defmodule Diffo.Provider.AssignableCharacteristic do end calculations do - calculate :value, Diffo.Type.CharacteristicValue, + calculate :value, + Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do public? true end - calculate :assigned_values, {:array, :integer}, - Diffo.Provider.Calculations.AssignedValues do + calculate :assigned_values, {:array, :integer}, Diffo.Provider.Calculations.AssignedValues do public? true argument :thing, :atom, allow_nil?: false end @@ -72,20 +86,6 @@ defmodule Diffo.Provider.AssignableCharacteristic do end end - actions do - create :create do - accept [:name, :first, :last, :assignable_type, :algorithm, :thing] - argument :instance_id, :uuid - argument :feature_id, :uuid - change manage_relationship(:instance_id, :instance, type: :append) - change manage_relationship(:feature_id, :feature, type: :append) - end - - update :update do - accept [:first, :last, :assignable_type, :algorithm] - end - end - preparations do prepare build(load: [:value, :free]) end diff --git a/lib/diffo/provider/assigner/assignable_characteristic/value.ex b/lib/diffo/provider/assigner/assignable_characteristic/value.ex index 10f7d63..0acae6b 100644 --- a/lib/diffo/provider/assigner/assignable_characteristic/value.ex +++ b/lib/diffo/provider/assigner/assignable_characteristic/value.ex @@ -6,6 +6,12 @@ defmodule Diffo.Provider.AssignableCharacteristic.Value do @moduledoc "JSON value struct for AssignableCharacteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + jason do + pick [:first, :last, :assignable_type, :algorithm] + compact true + rename assignable_type: :type + end + typed_struct do field :first, :integer, description: "the first assignable value in the pool" field :last, :integer, description: "the last assignable value in the pool" @@ -13,12 +19,6 @@ defmodule Diffo.Provider.AssignableCharacteristic.Value do field :algorithm, :atom, description: "the selection algorithm for auto-assign" end - jason do - pick [:first, :last, :assignable_type, :algorithm] - compact true - rename assignable_type: :type - end - defimpl String.Chars do def to_string(struct), do: inspect(struct) end diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 370c7c8..4c35acb 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -154,7 +154,7 @@ defmodule Diffo.Provider.Assigner do when is_struct(instance) and is_atom(pool) and is_atom(thing) and is_integer(value) do case pool_characteristic(instance.id, pool, thing) do {:ok, nil} -> false - {:ok, char} -> value in Enum.to_list(char.first..char.last) -- char.assigned_values + {:ok, char} -> value in (Enum.to_list(char.first..char.last) -- char.assigned_values) {:error, _} -> false end end diff --git a/lib/diffo/provider/changes/validate_relationship_permitted.ex b/lib/diffo/provider/changes/validate_relationship_permitted.ex new file mode 100644 index 0000000..b2d6777 --- /dev/null +++ b/lib/diffo/provider/changes/validate_relationship_permitted.ex @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Changes.ValidateRelationshipPermitted do + @moduledoc false + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, _context) do + case Ash.Changeset.get_argument(changeset, :relationships) do + nil -> changeset + [] -> changeset + rels -> validate_source_roles(changeset, rels) + end + end + + defp validate_source_roles(changeset, rels) 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 + :ok -> cs + {:error, msg} -> Ash.Changeset.add_error(cs, msg) + end + end) + end + + defp check_permitted(_role, :all), do: :ok + + defp check_permitted(_role, :none), + do: {:error, "relationships are not permitted as source on this resource"} + + defp check_permitted(role, roles) 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"} + end + end +end diff --git a/lib/diffo/provider/components/base_characteristic.ex b/lib/diffo/provider/components/base_characteristic.ex index 6e4118b..ffd70c3 100644 --- a/lib/diffo/provider/components/base_characteristic.ex +++ b/lib/diffo/provider/components/base_characteristic.ex @@ -81,7 +81,6 @@ defmodule Diffo.Provider.BaseCharacteristic do Diffo.Provider.Characteristic.Extension ] - neo4j do relate [ {:instance, :HAS, :incoming, :Instance}, diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 996362e..108560c 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -461,6 +461,7 @@ 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 actions do diff --git a/lib/diffo/provider/components/characteristic.ex b/lib/diffo/provider/components/characteristic.ex index 53b67e2..9bf353a 100644 --- a/lib/diffo/provider/components/characteristic.ex +++ b/lib/diffo/provider/components/characteristic.ex @@ -10,7 +10,11 @@ defmodule Diffo.Provider.Characteristic do otp_app: :diffo, domain: Diffo.Provider, data_layer: AshNeo4j.DataLayer, - extensions: [AshOutstanding.Resource, AshJason.Resource, Diffo.Provider.Characteristic.Extension] + extensions: [ + AshOutstanding.Resource, + AshJason.Resource, + Diffo.Provider.Characteristic.Extension + ] resource do description "An Ash Resource for a TMF Characteristic" diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index 36a8e58..fcedbe4 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -97,7 +97,8 @@ defmodule Diffo.Provider.Extension do PartyRole, PlaceDeclaration, PlaceRole, - Pool + Pool, + RelationshipStep } # ── specification ────────────────────────────────────────────────────────── @@ -177,8 +178,7 @@ defmodule Diffo.Provider.Extension do ], value_type: [ type: :any, - doc: - "The type of the characteristic value — a module or `{:array, module}` for an array." + doc: "The type of the characteristic value — a module or `{:array, module}` for an array." ] ] } @@ -464,6 +464,55 @@ defmodule Diffo.Provider.Extension do entities: [@pool_entity] } + # ── relationships ────────────────────────────────────────────────────────── + + @source_entity %Spark.Dsl.Entity{ + name: :source, + describe: + "Declares permitted source relationship roles — pipeline step, last declaration wins", + target: RelationshipStep, + args: [:roles], + auto_set_fields: [direction: :source], + schema: [ + roles: [ + type: :any, + doc: "`:all`, `:none`, or a list of role name atoms.", + required: true + ] + ] + } + + @target_entity %Spark.Dsl.Entity{ + name: :target, + describe: + "Declares permitted target relationship roles — pipeline step, last declaration wins", + target: RelationshipStep, + args: [:roles], + auto_set_fields: [direction: :target], + schema: [ + roles: [ + type: :any, + doc: "`:all`, `:none`, or a list of role name atoms.", + required: true + ] + ] + } + + @relationships_section %Spark.Dsl.Section{ + name: :relationships, + describe: + "Relationship role permissions for this Instance — declares which aliases it may participate in as source or target. Omitting defaults to `:none` per direction.", + examples: [ + """ + relationships do + source [:provides, :requires] + target :all + end + """ + ], + entities: [@source_entity, @target_entity] + } + # ── behaviour ────────────────────────────────────────────────────────────── @action_create_entity %Spark.Dsl.Entity{ @@ -528,12 +577,16 @@ defmodule Diffo.Provider.Extension do @parties, @places, @instances, + @relationships_section, @behaviour_section ] } use Spark.Dsl.Extension, sections: [@provider], + transformers: [ + Diffo.Provider.Extension.Transformers.TransformRelationships + ], persisters: [ Diffo.Provider.Extension.Persisters.PersistSpecification, Diffo.Provider.Extension.Persisters.PersistCharacteristics, @@ -552,6 +605,7 @@ defmodule Diffo.Provider.Extension do Diffo.Provider.Extension.Verifiers.VerifyParties, Diffo.Provider.Extension.Verifiers.VerifyPlaces, Diffo.Provider.Extension.Verifiers.VerifyInstances, - Diffo.Provider.Extension.Verifiers.VerifyBehaviour + Diffo.Provider.Extension.Verifiers.VerifyBehaviour, + Diffo.Provider.Extension.Verifiers.VerifyRelationships ] end diff --git a/lib/diffo/provider/extension/characteristic.ex b/lib/diffo/provider/extension/characteristic.ex index f548dfb..c6ff31f 100644 --- a/lib/diffo/provider/extension/characteristic.ex +++ b/lib/diffo/provider/extension/characteristic.ex @@ -199,5 +199,4 @@ defmodule Diffo.Provider.Extension.Characteristic do end def typed?(_), do: false - end diff --git a/lib/diffo/provider/extension/persisters/persist_specification.ex b/lib/diffo/provider/extension/persisters/persist_specification.ex index 5363e5a..81e4424 100644 --- a/lib/diffo/provider/extension/persisters/persist_specification.ex +++ b/lib/diffo/provider/extension/persisters/persist_specification.ex @@ -13,7 +13,12 @@ defmodule Diffo.Provider.Extension.Persisters.PersistSpecification do id: Transformer.get_option(dsl_state, [:provider, :specification], :id), name: Transformer.get_option(dsl_state, [:provider, :specification], :name), type: - Transformer.get_option(dsl_state, [:provider, :specification], :type, :serviceSpecification), + Transformer.get_option( + dsl_state, + [:provider, :specification], + :type, + :serviceSpecification + ), major_version: Transformer.get_option(dsl_state, [:provider, :specification], :major_version, 1), minor_version: diff --git a/lib/diffo/provider/extension/pool.ex b/lib/diffo/provider/extension/pool.ex index e35a56e..9409673 100644 --- a/lib/diffo/provider/extension/pool.ex +++ b/lib/diffo/provider/extension/pool.ex @@ -10,7 +10,8 @@ defmodule Diffo.Provider.Extension.Pool do @doc "Creates AssignableCharacteristic nodes for each declared pool during the build action" def create_pools(result, pools) when is_struct(result) and is_list(pools) do - Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name, thing: thing}, {:ok, acc} -> + Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name, thing: thing}, + {:ok, acc} -> case Diffo.Provider.AssignableCharacteristic |> Ash.Changeset.for_create(:create, %{name: name, thing: thing, instance_id: acc.id}) |> Ash.create() do diff --git a/lib/diffo/provider/extension/relationship_step.ex b/lib/diffo/provider/extension/relationship_step.ex new file mode 100644 index 0000000..6efd4f9 --- /dev/null +++ b/lib/diffo/provider/extension/relationship_step.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.RelationshipStep do + @moduledoc false + defstruct [:direction, :roles, :__spark_metadata__] +end diff --git a/lib/diffo/provider/extension/transformers/transform_relationships.ex b/lib/diffo/provider/extension/transformers/transform_relationships.ex new file mode 100644 index 0000000..ee88cdb --- /dev/null +++ b/lib/diffo/provider/extension/transformers/transform_relationships.ex @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Transformers.TransformRelationships do + @moduledoc "Resolves the relationships pipeline and bakes permitted_source_roles/0 and permitted_target_roles/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + steps = Transformer.get_entities(dsl_state, [:provider, :relationships]) + source_roles = resolve_roles(steps, :source) + target_roles = resolve_roles(steps, :target) + + escaped_steps = Macro.escape(steps) + escaped_source = Macro.escape(source_roles) + escaped_target = Macro.escape(target_roles) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def relationships, do: unquote(escaped_steps) + + @doc false + def permitted_source_roles, do: unquote(escaped_source) + + @doc false + def permitted_target_roles, do: unquote(escaped_target) + end + )} + end + + defp resolve_roles(steps, direction) do + steps + |> Enum.filter(&(&1.direction == direction)) + |> case do + [] -> :none + filtered -> List.last(filtered).roles + end + end + + @impl true + def after?(_), do: false +end diff --git a/lib/diffo/provider/extension/verifiers/verify_relationships.ex b/lib/diffo/provider/extension/verifiers/verify_relationships.ex new file mode 100644 index 0000000..cf6db75 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_relationships.ex @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyRelationships do + @moduledoc "Verifies that relationship role declarations are atoms, not modules or other invalid values" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + steps = Verifier.get_entities(dsl_state, [:provider, :relationships]) + + errors = Enum.flat_map(steps, &validate_step(resource, &1)) + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end + + defp validate_step(resource, %{direction: direction, roles: roles}) do + validate_roles(resource, direction, roles) + end + + defp validate_roles(_resource, _direction, :all), do: [] + defp validate_roles(_resource, _direction, :none), do: [] + + defp validate_roles(resource, direction, roles) when is_list(roles) and length(roles) > 0 do + Enum.flat_map(roles, fn role -> + if is_atom(role) do + [] + else + [ + DslError.exception( + module: resource, + path: [:provider, :relationships], + message: + "relationships: #{direction} role #{inspect(role)} must be an atom, got #{inspect(role)}" + ) + ] + end + end) + end + + defp validate_roles(resource, direction, roles) do + [ + DslError.exception( + module: resource, + path: [:provider, :relationships], + message: + "relationships: #{direction} roles must be :all, :none, or a non-empty list of atoms, got: #{inspect(roles)}" + ) + ] + end +end diff --git a/lib/diffo/type/name_value_array_primitive.ex b/lib/diffo/type/name_value_array_primitive.ex index 1c3b45c..6e45af0 100644 --- a/lib/diffo/type/name_value_array_primitive.ex +++ b/lib/diffo/type/name_value_array_primitive.ex @@ -17,6 +17,9 @@ defmodule Diffo.Type.NameValueArrayPrimitive do typed_struct do field :name, :atom, allow_nil?: false, description: "the name" - field :values, {:array, Diffo.Type.Primitive}, default: [], description: "the primitive values" + + field :values, {:array, Diffo.Type.Primitive}, + default: [], + description: "the primitive values" end end diff --git a/mix.exs b/mix.exs index fe686ab..485f949 100644 --- a/mix.exs +++ b/mix.exs @@ -143,8 +143,7 @@ defmodule Diffo.MixProject do "docs", "spark.replace_doc_links" ], - "spark.cheat_sheets": - "spark.cheat_sheets --extensions Diffo.Provider.Extension", + "spark.cheat_sheets": "spark.cheat_sheets --extensions Diffo.Provider.Extension", "spark.formatter": [ "spark.formatter --extensions Diffo.Provider.Extension", "format .formatter.exs" diff --git a/test/provider/extension/relationship_dsl_test.exs b/test/provider/extension/relationship_dsl_test.exs new file mode 100644 index 0000000..4397495 --- /dev/null +++ b/test/provider/extension/relationship_dsl_test.exs @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.RelationshipDslTest do + @moduledoc false + use ExUnit.Case, async: true + alias Diffo.Test.Util + alias Diffo.Test.Instance.ShelfInstance + alias Diffo.Test.Instance.CardInstance + alias Diffo.Test.Parties + alias Diffo.Provider.Extension.RelationshipStep + alias Diffo.Provider.Instance.Relationship, as: RelStruct + + # ── module-level fixture for last-wins test ───────────────────────────────── + + defmodule LastWinsInstance do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "tests pipeline last-wins" + plural_name :last_wins + end + + provider do + specification do + id "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + name "lastWins" + type :resourceSpecification + end + + relationships do + source [:provides] + source :all + end + end + end + + # ── TransformRelationships — baked functions ──────────────────────────────── + + describe "TransformRelationships — baked functions" do + test "ShelfInstance has source :all declared" do + assert ShelfInstance.permitted_source_roles() == :all + end + + test "ShelfInstance has no target declaration — defaults to :none" do + assert ShelfInstance.permitted_target_roles() == :none + end + + test "CardInstance has target :all declared" do + assert CardInstance.permitted_target_roles() == :all + end + + test "CardInstance has no source declaration — defaults to :none" do + assert CardInstance.permitted_source_roles() == :none + end + + test "ShelfInstance.relationships/0 returns raw pipeline steps" do + steps = ShelfInstance.relationships() + assert is_list(steps) + assert length(steps) == 1 + [step] = steps + assert is_struct(step, RelationshipStep) + assert step.direction == :source + assert step.roles == :all + end + + test "CardInstance.relationships/0 returns raw pipeline steps" do + steps = CardInstance.relationships() + assert is_list(steps) + [step] = steps + assert step.direction == :target + assert step.roles == :all + end + + test "pipeline last-wins — later source step overrides earlier" do + # LastWinsInstance declares source [:provides] then source :all; :all wins + assert LastWinsInstance.permitted_source_roles() == :all + assert length(LastWinsInstance.relationships()) == 2 + end + + test "resource with no relationships section gets :none for both directions" do + assert Diffo.Test.Instance.Broadband.permitted_source_roles() == :none + assert Diffo.Test.Instance.Broadband.permitted_target_roles() == :none + end + end + + # ── VerifyRelationships — compile-time errors ─────────────────────────────── + + describe "VerifyRelationships — compile-time errors" do + test "non-atom in source roles list warns DslError" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "relationships:", + fn -> + defmodule InvalidSourceRole do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with non-atom relationship role" + plural_name :invalid_source_roles + end + + provider do + specification do + id "b2c3d4e5-f6a7-8901-bcde-f12345678901" + name "invalidRole" + type :resourceSpecification + end + + relationships do + source ["not_an_atom"] + end + end + end + end + ) + end + + test "empty list for source roles warns DslError" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "relationships:", + fn -> + defmodule EmptySourceRoles do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with empty source roles" + plural_name :empty_source_roles + end + + provider do + specification do + id "c3d4e5f6-a7b8-9012-cdef-123456789012" + name "emptyRoles" + type :resourceSpecification + end + + relationships do + source [] + end + end + end + end + ) + end + end + + # ── ValidateRelationshipPermitted — integration enforcement ───────────────── + + describe "ValidateRelationshipPermitted — integration enforcement" do + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + test "relate action succeeds when source permits :all" do + {:ok, shelf} = Parties.build_shelf_with_installer() + {:ok, card} = Diffo.Test.Servo.build_card(%{}) + + rel = %RelStruct{id: card.id, alias: :connects, type: :service, direction: :forward} + + result = Diffo.Test.Servo.relate_shelf(shelf, %{relationships: [rel]}) + assert {:ok, _} = result + end + + test "relate action fails when source permits :none" do + {:ok, card} = Diffo.Test.Servo.build_card(%{}) + {:ok, shelf} = Parties.build_shelf_with_installer() + + # CardInstance has source :none — relating as source should fail + rel = %RelStruct{id: shelf.id, alias: :connects, type: :service, direction: :forward} + + result = Diffo.Test.Servo.relate_card(card, %{relationships: [rel]}) + + assert {:error, error} = result + assert Exception.message(error) =~ "not permitted as source" + end + end +end diff --git a/test/provider/extension/specification_test.exs b/test/provider/extension/specification_test.exs index c14f7e9..6f843c4 100644 --- a/test/provider/extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -27,21 +27,27 @@ defmodule Diffo.Provider.Extension.SpecificationTest do test "minor_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + {:ok, specification} = + Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.minor_version == ShelfInstance.specification()[:minor_version] end test "patch_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + {:ok, specification} = + Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.patch_version == ShelfInstance.specification()[:patch_version] end test "tmf_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + {:ok, specification} = + Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.tmf_version == ShelfInstance.specification()[:tmf_version] end end diff --git a/test/support/resource/characteristic/card_characteristic.ex b/test/support/resource/characteristic/card_characteristic.ex index 0d85b4c..ad9ea36 100644 --- a/test/support/resource/characteristic/card_characteristic.ex +++ b/test/support/resource/characteristic/card_characteristic.ex @@ -13,18 +13,6 @@ defmodule Diffo.Test.Characteristic.CardCharacteristic do plural_name :card_values end - attributes do - attribute :family, :atom, public?: true, description: "the card family name" - attribute :model, :string, public?: true, description: "the card model name" - attribute :technology, :atom, public?: true, description: "the card technology" - end - - calculations do - calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do - public? true - end - end - actions do create :create do accept [:name, :family, :model, :technology] @@ -39,6 +27,20 @@ defmodule Diffo.Test.Characteristic.CardCharacteristic do end end + attributes do + attribute :family, :atom, public?: true, description: "the card family name" + attribute :model, :string, public?: true, description: "the card model name" + attribute :technology, :atom, public?: true, description: "the card technology" + end + + calculations do + calculate :value, + Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + preparations do prepare build(load: [:value]) end diff --git a/test/support/resource/characteristic/card_characteristic/value.ex b/test/support/resource/characteristic/card_characteristic/value.ex index e1d4835..8962485 100644 --- a/test/support/resource/characteristic/card_characteristic/value.ex +++ b/test/support/resource/characteristic/card_characteristic/value.ex @@ -6,14 +6,14 @@ defmodule Diffo.Test.Characteristic.CardCharacteristic.Value do @moduledoc "Typed value struct for a Card characteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + jason do + pick [:family, :model, :technology] + compact true + end + typed_struct do field :family, :atom, description: "the card family name" field :model, :string, description: "the card model name" field :technology, :atom, description: "the card technology" end - - jason do - pick [:family, :model, :technology] - compact true - end end diff --git a/test/support/resource/characteristic/deployment_class.ex b/test/support/resource/characteristic/deployment_class.ex index d816f2c..59f7b6b 100644 --- a/test/support/resource/characteristic/deployment_class.ex +++ b/test/support/resource/characteristic/deployment_class.ex @@ -13,17 +13,6 @@ defmodule Diffo.Test.Characteristic.DeploymentClass do plural_name :deployment_class_values end - attributes do - attribute :class, :string, public?: true, description: "the deployment class" - attribute :mask, :string, public?: true, description: "the mask name" - end - - calculations do - calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do - public? true - end - end - actions do create :create do accept [:name, :class, :mask] @@ -38,6 +27,19 @@ defmodule Diffo.Test.Characteristic.DeploymentClass do end end + attributes do + attribute :class, :string, public?: true, description: "the deployment class" + attribute :mask, :string, public?: true, description: "the mask name" + end + + calculations do + calculate :value, + Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + preparations do prepare build(load: [:value]) end diff --git a/test/support/resource/characteristic/deployment_class/value.ex b/test/support/resource/characteristic/deployment_class/value.ex index 53a6179..b45f0a8 100644 --- a/test/support/resource/characteristic/deployment_class/value.ex +++ b/test/support/resource/characteristic/deployment_class/value.ex @@ -6,13 +6,13 @@ defmodule Diffo.Test.Characteristic.DeploymentClass.Value do @moduledoc "Typed value struct for a DeploymentClass characteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] - typed_struct do - field :class, :string, description: "the deployment class" - field :mask, :string, description: "the mask name" - end - jason do pick [:class, :mask] compact true end + + typed_struct do + field :class, :string, description: "the deployment class" + field :mask, :string, description: "the mask name" + end end diff --git a/test/support/resource/characteristic/shelf_characteristic.ex b/test/support/resource/characteristic/shelf_characteristic.ex index 7545df1..3cdfc47 100644 --- a/test/support/resource/characteristic/shelf_characteristic.ex +++ b/test/support/resource/characteristic/shelf_characteristic.ex @@ -13,18 +13,6 @@ defmodule Diffo.Test.Characteristic.ShelfCharacteristic do plural_name :shelf_values end - attributes do - attribute :family, :atom, public?: true, description: "the shelf family name" - attribute :model, :string, public?: true, description: "the shelf model name" - attribute :technology, :atom, public?: true, description: "the shelf technology" - end - - calculations do - calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do - public? true - end - end - actions do create :create do accept [:name, :family, :model, :technology] @@ -39,6 +27,20 @@ defmodule Diffo.Test.Characteristic.ShelfCharacteristic do end end + attributes do + attribute :family, :atom, public?: true, description: "the shelf family name" + attribute :model, :string, public?: true, description: "the shelf model name" + attribute :technology, :atom, public?: true, description: "the shelf technology" + end + + calculations do + calculate :value, + Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + preparations do prepare build(load: [:value]) end diff --git a/test/support/resource/characteristic/shelf_characteristic/value.ex b/test/support/resource/characteristic/shelf_characteristic/value.ex index 57aaf70..8119a3f 100644 --- a/test/support/resource/characteristic/shelf_characteristic/value.ex +++ b/test/support/resource/characteristic/shelf_characteristic/value.ex @@ -6,14 +6,14 @@ defmodule Diffo.Test.Characteristic.ShelfCharacteristic.Value do @moduledoc "Typed value struct for a Shelf characteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + jason do + pick [:family, :model, :technology] + compact true + end + typed_struct do field :family, :atom, description: "the shelf family name" field :model, :string, description: "the shelf model name" field :technology, :atom, description: "the shelf technology" end - - jason do - pick [:family, :model, :technology] - compact true - end end diff --git a/test/support/resource/instance/card_instance.ex b/test/support/resource/instance/card_instance.ex index 8e432ce..b0e27c3 100644 --- a/test/support/resource/instance/card_instance.ex +++ b/test/support/resource/instance/card_instance.ex @@ -43,6 +43,10 @@ defmodule Diffo.Test.Instance.CardInstance do pool :ports, :port end + relationships do + target :all + end + behaviour do actions do create :build diff --git a/test/support/resource/instance/shelf_instance.ex b/test/support/resource/instance/shelf_instance.ex index 30b3e19..fcfbef9 100644 --- a/test/support/resource/instance/shelf_instance.ex +++ b/test/support/resource/instance/shelf_instance.ex @@ -73,6 +73,10 @@ defmodule Diffo.Test.Instance.ShelfInstance do place_ref :billing_address, Diffo.Provider.Place end + relationships do + source :all + end + behaviour do actions do create :build diff --git a/usage-rules.md b/usage-rules.md index 1381c65..1315f17 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -40,7 +40,7 @@ end All DSL declarations live inside a single `provider do` block. The sections available depend on the resource kind: -- **Instance** — `specification`, `characteristics`, `features`, `pools`, `parties`, `places`, `behaviour` +- **Instance** — `specification`, `characteristics`, `features`, `pools`, `parties`, `places`, `relationships`, `behaviour` - **Party** — `instances`, `parties`, `places` - **Place** — `instances`, `parties`, `places` @@ -284,6 +284,40 @@ update :assign_core do end ``` +### `relationships do` — Instance only + +Declares which relationship roles this Instance kind may participate in as a **source** or +**target** in TMF `Relationship` records. Omitting the section defaults both directions to +`:none`, which blocks any update action that passes `argument :relationships, {:array, :struct}`. + +Declarations form a pipeline — `source` and `target` steps may each be repeated; **the last +declaration per direction wins**. + +```elixir +provider do + relationships do + source [:provides, :requires] # last step overrides earlier ones + target :all + end +end +``` + +Each step accepts `:all`, `:none`, or a non-empty list of role-name atoms (relationship aliases): + +| Value | Meaning | +|---|---| +| `:all` | any alias is permitted in this direction | +| `:none` | no relationships are permitted (default when section is omitted) | +| `[:provides, :requires]` | only these alias atoms are permitted | + +`ValidateRelationshipPermitted` is automatically injected by the DSL into every update action +that carries `argument :relationships, {:array, :struct}`. It enforces `permitted_source_roles/0` +on the source resource before the action runs. + +**The Assigner is not affected** — assignment actions use `argument :assignment`, not +`argument :relationships`, and write `DefinedSimpleRelationship` records directly via the +Provider domain. `relationships do` permissions are never checked during assignment. + ### `behaviour do` — Instance only Marks a named create action for build wiring. Declaring `create :name` injects the @@ -307,6 +341,8 @@ functions: - `specification/0`, `characteristics/0`, `features/0`, `pools/0`, `parties/0`, `places/0` - `characteristic/1`, `feature/1`, `feature_characteristic/2`, `pool/1`, `party/1`, `place/1` +- `relationships/0` — raw ordered list of `RelationshipStep` pipeline entries +- `permitted_source_roles/0`, `permitted_target_roles/0` — resolved permission (`:all`, `:none`, or list of atoms) - `build_before/1` — upserts the Specification node; creates Feature, Characteristic, and Party nodes; sets action argument ids. Called automatically before every create action. - `build_after/2` — relates the created TMF entities to the new instance node. Called @@ -484,7 +520,12 @@ end which looks up the thing name from the pool automatically. `assign/4` is still available for cases without a `pools do` declaration. - **Do not query `Diffo.Provider.Relationship` for `type: :assignedTo` records** — assignment - relationships live on `Diffo.Provider.AssignedToRelationship`. Access them via `instance.assignments`. + records live on `Diffo.Provider.DefinedSimpleRelationship`. Access them via `instance.assignments`. - **Do not filter `instance.forward_relationships` for `type == :assignedTo`** — those records no - longer exist there. `forward_relationships` contains only regular TMF relationships; - `assignments` contains pool assignment relationships. + longer exist there. `forward_relationships` contains only regular TMF `Relationship` nodes; + `instance.assignments` contains `DefinedSimpleRelationship` pool assignment records. +- **Do not write `update :relate` actions without a `relationships do` section** — omitting the + section defaults `permitted_source_roles` to `:none`, causing all calls to that action to fail. + Add `relationships do source :all end` (or a specific list of roles) to permit relates. +- **Do not add `relationships do` to Party or Place resources** — the section is for Instance + kinds only; it is not enforced on Party/Place resources and has no effect there. From b80ebad42b5c65a2905777986f2ca6f73d822088 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 13:11:07 +0930 Subject: [PATCH 05/23] 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 06/23] 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 07/23] 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 08/23] 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 09/23] 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 From aa5b400dbceb3061505e1056320b9e0b3029028f Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 00:49:30 +0930 Subject: [PATCH 10/23] minor refactorings --- .../instance/extension/characteristic.ex | 33 ++++++------ .../components/instance/extension/feature.ex | 28 +++++----- .../components/instance/extension/party.ex | 27 ++++------ .../components/instance/extension/place.ex | 27 ++++------ .../instance/extension/relationship.ex | 52 +++++-------------- lib/diffo/validations/is_related_different.ex | 4 -- 6 files changed, 60 insertions(+), 111 deletions(-) diff --git a/lib/diffo/provider/components/instance/extension/characteristic.ex b/lib/diffo/provider/components/instance/extension/characteristic.ex index a331175..9b003df 100644 --- a/lib/diffo/provider/components/instance/extension/characteristic.ex +++ b/lib/diffo/provider/components/instance/extension/characteristic.ex @@ -21,21 +21,21 @@ defmodule Diffo.Provider.Instance.Characteristic do """ def set_characteristics_argument(changeset, declarations) when is_struct(changeset, Ash.Changeset) and is_list(declarations) do - case characteristics = create_characteristics_from_declarations(declarations, :instance) do - [] -> + case create_characteristics_from_declarations(declarations, :instance) do + {:ok, []} -> changeset - {:error, error} -> - Ash.Changeset.add_error(changeset, error) - - _ -> + {:ok, characteristics} -> characteristic_ids = Enum.map(characteristics, &Map.get(&1, :id)) Ash.Changeset.force_set_argument(changeset, :characteristics, characteristic_ids) + + {:error, error} -> + Ash.Changeset.add_error(changeset, error) end end defp create_characteristics_from_declarations(declarations, type) do - Enum.reduce_while(declarations, [], fn %{name: name, value_type: value_type}, acc -> + Enum.reduce_while(declarations, {:ok, []}, fn %{name: name, value_type: value_type}, {:ok, acc} -> try do attrs = case value_type do @@ -48,7 +48,7 @@ defmodule Diffo.Provider.Instance.Characteristic do case Provider.create_characteristic(attrs) do {:ok, result} -> - {:cont, [result | acc]} + {:cont, {:ok, [result | acc]}} {:error, error} -> {:halt, {:error, error}} @@ -148,26 +148,25 @@ defmodule Diffo.Provider.Instance.Characteristic do end) characteristics = - Enum.reduce_while(characteristic_updates, [], fn {characteristic, value}, acc -> + Enum.reduce_while(characteristic_updates, {:ok, []}, fn {characteristic, value}, {:ok, acc} -> case Provider.update_characteristic(characteristic, %{value: value}) do {:ok, characteristic} -> - {:cont, [characteristic | acc]} + {:cont, {:ok, [characteristic | acc]}} {:error, error} -> - # preserve the error {:halt, {:error, error}} end end) case characteristics do - {:error, error} -> - {:error, error} - - [] -> + {:ok, []} -> {:error, "couldn't update characteristics"} - _ -> - {:ok, Map.put(result, :characteristics, characteristics)} + {:ok, updated} -> + {:ok, Map.put(result, :characteristics, updated)} + + {:error, error} -> + {:error, error} end end end diff --git a/lib/diffo/provider/components/instance/extension/feature.ex b/lib/diffo/provider/components/instance/extension/feature.ex index 9e1c723..4aa0da3 100644 --- a/lib/diffo/provider/components/instance/extension/feature.ex +++ b/lib/diffo/provider/components/instance/extension/feature.ex @@ -21,27 +21,26 @@ defmodule Diffo.Provider.Instance.Feature do """ def set_features_argument(changeset, declarations) when is_struct(changeset, Ash.Changeset) and is_list(declarations) do - case features = create_features_from_declarations(declarations) do - [] -> + case create_features_from_declarations(declarations) do + {:ok, []} -> changeset - {:error, error} -> - Ash.Changeset.add_error(changeset, error) - - _ -> + {:ok, features} -> feature_ids = Enum.map(features, &Map.get(&1, :id)) Ash.Changeset.force_set_argument(changeset, :features, feature_ids) + + {:error, error} -> + Ash.Changeset.add_error(changeset, error) end end defp create_features_from_declarations(declarations) do Enum.reduce_while( declarations, - [], - # create any feature characteristics - fn %{name: name, is_enabled?: isEnabled, characteristics: characteristics}, acc -> + {:ok, []}, + fn %{name: name, is_enabled?: isEnabled, characteristics: characteristics}, {:ok, acc} -> characteristic_ids = - Enum.reduce_while(characteristics, [], fn %{name: name, value_type: value_type}, acc -> + Enum.reduce_while(characteristics, {:ok, []}, fn %{name: name, value_type: value_type}, {:ok, ids} -> try do attrs = case value_type do @@ -54,7 +53,7 @@ defmodule Diffo.Provider.Instance.Feature do case Provider.create_characteristic(attrs) do {:ok, result} -> - {:cont, [result.id | acc]} + {:cont, {:ok, [result.id | ids]}} {:error, error} -> {:halt, {:error, error}} @@ -71,15 +70,14 @@ defmodule Diffo.Provider.Instance.Feature do {:error, error} -> {:halt, {:error, error}} - _ -> - # create feature with feature characteristics + {:ok, ids} -> case Provider.create_feature(%{ name: name, isEnabled: isEnabled, - characteristics: characteristic_ids + characteristics: ids }) do {:ok, result} -> - {:cont, [result | acc]} + {:cont, {:ok, [result | acc]}} {:error, error} -> {:halt, {:error, error}} diff --git a/lib/diffo/provider/components/instance/extension/party.ex b/lib/diffo/provider/components/instance/extension/party.ex index 7a9b800..eb219bc 100644 --- a/lib/diffo/provider/components/instance/extension/party.ex +++ b/lib/diffo/provider/components/instance/extension/party.ex @@ -89,24 +89,15 @@ defmodule Diffo.Provider.Instance.Party do {:ok, result} _ -> - party_refs = - Enum.reduce_while(parties, [], fn %{id: id, role: role}, acc -> - case Provider.create_party_ref(%{instance_id: result.id, party_id: id, role: role}) do - {:ok, party_ref} -> - {:cont, [party_ref | acc]} - - {:error, _error} -> - {:halt, []} - end - end) - - case party_refs do - [] -> - {:error, "couldn't relate parties"} - - _ -> - # sorted = Ash.Sort.runtime_sort(party_refs, [role: :asc, created_at: :desc]) - {:ok, result |> Map.put(:parties, party_refs)} + Enum.reduce_while(parties, {:ok, []}, fn %{id: id, role: role}, {:ok, acc} -> + case Provider.create_party_ref(%{instance_id: result.id, party_id: id, role: role}) do + {:ok, party_ref} -> {:cont, {:ok, [party_ref | acc]}} + {:error, error} -> {:halt, {:error, error}} + end + end) + |> case do + {:ok, party_refs} -> {:ok, Map.put(result, :parties, party_refs)} + {:error, error} -> {:error, error} end end end diff --git a/lib/diffo/provider/components/instance/extension/place.ex b/lib/diffo/provider/components/instance/extension/place.ex index 14f7780..b55a5bf 100644 --- a/lib/diffo/provider/components/instance/extension/place.ex +++ b/lib/diffo/provider/components/instance/extension/place.ex @@ -26,24 +26,15 @@ defmodule Diffo.Provider.Instance.Place do {:ok, result} _ -> - place_refs = - Enum.reduce_while(places, [], fn %{id: id, role: role}, acc -> - case Provider.create_place_ref(%{instance_id: result.id, place_id: id, role: role}) do - {:ok, place_ref} -> - {:cont, [place_ref | acc]} - - {:error, _error} -> - {:halt, []} - end - end) - - case place_refs do - [] -> - {:error, "couldn't relate places"} - - _ -> - # sorted = Ash.Sort.runtime_sort(place_refs, [role: :asc, created_at: :desc]) - {:ok, result |> Map.put(:places, place_refs)} + Enum.reduce_while(places, {:ok, []}, fn %{id: id, role: role}, {:ok, acc} -> + case Provider.create_place_ref(%{instance_id: result.id, place_id: id, role: role}) do + {:ok, place_ref} -> {:cont, {:ok, [place_ref | acc]}} + {:error, error} -> {:halt, {:error, error}} + end + end) + |> case do + {:ok, place_refs} -> {:ok, Map.put(result, :places, place_refs)} + {:error, error} -> {:error, error} end end end diff --git a/lib/diffo/provider/components/instance/extension/relationship.ex b/lib/diffo/provider/components/instance/extension/relationship.ex index 3d9a54e..e637dd6 100644 --- a/lib/diffo/provider/components/instance/extension/relationship.ex +++ b/lib/diffo/provider/components/instance/extension/relationship.ex @@ -26,53 +26,27 @@ defmodule Diffo.Provider.Instance.Relationship do {:ok, result} _ -> - created_relationships = - Enum.reduce_while(relationships, [], fn %{ + Enum.reduce_while(relationships, :ok, fn %{ id: id, alias: name, type: type, direction: direction }, - acc -> + :ok -> + attrs = case direction do - :reverse -> - case Provider.create_relationship(%{ - source_id: id, - party_id: result.id, - alias: name, - type: type - }) do - {:ok, relationship} -> - {:cont, [relationship | acc]} - - {:error, _error} -> - {:halt, []} - end - - _ -> - # default :forward - case Provider.create_relationship(%{ - source_id: result.id, - target_id: id, - alias: name, - type: type - }) do - {:ok, relationship} -> - {:cont, [relationship | acc]} - - {:error, _error} -> - {:halt, []} - end + :reverse -> %{source_id: id, party_id: result.id, alias: name, type: type} + _ -> %{source_id: result.id, target_id: id, alias: name, type: type} end - end) - - case created_relationships do - [] -> - {:error, "couldn't relate instances"} - _ -> - # we haven't put the relationships into the result, they might be forward_relationships or reverse_relationships - {:ok, result} + case Provider.create_relationship(attrs) do + {:ok, _} -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + |> case do + :ok -> {:ok, result} + {:error, error} -> {:error, error} end end end diff --git a/lib/diffo/validations/is_related_different.ex b/lib/diffo/validations/is_related_different.ex index 4b69f39..5371f8f 100644 --- a/lib/diffo/validations/is_related_different.ex +++ b/lib/diffo/validations/is_related_different.ex @@ -25,10 +25,6 @@ defmodule Diffo.Validations.IsRelatedDifferent do def validate(changeset, opts, _context) do case Ash.Changeset.fetch_argument_or_change(changeset, opts[:related_id]) do :error -> - # related_id isn't changing - :ok - {:ok, nil} - # related_id is nil :ok {:ok, related_id} -> From c3fb12877f298f756de9035bc835aa50218449e4 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 02:35:32 +0930 Subject: [PATCH 11/23] improved via assignment_relationship, added identity --- AGENTS.md | 1 + lib/diffo/provider.ex | 6 + lib/diffo/provider/assigner/assigner.ex | 125 ++++++++---------- .../components/assignment_relationship.ex | 106 +++++++++++++++ .../provider/components/base_instance.ex | 4 +- .../calculations/assigned_values.ex | 7 +- .../components/calculations/free_values.ex | 8 +- test/provider/extension/assigner_test.exs | 2 +- 8 files changed, 180 insertions(+), 79 deletions(-) create mode 100644 lib/diffo/provider/components/assignment_relationship.ex diff --git a/AGENTS.md b/AGENTS.md index 1854507..a0eb7fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ lib/diffo/provider/ base_characteristic.ex # Ash Fragment for typed characteristic resources base_relationship.ex # Ash Fragment for shared Relationship structure defined_simple_relationship.ex # DefinedSimpleRelationship — relationship with one optional embedded characteristic, frozen at creation + assignment_relationship.ex # AssignmentRelationship — pool assignment relationship with top-level pool/thing/value scalar attributes relationship.ex # Relationship — mutable TMF service/resource relationship with graph Characteristic nodes calculations/ characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index 4ee5324..898749b 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -83,6 +83,12 @@ defmodule Diffo.Provider do define :delete_defined_simple_relationship, action: :destroy end + resource Diffo.Provider.AssignmentRelationship do + define :create_assignment_relationship, action: :create + define :get_assignment_relationship_by_id, action: :read, get_by: :id + define :delete_assignment_relationship, action: :destroy + end + resource Diffo.Provider.AssignableCharacteristic do define :create_assignable_characteristic, action: :create define :get_assignable_characteristic_by_id, action: :read, get_by: :id diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 4c35acb..358b7a7 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -4,15 +4,14 @@ defmodule Diffo.Provider.Assigner do @moduledoc """ - Helper to perform Assignment using `Diffo.Provider.DefinedSimpleRelationship`. + Helper to perform Assignment using `Diffo.Provider.AssignmentRelationship`. - Each assignment is stored as a `DefinedSimpleRelationship` with `type: :assignedTo` - and a single `NameValuePrimitive` characteristic carrying the thing name and assigned value. + Each assignment is stored as an `AssignmentRelationship` with top-level `pool`, + `thing`, and `value` attributes. This makes them filterable at the Cypher level + and usable in aggregate expressions. """ alias Diffo.Provider.AssignableCharacteristic - alias Diffo.Provider.DefinedSimpleRelationship - alias Diffo.Type.NameValuePrimitive - alias Diffo.Type.Primitive + alias Diffo.Provider.AssignmentRelationship @doc """ Assign a thing using the pool declared via `pools do` on the instance module. @@ -42,63 +41,48 @@ defmodule Diffo.Provider.Assigner do _ -> case Map.get(assignment, :operation, :auto_assign) do :auto_assign -> - case next(result, pool, thing) do - {:ok, assigned} -> - relate_is_assigned(result, pool, thing, assigned, assignee_id) - - {:error, error} -> - {:error, error} + with {:ok, value} <- next(result, pool, thing) do + create_assignment(result, pool, thing, value, assignee_id) end :assign -> - case assignable?(result, pool, thing, assignment.id) do - true -> - relate_is_assigned(result, pool, thing, assignment.id, assignee_id) - - false -> - {:error, "#{thing} #{assignment.id} is not assignable"} + if assignable?(result, pool, thing, assignment.id) do + create_assignment(result, pool, thing, assignment.id, assignee_id) + else + {:error, "#{thing} #{assignment.id} is not assignable"} end :unassign -> - unrelate_is_assigned(result, pool, thing, assignment.id, assignee_id) + destroy_assignment(result, pool, thing, assignment.id, assignee_id) end end end - defp relate_is_assigned(result, _pool, thing, value, assignee_id) - when is_struct(result) and is_atom(thing) and is_integer(value) and + defp create_assignment(result, pool, thing, value, assignee_id) + when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do - case Diffo.Provider.create_defined_simple_relationship(%{ - type: :assignedTo, - characteristic: %NameValuePrimitive{ - name: thing, - value: Primitive.wrap("integer", value) - }, - source_id: result.id, - target_id: assignee_id - }) do - {:ok, _relationship} -> - {:ok, result} - - {:error, error} -> - {:error, error} + with {:ok, _} <- + Diffo.Provider.create_assignment_relationship(%{ + pool: pool, + thing: thing, + value: value, + source_id: result.id, + target_id: assignee_id + }) do + {:ok, result} end end - defp unrelate_is_assigned(result, pool, thing, value, assignee_id) + defp destroy_assignment(result, pool, thing, value, assignee_id) when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do case find_assignment(result.id, assignee_id, pool, thing, value) do {:ok, nil} -> {:error, "#{thing} #{value} is not assigned to assignee #{assignee_id}"} - {:ok, relationship} -> - case Ash.destroy(relationship, domain: Diffo.Provider) do - :ok -> - {:ok, result} - - {:error, error} -> - {:error, error} + {:ok, assignment} -> + with :ok <- Ash.destroy(assignment, domain: Diffo.Provider) do + {:ok, result} end {:error, error} -> @@ -106,32 +90,27 @@ defmodule Diffo.Provider.Assigner do end end - defp find_assignment(source_id, target_id, _pool, thing, value) do - case DefinedSimpleRelationship - |> Ash.Query.new() - |> Ash.Query.filter_input(source_id: source_id, target_id: target_id, type: :assignedTo) - |> Ash.read(domain: Diffo.Provider) do - {:ok, rels} -> - {:ok, - Enum.find(rels, fn rel -> - rel.characteristic && - rel.characteristic.name == thing && - Diffo.Unwrap.unwrap(rel.characteristic.value) == value - end)} - - {:error, error} -> - {:error, error} - end + defp find_assignment(source_id, target_id, pool, thing, value) do + AssignmentRelationship + |> Ash.Query.filter_input( + source_id: source_id, + target_id: target_id, + pool: pool, + thing: thing, + value: value + ) + |> Ash.read_one(domain: Diffo.Provider) end defp next(instance, pool, thing) when is_struct(instance) and is_atom(pool) and is_atom(thing) do - case pool_characteristic(instance.id, pool, thing) do + case pool_characteristic(instance.id, pool) do {:ok, nil} -> {:error, "pool #{pool} not found on instance #{instance.id}"} {:ok, char} -> - free = Enum.to_list(char.first..char.last) -- char.assigned_values + assigned = assigned_values_for(instance.id, thing) + free = Enum.to_list(char.first..char.last) -- assigned case free do [] -> @@ -152,18 +131,30 @@ defmodule Diffo.Provider.Assigner do defp assignable?(instance, pool, thing, value) when is_struct(instance) and is_atom(pool) and is_atom(thing) and is_integer(value) do - case pool_characteristic(instance.id, pool, thing) do - {:ok, nil} -> false - {:ok, char} -> value in (Enum.to_list(char.first..char.last) -- char.assigned_values) - {:error, _} -> false + case pool_characteristic(instance.id, pool) do + {:ok, nil} -> + false + + {:ok, char} -> + assigned = assigned_values_for(instance.id, thing) + value in (Enum.to_list(char.first..char.last) -- assigned) + + {:error, _} -> + false end end - defp pool_characteristic(instance_id, pool, thing) do + defp assigned_values_for(instance_id, thing) do + AssignmentRelationship + |> Ash.Query.filter_input(source_id: instance_id, thing: thing) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.value) + end + + defp pool_characteristic(instance_id, pool) do AssignableCharacteristic |> Ash.Query.new() |> Ash.Query.filter_input(instance_id: instance_id, name: pool) - |> Ash.Query.load(assigned_values: [thing: thing]) |> Ash.read_one(domain: Diffo.Provider) end end diff --git a/lib/diffo/provider/components/assignment_relationship.ex b/lib/diffo/provider/components/assignment_relationship.ex new file mode 100644 index 0000000..8302c80 --- /dev/null +++ b/lib/diffo/provider/components/assignment_relationship.ex @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.AssignmentRelationship do + @moduledoc """ + Ash Resource for a pool assignment relationship. + + Stores a single pool assignment as a direct Neo4j relationship between a source + (the pool-owning instance) and a target (the assignee instance). `pool`, `thing`, + and `value` are top-level scalar attributes, making them filterable at the Cypher + level and usable in aggregate filters via AshNeo4j #253. + + Contrast with `DefinedSimpleRelationship`, which stores its characteristic as an + embedded `NameValuePrimitive` — suitable as a general primitive but opaque to the + data layer for filtering purposes. + + Actions: **create** and **destroy** only. Assignments are commitments; to change + an assignment, destroy and recreate. + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseRelationship], + otp_app: :diffo, + domain: Diffo.Provider + + resource do + description "A pool assignment relationship between a source and target instance" + plural_name :assignment_relationships + end + + neo4j do + relate [ + {:source, :RELATES, :incoming, :Instance}, + {:target, :RELATES, :outgoing, :Instance} + ] + end + + jason do + pick [:type] + + customize fn result, record -> + reference = %Diffo.Provider.Reference{ + id: record.target_id, + href: record.target_href + } + + list_name = + Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(record.target_type) + + characteristic = %{name: record.thing, value: record.value} + + result + |> Diffo.Util.set(record.target_type, reference) + |> Diffo.Util.set(list_name, [characteristic]) + end + + order [:type, :resource, :service, :resourceRelationshipCharacteristic, + :serviceRelationshipCharacteristic] + end + + actions do + create :create do + description "creates a pool assignment relationship between a source and target instance" + accept [:pool, :thing, :value] + + argument :source_id, :uuid + argument :target_id, :string + + change set_attribute(:type, :assignedTo) + change manage_relationship(:source_id, :source, type: :append) + change manage_relationship(:target_id, :target, type: :append) + change Diffo.Changes.DetailRelationship + end + end + + attributes do + attribute :pool, :atom do + description "the pool name this assignment belongs to (e.g. :ports)" + allow_nil? false + public? true + end + + attribute :thing, :atom do + description "the kind of thing being assigned (e.g. :port)" + allow_nil? false + public? true + end + + attribute :value, :integer do + description "the assigned integer value" + allow_nil? false + public? true + constraints min: 0 + end + end + + identities do + identity :unique_assignment, [:source_id, :pool, :thing, :value] do + pre_check? true + end + end + + preparations do + prepare build(sort: [created_at: :asc]) + end +end diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 4d2c028..1ee5655 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -188,7 +188,7 @@ defmodule Diffo.Provider.BaseInstance do {:process_statuses, :STATUSES, :incoming, :ProcessStatus}, {:forward_relationships, :RELATES, :outgoing, :Relationship}, {:reverse_relationships, :RELATES, :incoming, :Relationship}, - {:assignments, :RELATES, :outgoing, :DefinedSimpleRelationship}, + {:assignments, :RELATES, :outgoing, :AssignmentRelationship}, {:features, :HAS, :outgoing, :Feature}, {:characteristics, :HAS, :outgoing, :Characteristic}, {:entities, :RELATES, :outgoing, :EntityRef}, @@ -409,7 +409,7 @@ defmodule Diffo.Provider.BaseInstance do public? true end - has_many :assignments, Diffo.Provider.DefinedSimpleRelationship do + has_many :assignments, Diffo.Provider.AssignmentRelationship do description "the instance's outgoing pool assignment relationships" destination_attribute :source_id public? true diff --git a/lib/diffo/provider/components/calculations/assigned_values.ex b/lib/diffo/provider/components/calculations/assigned_values.ex index 3b8a982..eef75a6 100644 --- a/lib/diffo/provider/components/calculations/assigned_values.ex +++ b/lib/diffo/provider/components/calculations/assigned_values.ex @@ -14,11 +14,10 @@ defmodule Diffo.Provider.Calculations.AssignedValues do thing = context.arguments[:thing] Enum.map(records, fn record -> - Diffo.Provider.DefinedSimpleRelationship - |> Ash.Query.filter_input(source_id: record.instance_id, type: :assignedTo) + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(source_id: record.instance_id, thing: thing) |> Ash.read!(domain: Diffo.Provider) - |> Enum.filter(fn rel -> rel.characteristic && rel.characteristic.name == thing end) - |> Enum.map(fn rel -> Diffo.Unwrap.unwrap(rel.characteristic.value) end) + |> Enum.map(& &1.value) end) end end diff --git a/lib/diffo/provider/components/calculations/free_values.ex b/lib/diffo/provider/components/calculations/free_values.ex index af0de70..d3fba8f 100644 --- a/lib/diffo/provider/components/calculations/free_values.ex +++ b/lib/diffo/provider/components/calculations/free_values.ex @@ -17,12 +17,10 @@ defmodule Diffo.Provider.Calculations.FreeValues do record -> count = - Diffo.Provider.DefinedSimpleRelationship - |> Ash.Query.filter_input(source_id: record.instance_id, type: :assignedTo) + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(source_id: record.instance_id, thing: record.thing) |> Ash.read!(domain: Diffo.Provider) - |> Enum.count(fn rel -> - rel.characteristic && rel.characteristic.name == record.thing - end) + |> length() record.last - record.first + 1 - count end) diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index 54de795..4c9e652 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -191,7 +191,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assert length(card.assignments) == 1 - assigned_port = Diffo.Unwrap.unwrap(hd(card.assignments).characteristic.value) + assigned_port = hd(card.assignments).value {:ok, card} = Servo.assign_port(card, %{ From 841a42d341f49b2372b84a14db82c3bf549f6f78 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 02:45:36 +0930 Subject: [PATCH 12/23] transform_behaviour is a transformer --- AGENTS.md | 2 +- .../transformers/transform_behaviour.ex | 124 ------------------ lib/diffo/provider/extension.ex | 6 +- .../transformers/transform_behaviour.ex | 12 +- 4 files changed, 6 insertions(+), 138 deletions(-) delete mode 100644 lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex diff --git a/AGENTS.md b/AGENTS.md index a0eb7fc..d99c454 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,7 +269,7 @@ Spark runs two separate pipelines during compilation, in this order: - A transformer that needs to expose baked state does not need a separate persister — call `Transformer.persist/3` inline and emit the module function via `Transformer.eval/3`. - Do not put a transformer in `persisters:` hoping `after?` declarations will order it relative to transformers — those declarations are silently ignored across pipeline boundaries. -**Current state:** `TransformBehaviour` is misregistered under `persisters:` — a known issue tracked for refactoring. New transformers go under `transformers:`. +New transformers go under `transformers:`. New persisters go under `persisters:`. ## Raising upstream bugs diff --git a/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex deleted file mode 100644 index 0064f9f..0000000 --- a/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex +++ /dev/null @@ -1,124 +0,0 @@ -# SPDX-FileCopyrightText: 2025 diffo contributors -# -# SPDX-License-Identifier: MIT - -defmodule Diffo.Provider.Instance.Extension.Transformers.TransformBehaviour do - @moduledoc "Generates build_before/1 and build_after/2, and injects build arguments into declared create actions" - use Spark.Dsl.Transformer - alias Spark.Dsl.Transformer - alias Diffo.Provider.Instance.Extension.ActionCreate - - @build_args [ - specified_by: :uuid, - features: {:array, :uuid}, - characteristics: {:array, :uuid} - ] - - @impl true - def transform(dsl_state) do - spec = Transformer.get_persisted(dsl_state, :specification, []) - - dsl_state = inject_create_arguments(dsl_state) - - {build_before_body, build_after_body} = - if spec[:id] do - before_body = - quote do - changeset - |> Diffo.Provider.Instance.Specification.set_specified_by_argument(specification()) - |> Diffo.Provider.Instance.Feature.set_features_argument(features()) - |> Diffo.Provider.Instance.Characteristic.set_characteristics_argument( - characteristics() - ) - |> Diffo.Provider.Instance.Party.validate_parties(parties()) - end - - after_body = - quote do - Diffo.Provider.Instance.ActionHelper.build_after(changeset, result) - end - - {before_body, after_body} - else - {quote(do: changeset), quote(do: {:ok, result})} - end - - {:ok, - Transformer.eval( - dsl_state, - [], - quote do - @doc false - def build_before(changeset), do: unquote(build_before_body) - - @doc false - def build_after(changeset, result), do: unquote(build_after_body) - - @doc false - def characteristic(name), do: Enum.find(characteristics(), &(&1.name == name)) - - @doc false - def feature(name), do: Enum.find(features(), &(&1.name == name)) - - @doc false - def feature_characteristic(feature_name, char_name) do - case feature(feature_name) do - nil -> nil - f -> Enum.find(f.characteristics, &(&1.name == char_name)) - end - end - - @doc false - def party(role), do: Enum.find(parties(), &(&1.role == role)) - - @doc false - def place(role), do: Enum.find(places(), &(&1.role == role)) - end - )} - end - - defp inject_create_arguments(dsl_state) do - action_create_declarations = - Transformer.get_entities(dsl_state, [:behaviour, :actions]) - |> Enum.filter(&is_struct(&1, ActionCreate)) - - Enum.reduce(action_create_declarations, dsl_state, fn %ActionCreate{name: action_name}, - dsl_state -> - action = - Transformer.get_entities(dsl_state, [:actions]) - |> Enum.find(&(is_struct(&1, Ash.Resource.Actions.Create) and &1.name == action_name)) - - if action do - existing = MapSet.new(action.arguments, & &1.name) - - new_args = - @build_args - |> Enum.reject(fn {name, _} -> MapSet.member?(existing, name) end) - |> Enum.map(fn {name, type} -> - %Ash.Resource.Actions.Argument{ - name: name, - type: type, - public?: false, - allow_nil?: true - } - end) - - updated = %{action | arguments: action.arguments ++ new_args} - - Transformer.replace_entity(dsl_state, [:actions], updated, fn entity -> - is_struct(entity, Ash.Resource.Actions.Create) and entity.name == action_name - end) - else - dsl_state - end - end) - end - - @impl true - def after?(Diffo.Provider.Instance.Extension.Persisters.PersistSpecification), do: true - def after?(Diffo.Provider.Instance.Extension.Persisters.PersistCharacteristics), do: true - def after?(Diffo.Provider.Instance.Extension.Persisters.PersistFeatures), do: true - def after?(Diffo.Provider.Instance.Extension.Persisters.PersistParties), do: true - def after?(Diffo.Provider.Instance.Extension.Persisters.PersistPlaces), do: true - def after?(_), do: false -end diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index fcedbe4..0f61d00 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -585,7 +585,8 @@ defmodule Diffo.Provider.Extension do use Spark.Dsl.Extension, sections: [@provider], transformers: [ - Diffo.Provider.Extension.Transformers.TransformRelationships + Diffo.Provider.Extension.Transformers.TransformRelationships, + Diffo.Provider.Extension.Transformers.TransformBehaviour ], persisters: [ Diffo.Provider.Extension.Persisters.PersistSpecification, @@ -594,8 +595,7 @@ defmodule Diffo.Provider.Extension do Diffo.Provider.Extension.Persisters.PersistPools, Diffo.Provider.Extension.Persisters.PersistParties, Diffo.Provider.Extension.Persisters.PersistPlaces, - Diffo.Provider.Extension.Persisters.PersistInstances, - Diffo.Provider.Extension.Transformers.TransformBehaviour + Diffo.Provider.Extension.Persisters.PersistInstances ], verifiers: [ Diffo.Provider.Extension.Verifiers.VerifySpecification, diff --git a/lib/diffo/provider/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/extension/transformers/transform_behaviour.ex index 7bc70ba..3e41769 100644 --- a/lib/diffo/provider/extension/transformers/transform_behaviour.ex +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -16,12 +16,12 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do @impl true def transform(dsl_state) do - spec = Transformer.get_persisted(dsl_state, :specification, []) + spec_id = Transformer.get_option(dsl_state, [:provider, :specification], :id) dsl_state = inject_create_arguments(dsl_state) {build_before_body, build_after_body} = - if spec[:id] do + if spec_id do before_body = quote do changeset @@ -128,12 +128,4 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do end) end - @impl true - def after?(Diffo.Provider.Extension.Persisters.PersistSpecification), do: true - def after?(Diffo.Provider.Extension.Persisters.PersistCharacteristics), do: true - def after?(Diffo.Provider.Extension.Persisters.PersistFeatures), do: true - def after?(Diffo.Provider.Extension.Persisters.PersistPools), do: true - def after?(Diffo.Provider.Extension.Persisters.PersistParties), do: true - def after?(Diffo.Provider.Extension.Persisters.PersistPlaces), do: true - def after?(_), do: false end From 1d726304dbe27a2a83f3b8cf4232bc8c59d18f42 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 04:52:06 +0930 Subject: [PATCH 13/23] resource lifecycle_status --- AGENTS.md | 1 + lib/diffo/provider.ex | 1 + lib/diffo/provider/assigner/assigner.ex | 17 ++++++++++++++--- lib/diffo/provider/components/base_instance.ex | 16 ++++++++++++++++ lib/diffo/provider/components/instance/util.ex | 5 ++++- test/provider/extension/assigner_test.exs | 12 ++++++++---- test/support/servo.ex | 1 + 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d99c454..0c48a21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -299,6 +299,7 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i - Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`. - Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically. - Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here. +- Calling `Assigner.assign/3` on an instance that is not in the correct lifecycle state — the assigner enforces: resource instances must have `resource_state: :operating`; service instances must have `service_state: :active` or `:inactive`. Lifecycle state transitions are an internal domain concern managed by the provider; assignment actions are external-facing. Future: consumer reads may filter out non-`:operating` resources entirely. - Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship` — `AssignedToRelationship` no longer exists; use `pools do / pool :name, :thing / end` instead. - Querying `Diffo.Provider.Relationship` for assignment records — assignments are stored as `Diffo.Provider.DefinedSimpleRelationship`; access them via `instance.assignments`. - Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly. diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index 898749b..6bf31ea 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -47,6 +47,7 @@ defmodule Diffo.Provider do define :suspend_service, action: :suspend define :terminate_service, action: :terminate define :status_service, action: :status + define :lifecycle_resource, action: :lifecycle define :respecify_instance, action: :specify define :relate_instance_features, action: :relate_features define :unrelate_instance_features, action: :unrelate_features diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 358b7a7..63fe270 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -19,9 +19,11 @@ defmodule Diffo.Provider.Assigner do """ def assign(result, changeset, pool_name) when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(pool_name) do - case result.__struct__.pool(pool_name) do - nil -> {:error, "pool #{pool_name} not declared on #{result.__struct__}"} - pool -> assign(result, changeset, pool_name, pool.thing) + with :ok <- check_lifecycle(result) do + case result.__struct__.pool(pool_name) do + nil -> {:error, "pool #{pool_name} not declared on #{result.__struct__}"} + pool -> assign(result, changeset, pool_name, pool.thing) + end end end @@ -58,6 +60,15 @@ defmodule Diffo.Provider.Assigner do end end + defp check_lifecycle(%{type: :resource, resource_state: state}) when state != :operating, + do: {:error, "cannot assign: resource lifecycle state is #{inspect(state)}, must be :operating"} + + defp check_lifecycle(%{type: :service, service_state: state}) + when state not in [:active, :inactive], + do: {:error, "cannot assign: service state is #{inspect(state)}, must be :active or :inactive"} + + defp check_lifecycle(_), do: :ok + defp create_assignment(result, pool, thing, value, assignee_id) when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 1ee5655..b58f8bc 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -261,6 +261,7 @@ defmodule Diffo.Provider.BaseInstance do :endOperatingDate, :state, :operatingStatus, + :lifecycleState, :administrativeState, :operationalState, :resourceStatus, @@ -366,6 +367,14 @@ defmodule Diffo.Provider.BaseInstance do constraints one_of: Diffo.Provider.Service.service_operating_statuses() end + attribute :resource_state, :atom do + description "the TMF lifecycleState for resource instances: planning, installing, operating, or retiring" + allow_nil? true + public? true + default nil + constraints one_of: [:planning, :installing, :operating, :retiring] + end + create_timestamp :created_at update_timestamp :updated_at @@ -605,6 +614,13 @@ defmodule Diffo.Provider.BaseInstance do accept [:service_operating_status] end + update :lifecycle do + description "sets the TMF lifecycleState for a resource instance" + require_atomic? false + validate attribute_equals(:type, :resource) + accept [:resource_state] + end + update :relate_features do description "relates features to the instance" argument :features, {:array, :uuid} diff --git a/lib/diffo/provider/components/instance/util.ex b/lib/diffo/provider/components/instance/util.ex index 9460c6a..b555255 100644 --- a/lib/diffo/provider/components/instance/util.ex +++ b/lib/diffo/provider/components/instance/util.ex @@ -64,7 +64,10 @@ defmodule Diffo.Provider.Instance.Util do |> Diffo.Util.set(:operatingStatus, record.service_operating_status) :resource -> - result + case record.resource_state do + nil -> result + state -> Diffo.Util.set(result, :lifecycleState, state) + end # |> Diffo.Util.ensure_not_nil(:administrativeState, record.resource_administrative_state) # |> Diffo.Util.ensure_not_nil(:operationalState, record.resource_operational_state) # |> Diffo.Util.ensure_not_nil(:resourceStatus, record.resource_status) diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index 4c9e652..a0b2f0f 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -98,6 +98,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) {:ok, card} = Servo.assign_port(card, %{ @@ -109,7 +110,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"lifecycleState\":\"operating\",\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]}]}) end test "auto assign two ports to same resource" do @@ -123,6 +124,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) {:ok, card} = Servo.assign_port(card, %{ @@ -139,7 +141,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]},{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":2}]}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"lifecycleState\":\"operating\",\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]},{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":2}]}]}) end test "specific assignment rejects duplicate request" do @@ -153,6 +155,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) {:ok, card} = Servo.assign_port(card, %{ @@ -169,7 +172,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":5}]}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"lifecycleState\":\"operating\",\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":5}]}]}) end test "unassign an auto-assigned port from a resource" do @@ -183,6 +186,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) {:ok, card} = Servo.assign_port(card, %{ @@ -207,7 +211,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"}}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"lifecycleState\":\"operating\"}) end end end diff --git a/test/support/servo.ex b/test/support/servo.ex index 95a222f..d055fff 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -41,6 +41,7 @@ defmodule Diffo.Test.Servo do define :define_card, action: :define define :relate_card, action: :relate define :assign_port, action: :assign_port + define :lifecycle_card, action: :lifecycle end resource Broadband do From c5dd9901f2dffb9850153fd531c1bda51f2d8ea5 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 18:43:17 +0930 Subject: [PATCH 14/23] inheritied party and place via instance dsl --- AGENTS.md | 3 +- lib/diffo/provider/assigner/assigner.ex | 8 +- lib/diffo/provider/assigner/assignment.ex | 2 + .../components/assignment_relationship.ex | 23 +++- .../calculations/inherited_party.ex | 37 ++++++ .../calculations/inherited_place.ex | 37 ++++++ lib/diffo/provider/components/relationship.ex | 4 +- lib/diffo/provider/extension.ex | 66 +++++++++- .../extension/inherited_party_declaration.ex | 12 ++ .../extension/inherited_place_declaration.ex | 12 ++ .../transformers/transform_inherited_refs.ex | 63 +++++++++ mix.lock | 2 +- .../extension/inherited_refs_test.exs | 122 ++++++++++++++++++ .../resource/instance/access_service.ex | 50 +++++++ test/support/servo.ex | 6 + 15 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 lib/diffo/provider/components/calculations/inherited_party.ex create mode 100644 lib/diffo/provider/components/calculations/inherited_place.ex create mode 100644 lib/diffo/provider/extension/inherited_party_declaration.ex create mode 100644 lib/diffo/provider/extension/inherited_place_declaration.ex create mode 100644 lib/diffo/provider/extension/transformers/transform_inherited_refs.ex create mode 100644 test/provider/extension/inherited_refs_test.exs create mode 100644 test/support/resource/instance/access_service.ex diff --git a/AGENTS.md b/AGENTS.md index 0c48a21..7faedc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ lib/diffo/provider/ base_characteristic.ex # Ash Fragment for typed characteristic resources base_relationship.ex # Ash Fragment for shared Relationship structure defined_simple_relationship.ex # DefinedSimpleRelationship — relationship with one optional embedded characteristic, frozen at creation - assignment_relationship.ex # AssignmentRelationship — pool assignment relationship with top-level pool/thing/value scalar attributes + assignment_relationship.ex # AssignmentRelationship — pool assignment relationship with top-level pool/thing/value/alias scalar attributes relationship.ex # Relationship — mutable TMF service/resource relationship with graph Characteristic nodes calculations/ characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields @@ -300,6 +300,7 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i - Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically. - Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here. - Calling `Assigner.assign/3` on an instance that is not in the correct lifecycle state — the assigner enforces: resource instances must have `resource_state: :operating`; service instances must have `service_state: :active` or `:inactive`. Lifecycle state transitions are an internal domain concern managed by the provider; assignment actions are external-facing. Future: consumer reads may filter out non-`:operating` resources entirely. +- Wondering why `Relationship` and `AssignmentRelationship` both have an `alias` attribute with a `[:source_id, :alias]` / `[:target_id, :alias]` identity — alias is a "baby name" given to a relationship slot before (or when) the target is bound. Its full purpose becomes clear alongside the first-order expectation system (see issue #122): the expectation declares the alias for a slot it expects to be filled, and the actual relationship carries the same alias so the two can be matched. Without expectations in place, aliases look like optional metadata; with them, they are the join key between intent and fulfilment. - Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship` — `AssignedToRelationship` no longer exists; use `pools do / pool :name, :thing / end` instead. - Querying `Diffo.Provider.Relationship` for assignment records — assignments are stored as `Diffo.Provider.DefinedSimpleRelationship`; access them via `instance.assignments`. - Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly. diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 63fe270..92ca66c 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -35,6 +35,7 @@ defmodule Diffo.Provider.Assigner do is_atom(thing) do assignment = Map.get(changeset.arguments, :assignment, %{}) assignee_id = Map.get(assignment, :assignee_id) + alias_name = Map.get(assignment, :alias) case assignee_id do nil -> @@ -44,12 +45,12 @@ defmodule Diffo.Provider.Assigner do case Map.get(assignment, :operation, :auto_assign) do :auto_assign -> with {:ok, value} <- next(result, pool, thing) do - create_assignment(result, pool, thing, value, assignee_id) + create_assignment(result, pool, thing, value, assignee_id, alias_name) end :assign -> if assignable?(result, pool, thing, assignment.id) do - create_assignment(result, pool, thing, assignment.id, assignee_id) + create_assignment(result, pool, thing, assignment.id, assignee_id, alias_name) else {:error, "#{thing} #{assignment.id} is not assignable"} end @@ -69,11 +70,12 @@ defmodule Diffo.Provider.Assigner do defp check_lifecycle(_), do: :ok - defp create_assignment(result, pool, thing, value, assignee_id) + defp create_assignment(result, pool, thing, value, assignee_id, alias_name) when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do with {:ok, _} <- Diffo.Provider.create_assignment_relationship(%{ + alias: alias_name, pool: pool, thing: thing, value: value, diff --git a/lib/diffo/provider/assigner/assignment.ex b/lib/diffo/provider/assigner/assignment.ex index a2cb1d1..bb175c4 100644 --- a/lib/diffo/provider/assigner/assignment.ex +++ b/lib/diffo/provider/assigner/assignment.ex @@ -19,6 +19,8 @@ defmodule Diffo.Provider.Assignment do constraints: [min: 0], description: "the id of the assigned thing" + field :alias, :atom, description: "the consumer's stable name for this assignment slot" + field :assignable_type, :string, description: "the type of the assigned thing" field :assignee_id, :uuid, description: "the id of the assignee Ash resource" diff --git a/lib/diffo/provider/components/assignment_relationship.ex b/lib/diffo/provider/components/assignment_relationship.ex index 8302c80..abbadb2 100644 --- a/lib/diffo/provider/components/assignment_relationship.ex +++ b/lib/diffo/provider/components/assignment_relationship.ex @@ -47,11 +47,18 @@ defmodule Diffo.Provider.AssignmentRelationship do list_name = Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(record.target_type) - characteristic = %{name: record.thing, value: record.value} + characteristics = + [%{name: record.thing, value: record.value}] + |> then(fn chars -> + case record.alias do + nil -> chars + a -> chars ++ [%{name: :alias, value: a}] + end + end) result |> Diffo.Util.set(record.target_type, reference) - |> Diffo.Util.set(list_name, [characteristic]) + |> Diffo.Util.set(list_name, characteristics) end order [:type, :resource, :service, :resourceRelationshipCharacteristic, @@ -61,7 +68,7 @@ defmodule Diffo.Provider.AssignmentRelationship do actions do create :create do description "creates a pool assignment relationship between a source and target instance" - accept [:pool, :thing, :value] + accept [:alias, :pool, :thing, :value] argument :source_id, :uuid argument :target_id, :string @@ -74,6 +81,12 @@ defmodule Diffo.Provider.AssignmentRelationship do end attributes do + attribute :alias, :atom do + description "the alias of this assignment, used by the consuming instance to name the slot" + allow_nil? true + public? true + end + attribute :pool, :atom do description "the pool name this assignment belongs to (e.g. :ports)" allow_nil? false @@ -98,6 +111,10 @@ defmodule Diffo.Provider.AssignmentRelationship do identity :unique_assignment, [:source_id, :pool, :thing, :value] do pre_check? true end + + identity :unique_alias, [:target_id, :alias] do + pre_check? true + end end preparations do diff --git a/lib/diffo/provider/components/calculations/inherited_party.ex b/lib/diffo/provider/components/calculations/inherited_party.ex new file mode 100644 index 0000000..ab58489 --- /dev/null +++ b/lib/diffo/provider/components/calculations/inherited_party.ex @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.InheritedParty do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + via = opts[:via] + source_role = opts[:source_role] + + Enum.map(records, fn record -> + final_ids = + Enum.reduce(via, [record.id], fn alias_step, ids -> + Enum.flat_map(ids, fn id -> + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id, alias: alias_step) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end) + end) + + Enum.flat_map(final_ids, fn id -> + Diffo.Provider.PartyRef + |> Ash.Query.filter_input(instance_id: id, role: source_role) + |> Ash.Query.load(:party) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.party) + end) + end) + end +end diff --git a/lib/diffo/provider/components/calculations/inherited_place.ex b/lib/diffo/provider/components/calculations/inherited_place.ex new file mode 100644 index 0000000..cc252bd --- /dev/null +++ b/lib/diffo/provider/components/calculations/inherited_place.ex @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.InheritedPlace do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + via = opts[:via] + source_role = opts[:source_role] + + Enum.map(records, fn record -> + final_ids = + Enum.reduce(via, [record.id], fn alias_step, ids -> + Enum.flat_map(ids, fn id -> + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id, alias: alias_step) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end) + end) + + Enum.flat_map(final_ids, fn id -> + Diffo.Provider.PlaceRef + |> Ash.Query.filter_input(instance_id: id, role: source_role) + |> Ash.Query.load(:place) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.place) + end) + end) + end +end diff --git a/lib/diffo/provider/components/relationship.ex b/lib/diffo/provider/components/relationship.ex index 474c8d5..d0e784b 100644 --- a/lib/diffo/provider/components/relationship.ex +++ b/lib/diffo/provider/components/relationship.ex @@ -130,7 +130,9 @@ defmodule Diffo.Provider.Relationship do end identities do - identity :unique_source_and_target, [:source_id, :target_id] + identity :unique_source_alias, [:source_id, :alias] do + pre_check? true + end end preparations do diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index 0f61d00..aee67ad 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -93,6 +93,8 @@ defmodule Diffo.Provider.Extension do Characteristic, Feature, InstanceRole, + InheritedPartyDeclaration, + InheritedPlaceDeclaration, PartyDeclaration, PartyRole, PlaceDeclaration, @@ -292,10 +294,35 @@ defmodule Diffo.Provider.Extension do ] } + @inherited_party_entity %Spark.Dsl.Entity{ + name: :inherited_party, + describe: + "Declares a party derived by traversing the assignment graph — generates a calculation, no PartyRef node created", + target: InheritedPartyDeclaration, + args: [:role], + schema: [ + role: [ + type: :atom, + doc: "The role name — also the default alias to follow on AssignmentRelationship.", + required: true + ], + via: [ + type: {:list, :atom}, + doc: + "Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level." + ], + source_role: [ + type: :atom, + doc: "The PartyRef role to pick up on the arrived-at instance.", + required: true + ] + ] + } + @parties %Spark.Dsl.Section{ name: :parties, describe: - "Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds", + "Party roles on this resource — `party`/`parties`/`party_ref`/`inherited_party` for Instance kinds; `role` for Party and Place kinds", examples: [ """ # Instance @@ -303,6 +330,7 @@ defmodule Diffo.Provider.Extension do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo parties :technicians, MyApp.Technician, constraints: [min: 1] + inherited_party :customer, source_role: :owner end # Party or Place @@ -311,7 +339,7 @@ defmodule Diffo.Provider.Extension do end """ ], - entities: [@party_entity, @parties_entity, @party_ref_entity, @party_role_entity] + entities: [@party_entity, @parties_entity, @party_ref_entity, @party_role_entity, @inherited_party_entity] } # ── places ───────────────────────────────────────────────────────────────── @@ -368,16 +396,43 @@ defmodule Diffo.Provider.Extension do ] } + @inherited_place_entity %Spark.Dsl.Entity{ + name: :inherited_place, + describe: + "Declares a place derived by traversing the assignment graph — generates a calculation, no PlaceRef node created", + target: InheritedPlaceDeclaration, + args: [:role], + schema: [ + role: [ + type: :atom, + doc: "The role name — also the default alias to follow on AssignmentRelationship.", + required: true + ], + via: [ + type: {:list, :atom}, + doc: + "Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level." + ], + source_role: [ + type: :atom, + doc: "The PlaceRef role to pick up on the arrived-at instance.", + required: true + ] + ] + } + @places %Spark.Dsl.Section{ name: :places, describe: - "Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds", + "Place roles on this resource — `place`/`places`/`place_ref`/`inherited_place` for Instance kinds; `role` for Party and Place kinds", examples: [ """ # Instance places do place :installation_site, MyApp.GeographicSite place_ref :billing_address, MyApp.GeographicAddress + inherited_place :a_end, source_role: :location + inherited_place :poi, via: [:cvc_link, :nni_link], source_role: :poi end # Party or Place @@ -386,7 +441,7 @@ defmodule Diffo.Provider.Extension do end """ ], - entities: [@place_entity, @places_entity, @place_ref_entity, @place_role_entity] + entities: [@place_entity, @places_entity, @place_ref_entity, @place_role_entity, @inherited_place_entity] } # ── instances ────────────────────────────────────────────────────────────── @@ -586,7 +641,8 @@ defmodule Diffo.Provider.Extension do sections: [@provider], transformers: [ Diffo.Provider.Extension.Transformers.TransformRelationships, - Diffo.Provider.Extension.Transformers.TransformBehaviour + Diffo.Provider.Extension.Transformers.TransformBehaviour, + Diffo.Provider.Extension.Transformers.TransformInheritedRefs ], persisters: [ Diffo.Provider.Extension.Persisters.PersistSpecification, diff --git a/lib/diffo/provider/extension/inherited_party_declaration.ex b/lib/diffo/provider/extension/inherited_party_declaration.ex new file mode 100644 index 0000000..d02437f --- /dev/null +++ b/lib/diffo/provider/extension/inherited_party_declaration.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InheritedPartyDeclaration do + @moduledoc "DSL entity declaring an inherited party role — derived by traversing the assignment graph" + defstruct [:role, :via, :source_role, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/inherited_place_declaration.ex b/lib/diffo/provider/extension/inherited_place_declaration.ex new file mode 100644 index 0000000..7f55dc9 --- /dev/null +++ b/lib/diffo/provider/extension/inherited_place_declaration.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InheritedPlaceDeclaration do + @moduledoc "DSL entity declaring an inherited place role — derived by traversing the assignment graph" + defstruct [:role, :via, :source_role, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex b/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex new file mode 100644 index 0000000..8ef4c32 --- /dev/null +++ b/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Transformers.TransformInheritedRefs do + @moduledoc "Injects Ash calculations for inherited_place and inherited_party declarations" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + alias Diffo.Provider.Extension.InheritedPlaceDeclaration + alias Diffo.Provider.Extension.InheritedPartyDeclaration + + @impl true + def transform(dsl_state) do + places = Transformer.get_entities(dsl_state, [:provider, :places]) + parties = Transformer.get_entities(dsl_state, [:provider, :parties]) + + dsl_state = + places + |> Enum.filter(&is_struct(&1, InheritedPlaceDeclaration)) + |> Enum.reduce(dsl_state, &inject_place_calculation(&2, &1)) + + dsl_state = + parties + |> Enum.filter(&is_struct(&1, InheritedPartyDeclaration)) + |> Enum.reduce(dsl_state, &inject_party_calculation(&2, &1)) + + {:ok, dsl_state} + end + + defp inject_place_calculation(dsl_state, %InheritedPlaceDeclaration{} = decl) do + via = decl.via || [decl.role] + + calc = %Ash.Resource.Calculation{ + name: decl.role, + type: {:array, :map}, + calculation: {Diffo.Provider.Calculations.InheritedPlace, [via: via, source_role: decl.source_role]}, + description: "Inherited place via assignment alias traversal", + arguments: [], + public?: true, + allow_nil?: true, + constraints: [] + } + + Transformer.add_entity(dsl_state, [:calculations], calc) + end + + defp inject_party_calculation(dsl_state, %InheritedPartyDeclaration{} = decl) do + via = decl.via || [decl.role] + + calc = %Ash.Resource.Calculation{ + name: decl.role, + type: {:array, :map}, + calculation: {Diffo.Provider.Calculations.InheritedParty, [via: via, source_role: decl.source_role]}, + description: "Inherited party via assignment alias traversal", + arguments: [], + public?: true, + allow_nil?: true, + constraints: [] + } + + Transformer.add_entity(dsl_state, [:calculations], calc) + end +end diff --git a/mix.lock b/mix.lock index 44d757b..4d5dc6e 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "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"}, - "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, + "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"}, diff --git a/test/provider/extension/inherited_refs_test.exs b/test/provider/extension/inherited_refs_test.exs new file mode 100644 index 0000000..d99ca41 --- /dev/null +++ b/test/provider/extension/inherited_refs_test.exs @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InheritedRefsTest do + @moduledoc false + use ExUnit.Case, async: true + @moduletag :domain_extended + + alias Diffo.Provider.Assignment + alias Diffo.Test.Servo + + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + describe "inherited_place — single-hop via alias" do + test "service inherits place from assigned card via :primary alias" do + place = + Diffo.Provider.create_place!(%{ + id: "LOC-TEST-INHERITED-001", + name: "Test Exchange", + type: :GeographicSite + }) + + {:ok, card} = Servo.build_card(%{}) + + updates = [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] + + {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) + + Diffo.Provider.create_place_ref!(%{ + instance_id: card.id, + role: :location, + place_id: place.id + }) + + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card} = + Servo.assign_port(card, %{ + assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :primary} + }) + + service = Ash.load!(service, [:primary], domain: Servo) + + assert length(service.primary) == 1 + assert hd(service.primary).id == place.id + end + + test "service with no assignment returns empty list for inherited place" do + {:ok, service} = Servo.build_access_service(%{}) + + service = Ash.load!(service, [:primary], domain: Servo) + + assert service.primary == [] + end + + test "service inherits only the place from the aliased assignment, not from unaliased ones" do + place_a = + Diffo.Provider.create_place!(%{ + id: "LOC-TEST-INHERITED-002", + name: "Exchange A", + type: :GeographicSite + }) + + place_b = + Diffo.Provider.create_place!(%{ + id: "LOC-TEST-INHERITED-003", + name: "Exchange B", + type: :GeographicSite + }) + + {:ok, card_a} = Servo.build_card(%{}) + {:ok, card_b} = Servo.build_card(%{}) + + updates = [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] + + {:ok, card_a} = Servo.define_card(card_a, %{characteristic_value_updates: updates}) + {:ok, card_a} = Servo.lifecycle_card(card_a, %{resource_state: :operating}) + {:ok, card_b} = Servo.define_card(card_b, %{characteristic_value_updates: updates}) + {:ok, card_b} = Servo.lifecycle_card(card_b, %{resource_state: :operating}) + + Diffo.Provider.create_place_ref!(%{ + instance_id: card_a.id, + role: :location, + place_id: place_a.id + }) + + Diffo.Provider.create_place_ref!(%{ + instance_id: card_b.id, + role: :location, + place_id: place_b.id + }) + + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card_a} = + Servo.assign_port(card_a, %{ + assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :primary} + }) + + {:ok, _card_b} = + Servo.assign_port(card_b, %{ + assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :secondary} + }) + + service = Ash.load!(service, [:primary], domain: Servo) + + assert length(service.primary) == 1 + assert hd(service.primary).id == place_a.id + end + end +end diff --git a/test/support/resource/instance/access_service.ex b/test/support/resource/instance/access_service.ex new file mode 100644 index 0000000..9f102e5 --- /dev/null +++ b/test/support/resource/instance/access_service.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Instance.AccessService do + @moduledoc """ + Minimal test service instance that declares an inherited_place. + Used by inherited_refs_test.exs to verify assignment alias traversal. + """ + alias Diffo.Provider.BaseInstance + alias Diffo.Test.Servo + + use Ash.Resource, + fragments: [BaseInstance], + domain: Servo + + resource do + description "A test access service with an inherited place via assignment alias" + plural_name :access_services + end + + provider do + specification do + id "c4e7a2b1-3d5f-4a6b-8c9d-0e1f2a3b4c5d" + name "accessService" + type :serviceSpecification + description "A test access service instance" + category "Access" + end + + places do + inherited_place :primary, source_role: :location + end + + behaviour do + actions do + create :build + end + end + end + + actions do + create :build do + accept [:id, :name, :type] + change set_attribute(:type, :service) + change load [:href] + upsert? false + end + end +end diff --git a/test/support/servo.ex b/test/support/servo.ex index d055fff..78b58ac 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -17,6 +17,7 @@ defmodule Diffo.Test.Servo do alias Diffo.Test.Instance.CardInstance alias Diffo.Test.Instance.Broadband alias Diffo.Test.Instance.BroadbandV2 + alias Diffo.Test.Instance.AccessService alias Diffo.Test.Characteristic.ShelfCharacteristic alias Diffo.Test.Characteristic.CardCharacteristic alias Diffo.Test.Characteristic.DeploymentClass @@ -54,6 +55,11 @@ defmodule Diffo.Test.Servo do define :get_broadband_v2_by_id, action: :read, get_by: :id end + resource AccessService do + define :build_access_service, action: :build + define :get_access_service_by_id, action: :read, get_by: :id + end + resource ShelfCharacteristic resource CardCharacteristic resource DeploymentClass From ab207fc2b1cf27f58930656eceadeacab8ff3351 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 19:59:26 +0930 Subject: [PATCH 15/23] spark cheatsheets --- .../dsls/DSL-Diffo.Provider.Extension.md | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md index 4df32ff..90ab0c9 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -103,11 +103,13 @@ Provider DSL — structure, roles, and behaviour for this resource kind * parties * party_ref * role + * inherited_party * [places](#provider-places) * place * places * place_ref * role + * inherited_place * [instances](#provider-instances) * role * instance_ref @@ -330,13 +332,14 @@ Declares an assignable pool — a named range of values for auto-assignment ### provider.parties -Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds +Party roles on this resource — `party`/`parties`/`party_ref`/`inherited_party` for Instance kinds; `role` for Party and Place kinds ### Nested DSLs * [party](#provider-parties-party) * [parties](#provider-parties-parties) * [party_ref](#provider-parties-party_ref) * [role](#provider-parties-role) + * [inherited_party](#provider-parties-inherited_party) ### Examples @@ -346,6 +349,7 @@ parties do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo parties :technicians, MyApp.Technician, constraints: [min: 1] + inherited_party :customer, source_role: :owner end # Party or Place @@ -483,15 +487,48 @@ Declares a role this Party or Place kind plays with respect to other Parties Target: `Diffo.Provider.Extension.PartyRole` +### provider.parties.inherited_party +```elixir +inherited_party role +``` + + +Declares a party derived by traversing the assignment graph — generates a calculation, no PartyRef node created + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-inherited_party-role){: #provider-parties-inherited_party-role .spark-required} | `atom` | | The role name — also the default alias to follow on AssignmentRelationship. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`source_role`](#provider-parties-inherited_party-source_role){: #provider-parties-inherited_party-source_role .spark-required} | `atom` | | The PartyRef role to pick up on the arrived-at instance. | +| [`via`](#provider-parties-inherited_party-via){: #provider-parties-inherited_party-via } | `list(atom)` | | Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InheritedPartyDeclaration` + ### provider.places -Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds +Place roles on this resource — `place`/`places`/`place_ref`/`inherited_place` for Instance kinds; `role` for Party and Place kinds ### Nested DSLs * [place](#provider-places-place) * [places](#provider-places-places) * [place_ref](#provider-places-place_ref) * [role](#provider-places-role) + * [inherited_place](#provider-places-inherited_place) ### Examples @@ -500,6 +537,8 @@ Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds places do place :installation_site, MyApp.GeographicSite place_ref :billing_address, MyApp.GeographicAddress + inherited_place :a_end, source_role: :location + inherited_place :poi, via: [:cvc_link, :nni_link], source_role: :poi end # Party or Place @@ -637,6 +676,38 @@ Declares a role this Party or Place kind plays with respect to Places Target: `Diffo.Provider.Extension.PlaceRole` +### provider.places.inherited_place +```elixir +inherited_place role +``` + + +Declares a place derived by traversing the assignment graph — generates a calculation, no PlaceRef node created + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-inherited_place-role){: #provider-places-inherited_place-role .spark-required} | `atom` | | The role name — also the default alias to follow on AssignmentRelationship. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`source_role`](#provider-places-inherited_place-source_role){: #provider-places-inherited_place-source_role .spark-required} | `atom` | | The PlaceRef role to pick up on the arrived-at instance. | +| [`via`](#provider-places-inherited_place-via){: #provider-places-inherited_place-via } | `list(atom)` | | Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InheritedPlaceDeclaration` + ### provider.instances Declares the roles this Party or Place kind plays with respect to Instances From b4f99bbeeb23e983744c483f57fc4dfb1c2230c5 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 20:09:56 +0930 Subject: [PATCH 16/23] formating --- .formatter.exs | 4 ++++ lib/diffo/provider/assigner/assigner.ex | 7 +++++-- .../components/assignment_relationship.ex | 13 ++++++++++--- .../instance/extension/characteristic.ex | 6 ++++-- .../components/instance/extension/feature.ex | 3 ++- .../instance/extension/relationship.ex | 12 ++++++------ lib/diffo/provider/components/instance/util.ex | 1 + lib/diffo/provider/extension.ex | 16 ++++++++++++++-- .../transformers/transform_behaviour.ex | 1 - .../transformers/transform_inherited_refs.ex | 6 ++++-- .../validate_relationship_permitted.ex | 9 +++++++-- .../provider/extension/inherited_refs_test.exs | 18 +++++++++++++++--- .../extension/relationship_dsl_test.exs | 1 - 13 files changed, 72 insertions(+), 25 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index 35bd347..174948b 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -15,6 +15,10 @@ spark_locals_without_parens = [ feature: 1, feature: 2, id: 1, + inherited_party: 1, + inherited_party: 2, + inherited_place: 1, + inherited_place: 2, instance_ref: 2, is_enabled?: 1, major_version: 1, diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 92ca66c..fbc2229 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -62,11 +62,14 @@ defmodule Diffo.Provider.Assigner do end defp check_lifecycle(%{type: :resource, resource_state: state}) when state != :operating, - do: {:error, "cannot assign: resource lifecycle state is #{inspect(state)}, must be :operating"} + do: + {:error, "cannot assign: resource lifecycle state is #{inspect(state)}, must be :operating"} defp check_lifecycle(%{type: :service, service_state: state}) when state not in [:active, :inactive], - do: {:error, "cannot assign: service state is #{inspect(state)}, must be :active or :inactive"} + do: + {:error, + "cannot assign: service state is #{inspect(state)}, must be :active or :inactive"} defp check_lifecycle(_), do: :ok diff --git a/lib/diffo/provider/components/assignment_relationship.ex b/lib/diffo/provider/components/assignment_relationship.ex index abbadb2..a6ed4e1 100644 --- a/lib/diffo/provider/components/assignment_relationship.ex +++ b/lib/diffo/provider/components/assignment_relationship.ex @@ -45,7 +45,9 @@ defmodule Diffo.Provider.AssignmentRelationship do } list_name = - Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(record.target_type) + Diffo.Provider.Relationship.derive_relationship_characteristic_list_name( + record.target_type + ) characteristics = [%{name: record.thing, value: record.value}] @@ -61,8 +63,13 @@ defmodule Diffo.Provider.AssignmentRelationship do |> Diffo.Util.set(list_name, characteristics) end - order [:type, :resource, :service, :resourceRelationshipCharacteristic, - :serviceRelationshipCharacteristic] + order [ + :type, + :resource, + :service, + :resourceRelationshipCharacteristic, + :serviceRelationshipCharacteristic + ] end actions do diff --git a/lib/diffo/provider/components/instance/extension/characteristic.ex b/lib/diffo/provider/components/instance/extension/characteristic.ex index 9b003df..5180eb6 100644 --- a/lib/diffo/provider/components/instance/extension/characteristic.ex +++ b/lib/diffo/provider/components/instance/extension/characteristic.ex @@ -35,7 +35,8 @@ defmodule Diffo.Provider.Instance.Characteristic do end defp create_characteristics_from_declarations(declarations, type) do - Enum.reduce_while(declarations, {:ok, []}, fn %{name: name, value_type: value_type}, {:ok, acc} -> + Enum.reduce_while(declarations, {:ok, []}, fn %{name: name, value_type: value_type}, + {:ok, acc} -> try do attrs = case value_type do @@ -148,7 +149,8 @@ defmodule Diffo.Provider.Instance.Characteristic do end) characteristics = - Enum.reduce_while(characteristic_updates, {:ok, []}, fn {characteristic, value}, {:ok, acc} -> + Enum.reduce_while(characteristic_updates, {:ok, []}, fn {characteristic, value}, + {:ok, acc} -> case Provider.update_characteristic(characteristic, %{value: value}) do {:ok, characteristic} -> {:cont, {:ok, [characteristic | acc]}} diff --git a/lib/diffo/provider/components/instance/extension/feature.ex b/lib/diffo/provider/components/instance/extension/feature.ex index 4aa0da3..a67ca29 100644 --- a/lib/diffo/provider/components/instance/extension/feature.ex +++ b/lib/diffo/provider/components/instance/extension/feature.ex @@ -40,7 +40,8 @@ defmodule Diffo.Provider.Instance.Feature do {:ok, []}, fn %{name: name, is_enabled?: isEnabled, characteristics: characteristics}, {:ok, acc} -> characteristic_ids = - Enum.reduce_while(characteristics, {:ok, []}, fn %{name: name, value_type: value_type}, {:ok, ids} -> + Enum.reduce_while(characteristics, {:ok, []}, fn %{name: name, value_type: value_type}, + {:ok, ids} -> try do attrs = case value_type do diff --git a/lib/diffo/provider/components/instance/extension/relationship.ex b/lib/diffo/provider/components/instance/extension/relationship.ex index e637dd6..50c1264 100644 --- a/lib/diffo/provider/components/instance/extension/relationship.ex +++ b/lib/diffo/provider/components/instance/extension/relationship.ex @@ -27,12 +27,12 @@ defmodule Diffo.Provider.Instance.Relationship do _ -> Enum.reduce_while(relationships, :ok, fn %{ - id: id, - alias: name, - type: type, - direction: direction - }, - :ok -> + id: id, + alias: name, + type: type, + direction: direction + }, + :ok -> attrs = case direction do :reverse -> %{source_id: id, party_id: result.id, alias: name, type: type} diff --git a/lib/diffo/provider/components/instance/util.ex b/lib/diffo/provider/components/instance/util.ex index b555255..0e20761 100644 --- a/lib/diffo/provider/components/instance/util.ex +++ b/lib/diffo/provider/components/instance/util.ex @@ -68,6 +68,7 @@ defmodule Diffo.Provider.Instance.Util do nil -> result state -> Diffo.Util.set(result, :lifecycleState, state) end + # |> Diffo.Util.ensure_not_nil(:administrativeState, record.resource_administrative_state) # |> Diffo.Util.ensure_not_nil(:operationalState, record.resource_operational_state) # |> Diffo.Util.ensure_not_nil(:resourceStatus, record.resource_status) diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index aee67ad..3343df6 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -339,7 +339,13 @@ defmodule Diffo.Provider.Extension do end """ ], - entities: [@party_entity, @parties_entity, @party_ref_entity, @party_role_entity, @inherited_party_entity] + entities: [ + @party_entity, + @parties_entity, + @party_ref_entity, + @party_role_entity, + @inherited_party_entity + ] } # ── places ───────────────────────────────────────────────────────────────── @@ -441,7 +447,13 @@ defmodule Diffo.Provider.Extension do end """ ], - entities: [@place_entity, @places_entity, @place_ref_entity, @place_role_entity, @inherited_place_entity] + entities: [ + @place_entity, + @places_entity, + @place_ref_entity, + @place_role_entity, + @inherited_place_entity + ] } # ── instances ────────────────────────────────────────────────────────────── diff --git a/lib/diffo/provider/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/extension/transformers/transform_behaviour.ex index 3e41769..3ab8a5c 100644 --- a/lib/diffo/provider/extension/transformers/transform_behaviour.ex +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -127,5 +127,4 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do end end) end - end diff --git a/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex b/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex index 8ef4c32..f281927 100644 --- a/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex +++ b/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex @@ -33,7 +33,8 @@ defmodule Diffo.Provider.Extension.Transformers.TransformInheritedRefs do calc = %Ash.Resource.Calculation{ name: decl.role, type: {:array, :map}, - calculation: {Diffo.Provider.Calculations.InheritedPlace, [via: via, source_role: decl.source_role]}, + calculation: + {Diffo.Provider.Calculations.InheritedPlace, [via: via, source_role: decl.source_role]}, description: "Inherited place via assignment alias traversal", arguments: [], public?: true, @@ -50,7 +51,8 @@ defmodule Diffo.Provider.Extension.Transformers.TransformInheritedRefs do calc = %Ash.Resource.Calculation{ name: decl.role, type: {:array, :map}, - calculation: {Diffo.Provider.Calculations.InheritedParty, [via: via, source_role: decl.source_role]}, + calculation: + {Diffo.Provider.Calculations.InheritedParty, [via: via, source_role: decl.source_role]}, description: "Inherited party via assignment alias traversal", arguments: [], public?: true, diff --git a/lib/diffo/provider/validations/validate_relationship_permitted.ex b/lib/diffo/provider/validations/validate_relationship_permitted.ex index 3e7b18f..8601c27 100644 --- a/lib/diffo/provider/validations/validate_relationship_permitted.ex +++ b/lib/diffo/provider/validations/validate_relationship_permitted.ex @@ -50,7 +50,11 @@ defmodule Diffo.Provider.Validations.ValidateRelationshipPermitted do end :error -> - [[field: :relationships, message: "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) @@ -99,7 +103,8 @@ defmodule Diffo.Provider.Validations.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 diff --git a/test/provider/extension/inherited_refs_test.exs b/test/provider/extension/inherited_refs_test.exs index d99ca41..1e40da1 100644 --- a/test/provider/extension/inherited_refs_test.exs +++ b/test/provider/extension/inherited_refs_test.exs @@ -44,7 +44,11 @@ defmodule Diffo.Provider.Extension.InheritedRefsTest do {:ok, _card} = Servo.assign_port(card, %{ - assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :primary} + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } }) service = Ash.load!(service, [:primary], domain: Servo) @@ -105,12 +109,20 @@ defmodule Diffo.Provider.Extension.InheritedRefsTest do {:ok, _card_a} = Servo.assign_port(card_a, %{ - assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :primary} + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } }) {:ok, _card_b} = Servo.assign_port(card_b, %{ - assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :secondary} + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :secondary + } }) service = Ash.load!(service, [:primary], domain: Servo) diff --git a/test/provider/extension/relationship_dsl_test.exs b/test/provider/extension/relationship_dsl_test.exs index a3c4678..465181d 100644 --- a/test/provider/extension/relationship_dsl_test.exs +++ b/test/provider/extension/relationship_dsl_test.exs @@ -194,6 +194,5 @@ defmodule Diffo.Provider.Extension.RelationshipDslTest do assert {:error, error} = result assert Exception.message(error) =~ "not permitted as target" end - end end From acaac3199ed7a7079b2b7cffc20dd62584eb86a8 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 20:13:42 +0930 Subject: [PATCH 17/23] agent guidance --- AGENTS.md | 28 +++++-- .../dsls/DSL-Diffo.Provider.Extension.md | 75 ++++++++++++++++++- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7faedc3..58c0036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,6 +271,27 @@ Spark runs two separate pipelines during compilation, in this order: New transformers go under `transformers:`. New persisters go under `persisters:`. +## DSL shape changes + +Whenever you add, rename, or remove a DSL entity or section in `Diffo.Provider.Extension` +(or any Spark extension in this project), run this checklist in order: + +1. **Update `.formatter.exs`** — add new entity names to `spark_locals_without_parens` with + each supported arity. Without this, `mix format` will add unwanted parentheses to every + DSL call site. + +2. **Run `mix format`** — apply formatting across the codebase and verify the output looks + correct. Run `mix format --check-formatted` to confirm nothing was missed. + +3. **Run `mix spark.cheat_sheets`** — regenerates + `documentation/dsls/DSL-Diffo.Provider.Extension.md`. This file is Spark-generated; + never edit it by hand. Commit the regenerated file alongside the DSL change. + +4. **Run `mix test`** — confirm no regressions. + +Do not skip step 1 even for a "small" entity addition — the formatter will silently reformat +every call site in CI and produce noisy diffs in future PRs. + ## Raising upstream bugs When a bug is found in a dependency (e.g. AshNeo4j, Bolty), raise a GitHub issue on that @@ -309,11 +330,8 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i - Using module names (e.g. `MyApp.CardInstance`) as role values in `relationships do` — roles are atoms like `:provides`, not module references. - Forgetting that `relationships do` omitted means `:none` for both source and target — any update action with `argument :relationships, {:array, :struct}` will fail unless the resource declares permissions. - Thinking the Assigner requires `relationships do` permissions — it does not. The Assigner writes `DefinedSimpleRelationship` records directly via the Provider domain; `ValidateRelationshipPermitted` only runs on actions that carry `argument :relationships, {:array, :struct}`, which the Assigner's `assign_*` actions do not. -- Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated; - run `mix spark.cheat_sheets` to regenerate it. Whenever you add, rename, or remove a DSL - entity or section, also check `.formatter.exs` — new entity names must be added to - `spark_locals_without_parens` (with each arity) so the Spark formatter omits parentheses. - Run `mix format` afterward to verify. +- Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated. + See the **DSL shape changes** section above for the full checklist. - 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 diff --git a/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md index 4df32ff..90ab0c9 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -103,11 +103,13 @@ Provider DSL — structure, roles, and behaviour for this resource kind * parties * party_ref * role + * inherited_party * [places](#provider-places) * place * places * place_ref * role + * inherited_place * [instances](#provider-instances) * role * instance_ref @@ -330,13 +332,14 @@ Declares an assignable pool — a named range of values for auto-assignment ### provider.parties -Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds +Party roles on this resource — `party`/`parties`/`party_ref`/`inherited_party` for Instance kinds; `role` for Party and Place kinds ### Nested DSLs * [party](#provider-parties-party) * [parties](#provider-parties-parties) * [party_ref](#provider-parties-party_ref) * [role](#provider-parties-role) + * [inherited_party](#provider-parties-inherited_party) ### Examples @@ -346,6 +349,7 @@ parties do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo parties :technicians, MyApp.Technician, constraints: [min: 1] + inherited_party :customer, source_role: :owner end # Party or Place @@ -483,15 +487,48 @@ Declares a role this Party or Place kind plays with respect to other Parties Target: `Diffo.Provider.Extension.PartyRole` +### provider.parties.inherited_party +```elixir +inherited_party role +``` + + +Declares a party derived by traversing the assignment graph — generates a calculation, no PartyRef node created + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-inherited_party-role){: #provider-parties-inherited_party-role .spark-required} | `atom` | | The role name — also the default alias to follow on AssignmentRelationship. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`source_role`](#provider-parties-inherited_party-source_role){: #provider-parties-inherited_party-source_role .spark-required} | `atom` | | The PartyRef role to pick up on the arrived-at instance. | +| [`via`](#provider-parties-inherited_party-via){: #provider-parties-inherited_party-via } | `list(atom)` | | Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InheritedPartyDeclaration` + ### provider.places -Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds +Place roles on this resource — `place`/`places`/`place_ref`/`inherited_place` for Instance kinds; `role` for Party and Place kinds ### Nested DSLs * [place](#provider-places-place) * [places](#provider-places-places) * [place_ref](#provider-places-place_ref) * [role](#provider-places-role) + * [inherited_place](#provider-places-inherited_place) ### Examples @@ -500,6 +537,8 @@ Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds places do place :installation_site, MyApp.GeographicSite place_ref :billing_address, MyApp.GeographicAddress + inherited_place :a_end, source_role: :location + inherited_place :poi, via: [:cvc_link, :nni_link], source_role: :poi end # Party or Place @@ -637,6 +676,38 @@ Declares a role this Party or Place kind plays with respect to Places Target: `Diffo.Provider.Extension.PlaceRole` +### provider.places.inherited_place +```elixir +inherited_place role +``` + + +Declares a place derived by traversing the assignment graph — generates a calculation, no PlaceRef node created + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-inherited_place-role){: #provider-places-inherited_place-role .spark-required} | `atom` | | The role name — also the default alias to follow on AssignmentRelationship. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`source_role`](#provider-places-inherited_place-source_role){: #provider-places-inherited_place-source_role .spark-required} | `atom` | | The PlaceRef role to pick up on the arrived-at instance. | +| [`via`](#provider-places-inherited_place-via){: #provider-places-inherited_place-via } | `list(atom)` | | Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InheritedPlaceDeclaration` + ### provider.instances Declares the roles this Party or Place kind plays with respect to Instances From 063bb11102d78b987fbf56187dc10f58e605dde3 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 21:06:39 +0930 Subject: [PATCH 18/23] field via assigned relationship calculation --- .../field_via_assigned_relationship.ex | 46 +++++++ .../field_via_assigned_relationship_test.exs | 123 ++++++++++++++++++ .../resource/instance/access_service.ex | 8 ++ 3 files changed, 177 insertions(+) create mode 100644 lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex create mode 100644 test/provider/extension/field_via_assigned_relationship_test.exs diff --git a/lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex b/lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex new file mode 100644 index 0000000..6eab486 --- /dev/null +++ b/lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.FieldViaAssignedRelationship do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + via = opts[:via] + field = opts[:field] + + Enum.map(records, fn record -> + record.id + |> traverse(via) + |> Enum.flat_map(fn source_id -> + Diffo.Provider.Instance + |> Ash.Query.filter_input(id: source_id) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(&Map.get(&1, field)) + end) + end) + end + + defp traverse(id, nil) do + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end + + defp traverse(id, via) do + Enum.reduce(via, [id], fn alias_step, ids -> + Enum.flat_map(ids, fn i -> + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: i, alias: alias_step) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end) + end) + end +end diff --git a/test/provider/extension/field_via_assigned_relationship_test.exs b/test/provider/extension/field_via_assigned_relationship_test.exs new file mode 100644 index 0000000..907bfa2 --- /dev/null +++ b/test/provider/extension/field_via_assigned_relationship_test.exs @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.FieldViaAssignedRelationshipTest do + @moduledoc false + use ExUnit.Case, async: true + @moduletag :domain_extended + + alias Diffo.Provider.Assignment + alias Diffo.Test.Servo + + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + defp setup_card(name) do + updates = [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] + + {:ok, card} = Servo.build_card(%{name: name}) + {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) + card + end + + describe "FieldViaAssignedRelationship — aliased via" do + test "returns field from source instance reached via alias" do + card = setup_card("cvc-01") + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card} = + Servo.assign_port(card, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } + }) + + service = Ash.load!(service, [:assigner_name], domain: Servo) + + assert service.assigner_name == ["cvc-01"] + end + + test "returns empty list when no assignment exists" do + {:ok, service} = Servo.build_access_service(%{}) + + service = Ash.load!(service, [:assigner_name], domain: Servo) + + assert service.assigner_name == [] + end + + test "alias filters to only the matching source" do + card_a = setup_card("cvc-01") + card_b = setup_card("cvc-02") + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card_a} = + Servo.assign_port(card_a, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } + }) + + {:ok, _card_b} = + Servo.assign_port(card_b, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :secondary + } + }) + + service = Ash.load!(service, [:assigner_name], domain: Servo) + + assert service.assigner_name == ["cvc-01"] + end + end + + describe "FieldViaAssignedRelationship — unaliased (all assigners)" do + test "returns fields from all source instances regardless of alias" do + card_a = setup_card("cvc-01") + card_b = setup_card("cvc-02") + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card_a} = + Servo.assign_port(card_a, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } + }) + + {:ok, _card_b} = + Servo.assign_port(card_b, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :secondary + } + }) + + service = Ash.load!(service, [:assigner_names], domain: Servo) + + assert Enum.sort(service.assigner_names) == ["cvc-01", "cvc-02"] + end + + test "returns empty list when no assignments exist" do + {:ok, service} = Servo.build_access_service(%{}) + + service = Ash.load!(service, [:assigner_names], domain: Servo) + + assert service.assigner_names == [] + end + end +end diff --git a/test/support/resource/instance/access_service.ex b/test/support/resource/instance/access_service.ex index 9f102e5..defe2ab 100644 --- a/test/support/resource/instance/access_service.ex +++ b/test/support/resource/instance/access_service.ex @@ -39,6 +39,14 @@ defmodule Diffo.Test.Instance.AccessService do end end + calculations do + calculate :assigner_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAssignedRelationship, [via: [:primary], field: :name]} + + calculate :assigner_names, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAssignedRelationship, [field: :name]} + end + actions do create :build do accept [:id, :name, :type] From 35cddec2015f2dd0f0f575ed9e9923e6e11c39ea Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 21:51:13 +0930 Subject: [PATCH 19/23] field via aliased relationship calculation --- .../field_via_aliased_relationship.ex | 42 ++++++++ .../field_via_aliased_relationship_test.exs | 101 ++++++++++++++++++ .../resource/instance/shelf_instance.ex | 8 ++ 3 files changed, 151 insertions(+) create mode 100644 lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex create mode 100644 test/provider/extension/field_via_aliased_relationship_test.exs diff --git a/lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex b/lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex new file mode 100644 index 0000000..85f32d1 --- /dev/null +++ b/lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.FieldViaAliasedRelationship do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + alias_name = opts[:alias] + field = opts[:field] + + Enum.map(records, fn record -> + record.id + |> traverse(alias_name) + |> Enum.flat_map(fn target_id -> + Diffo.Provider.Instance + |> Ash.Query.filter_input(id: target_id) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(&Map.get(&1, field)) + end) + end) + end + + defp traverse(id, nil) do + Diffo.Provider.DefinedSimpleRelationship + |> Ash.Query.filter_input(source_id: id) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.target_id) + end + + defp traverse(id, alias_name) do + Diffo.Provider.DefinedSimpleRelationship + |> Ash.Query.filter_input(source_id: id, alias: alias_name) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.target_id) + end +end diff --git a/test/provider/extension/field_via_aliased_relationship_test.exs b/test/provider/extension/field_via_aliased_relationship_test.exs new file mode 100644 index 0000000..f58b723 --- /dev/null +++ b/test/provider/extension/field_via_aliased_relationship_test.exs @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.FieldViaAliasedRelationshipTest do + @moduledoc false + use ExUnit.Case, async: true + @moduletag :domain_extended + + alias Diffo.Test.Parties + alias Diffo.Test.Servo + + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + describe "FieldViaAliasedRelationship — aliased" do + test "returns field from target instance reached via alias" do + {:ok, shelf} = Parties.build_shelf_with_installer() + {:ok, card} = Servo.build_card(%{name: "target-card"}) + + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + alias: :link, + source_id: shelf.id, + target_id: card.id + }) + + shelf = Ash.load!(shelf, [:linked_target_name], domain: Servo) + + assert shelf.linked_target_name == ["target-card"] + end + + test "returns empty list when no matching relationship exists" do + {:ok, shelf} = Parties.build_shelf_with_installer() + + shelf = Ash.load!(shelf, [:linked_target_name], domain: Servo) + + assert shelf.linked_target_name == [] + end + + test "alias filters to only the matching target" do + {:ok, shelf} = Parties.build_shelf_with_installer() + {:ok, card_a} = Servo.build_card(%{name: "target-a"}) + {:ok, card_b} = Servo.build_card(%{name: "target-b"}) + + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + alias: :link, + source_id: shelf.id, + target_id: card_a.id + }) + + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + alias: :other, + source_id: shelf.id, + target_id: card_b.id + }) + + shelf = Ash.load!(shelf, [:linked_target_name], domain: Servo) + + assert shelf.linked_target_name == ["target-a"] + end + end + + describe "FieldViaAliasedRelationship — unaliased (all targets)" do + test "returns fields from all related target instances regardless of alias" do + {:ok, shelf} = Parties.build_shelf_with_installer() + {:ok, card_a} = Servo.build_card(%{name: "target-a"}) + {:ok, card_b} = Servo.build_card(%{name: "target-b"}) + + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + alias: :link, + source_id: shelf.id, + target_id: card_a.id + }) + + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + alias: :other, + source_id: shelf.id, + target_id: card_b.id + }) + + shelf = Ash.load!(shelf, [:all_linked_names], domain: Servo) + + assert Enum.sort(shelf.all_linked_names) == ["target-a", "target-b"] + end + + test "returns empty list when no relationships exist" do + {:ok, shelf} = Parties.build_shelf_with_installer() + + shelf = Ash.load!(shelf, [:all_linked_names], domain: Servo) + + assert shelf.all_linked_names == [] + end + end +end diff --git a/test/support/resource/instance/shelf_instance.ex b/test/support/resource/instance/shelf_instance.ex index fcfbef9..e9fc1f4 100644 --- a/test/support/resource/instance/shelf_instance.ex +++ b/test/support/resource/instance/shelf_instance.ex @@ -84,6 +84,14 @@ defmodule Diffo.Test.Instance.ShelfInstance do end end + calculations do + calculate :linked_target_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAliasedRelationship, [alias: :link, field: :name]} + + calculate :all_linked_names, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAliasedRelationship, [field: :name]} + end + actions do create :build do description "creates a new Shelf resource instance for build" From f2cedd9b62689f5a57679e946d215817c66080cf Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 22:39:01 +0930 Subject: [PATCH 20/23] field from assignment calculation --- .../calculations/field_from_assignment.ex | 35 +++++ .../extension/field_from_assignment_test.exs | 124 ++++++++++++++++++ .../resource/instance/access_service.ex | 6 + 3 files changed, 165 insertions(+) create mode 100644 lib/diffo/provider/components/calculations/field_from_assignment.ex create mode 100644 test/provider/extension/field_from_assignment_test.exs diff --git a/lib/diffo/provider/components/calculations/field_from_assignment.ex b/lib/diffo/provider/components/calculations/field_from_assignment.ex new file mode 100644 index 0000000..f3d4d4c --- /dev/null +++ b/lib/diffo/provider/components/calculations/field_from_assignment.ex @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.FieldFromAssignment do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + alias_name = opts[:alias] + field = opts[:field] + + Enum.map(records, fn record -> + record.id + |> assignments(alias_name) + |> Enum.map(&Map.get(&1, field)) + end) + end + + defp assignments(id, nil) do + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id) + |> Ash.read!(domain: Diffo.Provider) + end + + defp assignments(id, alias_name) do + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id, alias: alias_name) + |> Ash.read!(domain: Diffo.Provider) + end +end diff --git a/test/provider/extension/field_from_assignment_test.exs b/test/provider/extension/field_from_assignment_test.exs new file mode 100644 index 0000000..270ee88 --- /dev/null +++ b/test/provider/extension/field_from_assignment_test.exs @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.FieldFromAssignmentTest do + @moduledoc false + use ExUnit.Case, async: true + @moduletag :domain_extended + + alias Diffo.Provider.Assignment + alias Diffo.Test.Servo + + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + defp setup_card do + updates = [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] + + {:ok, card} = Servo.build_card(%{}) + {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) + card + end + + describe "FieldFromAssignment — aliased" do + test "returns field value from the aliased assignment record" do + card = setup_card() + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card} = + Servo.assign_port(card, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } + }) + + service = Ash.load!(service, [:assigned_port], domain: Servo) + + assert length(service.assigned_port) == 1 + assert hd(service.assigned_port) == 1 + end + + test "returns empty list when no assignment exists" do + {:ok, service} = Servo.build_access_service(%{}) + + service = Ash.load!(service, [:assigned_port], domain: Servo) + + assert service.assigned_port == [] + end + + test "alias filters to only the matching assignment record" do + card_a = setup_card() + card_b = setup_card() + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card_a} = + Servo.assign_port(card_a, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } + }) + + {:ok, _card_b} = + Servo.assign_port(card_b, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :secondary + } + }) + + service = Ash.load!(service, [:assigned_port], domain: Servo) + + assert length(service.assigned_port) == 1 + end + end + + describe "FieldFromAssignment — unaliased (all assignments)" do + test "returns field values from all assignment records" do + card_a = setup_card() + card_b = setup_card() + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card_a} = + Servo.assign_port(card_a, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :primary + } + }) + + {:ok, _card_b} = + Servo.assign_port(card_b, %{ + assignment: %Assignment{ + assignee_id: service.id, + operation: :auto_assign, + alias: :secondary + } + }) + + service = Ash.load!(service, [:all_assignment_values], domain: Servo) + + assert length(service.all_assignment_values) == 2 + end + + test "returns empty list when no assignments exist" do + {:ok, service} = Servo.build_access_service(%{}) + + service = Ash.load!(service, [:all_assignment_values], domain: Servo) + + assert service.all_assignment_values == [] + end + end +end diff --git a/test/support/resource/instance/access_service.ex b/test/support/resource/instance/access_service.ex index defe2ab..42f702d 100644 --- a/test/support/resource/instance/access_service.ex +++ b/test/support/resource/instance/access_service.ex @@ -45,6 +45,12 @@ defmodule Diffo.Test.Instance.AccessService do calculate :assigner_names, {:array, :string}, {Diffo.Provider.Calculations.FieldViaAssignedRelationship, [field: :name]} + + calculate :assigned_port, {:array, :integer}, + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :primary, field: :value]} + + calculate :all_assignment_values, {:array, :integer}, + {Diffo.Provider.Calculations.FieldFromAssignment, [field: :value]} end actions do From 5b0f435e36c4168280b98fd11af27b251f0cb196 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 22:55:09 +0930 Subject: [PATCH 21/23] field via relationship with type, alias (nil wild) calculation --- .../field_via_aliased_relationship.ex | 42 ------------------- .../calculations/field_via_relationship.ex | 34 +++++++++++++++ ...st.exs => field_via_relationship_test.exs} | 24 ++++------- .../resource/instance/shelf_instance.ex | 7 ++-- 4 files changed, 46 insertions(+), 61 deletions(-) delete mode 100644 lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex create mode 100644 lib/diffo/provider/components/calculations/field_via_relationship.ex rename test/provider/extension/{field_via_aliased_relationship_test.exs => field_via_relationship_test.exs} (76%) diff --git a/lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex b/lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex deleted file mode 100644 index 85f32d1..0000000 --- a/lib/diffo/provider/components/calculations/field_via_aliased_relationship.ex +++ /dev/null @@ -1,42 +0,0 @@ -# SPDX-FileCopyrightText: 2025 diffo contributors -# -# SPDX-License-Identifier: MIT - -defmodule Diffo.Provider.Calculations.FieldViaAliasedRelationship do - @moduledoc false - use Ash.Resource.Calculation - - @impl true - def load(_query, _opts, _context), do: [] - - @impl true - def calculate(records, opts, _context) do - alias_name = opts[:alias] - field = opts[:field] - - Enum.map(records, fn record -> - record.id - |> traverse(alias_name) - |> Enum.flat_map(fn target_id -> - Diffo.Provider.Instance - |> Ash.Query.filter_input(id: target_id) - |> Ash.read!(domain: Diffo.Provider) - |> Enum.map(&Map.get(&1, field)) - end) - end) - end - - defp traverse(id, nil) do - Diffo.Provider.DefinedSimpleRelationship - |> Ash.Query.filter_input(source_id: id) - |> Ash.read!(domain: Diffo.Provider) - |> Enum.map(& &1.target_id) - end - - defp traverse(id, alias_name) do - Diffo.Provider.DefinedSimpleRelationship - |> Ash.Query.filter_input(source_id: id, alias: alias_name) - |> Ash.read!(domain: Diffo.Provider) - |> Enum.map(& &1.target_id) - end -end diff --git a/lib/diffo/provider/components/calculations/field_via_relationship.ex b/lib/diffo/provider/components/calculations/field_via_relationship.ex new file mode 100644 index 0000000..219965b --- /dev/null +++ b/lib/diffo/provider/components/calculations/field_via_relationship.ex @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.FieldViaRelationship do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + alias_name = opts[:alias] + type = opts[:type] + field = opts[:field] + + Enum.map(records, fn record -> + filter = [source_id: record.id] + filter = if type, do: Keyword.put(filter, :type, type), else: filter + filter = if alias_name, do: Keyword.put(filter, :alias, alias_name), else: filter + + Diffo.Provider.DefinedSimpleRelationship + |> Ash.Query.filter_input(filter) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.flat_map(fn rel -> + Diffo.Provider.Instance + |> Ash.Query.filter_input(id: rel.target_id) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(&Map.get(&1, field)) + end) + end) + end +end diff --git a/test/provider/extension/field_via_aliased_relationship_test.exs b/test/provider/extension/field_via_relationship_test.exs similarity index 76% rename from test/provider/extension/field_via_aliased_relationship_test.exs rename to test/provider/extension/field_via_relationship_test.exs index f58b723..21229de 100644 --- a/test/provider/extension/field_via_aliased_relationship_test.exs +++ b/test/provider/extension/field_via_relationship_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Provider.Extension.FieldViaAliasedRelationshipTest do +defmodule Diffo.Provider.Extension.FieldViaRelationshipTest do @moduledoc false use ExUnit.Case, async: true @moduletag :domain_extended @@ -15,7 +15,7 @@ defmodule Diffo.Provider.Extension.FieldViaAliasedRelationshipTest do on_exit(&AshNeo4j.Sandbox.rollback/0) end - describe "FieldViaAliasedRelationship — aliased" do + describe "FieldViaRelationship — aliased" do test "returns field from target instance reached via alias" do {:ok, shelf} = Parties.build_shelf_with_installer() {:ok, card} = Servo.build_card(%{name: "target-card"}) @@ -65,8 +65,8 @@ defmodule Diffo.Provider.Extension.FieldViaAliasedRelationshipTest do end end - describe "FieldViaAliasedRelationship — unaliased (all targets)" do - test "returns fields from all related target instances regardless of alias" do + describe "FieldViaRelationship — type filter" do + test "type filters to only relationships of the matching type" do {:ok, shelf} = Parties.build_shelf_with_installer() {:ok, card_a} = Servo.build_card(%{name: "target-a"}) {:ok, card_b} = Servo.build_card(%{name: "target-b"}) @@ -79,23 +79,15 @@ defmodule Diffo.Provider.Extension.FieldViaAliasedRelationshipTest do }) Diffo.Provider.create_defined_simple_relationship!(%{ - type: :assignedTo, - alias: :other, + type: :reliesOn, + alias: :link, source_id: shelf.id, target_id: card_b.id }) - shelf = Ash.load!(shelf, [:all_linked_names], domain: Servo) - - assert Enum.sort(shelf.all_linked_names) == ["target-a", "target-b"] - end - - test "returns empty list when no relationships exist" do - {:ok, shelf} = Parties.build_shelf_with_installer() - - shelf = Ash.load!(shelf, [:all_linked_names], domain: Servo) + shelf = Ash.load!(shelf, [:assigned_linked_name], domain: Servo) - assert shelf.all_linked_names == [] + assert shelf.assigned_linked_name == ["target-a"] end end end diff --git a/test/support/resource/instance/shelf_instance.ex b/test/support/resource/instance/shelf_instance.ex index e9fc1f4..fb8f2ee 100644 --- a/test/support/resource/instance/shelf_instance.ex +++ b/test/support/resource/instance/shelf_instance.ex @@ -86,10 +86,11 @@ defmodule Diffo.Test.Instance.ShelfInstance do calculations do calculate :linked_target_name, {:array, :string}, - {Diffo.Provider.Calculations.FieldViaAliasedRelationship, [alias: :link, field: :name]} + {Diffo.Provider.Calculations.FieldViaRelationship, [alias: :link, field: :name]} - calculate :all_linked_names, {:array, :string}, - {Diffo.Provider.Calculations.FieldViaAliasedRelationship, [field: :name]} + calculate :assigned_linked_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaRelationship, + [type: :assignedTo, alias: :link, field: :name]} end actions do From dd8e5c5f67ce7ef178028f000e1c3447fbc5b320 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 23:06:08 +0930 Subject: [PATCH 22/23] update docs and guidance --- AGENTS.md | 32 +++- .../use_diffo_provider_extension.livemd | 108 ++++++++++++++ .../calculations/field_from_assignment.ex | 29 +++- .../field_via_assigned_relationship.ex | 27 +++- .../calculations/field_via_relationship.ex | 34 ++++- .../calculations/inherited_party.ex | 11 +- .../calculations/inherited_place.ex | 11 +- .../extension/inherited_party_declaration.ex | 27 +++- .../extension/inherited_place_declaration.ex | 27 +++- usage-rules.md | 138 ++++++++++++++++++ 10 files changed, 434 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58c0036..3a359a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,10 @@ lib/diffo/provider/ relationship_step.ex # RelationshipStep struct — pipeline step for relationships do persisters/ # Terminal bakers — run after all transformers; only read DSL state and bake module functions transformers/ - transform_relationships.ex # TransformRelationships — resolves relationships pipeline, bakes permitted_source_roles/0 and permitted_target_roles/0 + transform_relationships.ex # TransformRelationships — resolves relationships pipeline, bakes permitted_source_roles/0 and permitted_target_roles/0 + transform_inherited_refs.ex # TransformInheritedRefs — injects calculations for inherited_place/inherited_party declarations + inherited_place_declaration.ex # DSL entity struct for inherited_place + inherited_party_declaration.ex # DSL entity struct for inherited_party verifiers/ verify_relationships.ex # Verifies relationship role declarations are atoms validations/ @@ -69,8 +72,13 @@ lib/diffo/provider/ assignment_relationship.ex # AssignmentRelationship — pool assignment relationship with top-level pool/thing/value/alias scalar attributes relationship.ex # Relationship — mutable TMF service/resource relationship with graph Characteristic nodes calculations/ - characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields - assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing + characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields + assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing + inherited_place.ex # Calculation: backing impl for inherited_place DSL + inherited_party.ex # Calculation: backing impl for inherited_party DSL + field_from_assignment.ex # Calculation: field from AssignmentRelationship record + field_via_assigned_relationship.ex # Calculation: field from source instance via assignment traversal + field_via_relationship.ex # Calculation: field from target instance via DefinedSimpleRelationship instance/extension.ex # Thin marker (sections: []) — kind identification party/extension.ex # Thin marker place/extension.ex # Thin marker @@ -147,6 +155,10 @@ provider do places do place :installation_site, MyApp.GeographicSite place_ref :billing_address, MyApp.GeographicAddress + # Inherited — generates a calculation that traverses AssignmentRelationship + # by alias and reads PlaceRef from the source instance. No PlaceRef edge is created. + inherited_place :exchange, source_role: :location # alias = role name (single-hop default) + inherited_place :nni_site, via: [:uplink], source_role: :location # explicit alias end behaviour do @@ -350,3 +362,17 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i - 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. +- Using `inherited_place` or `inherited_party` without an assignment alias in place — the + traversal filters by alias; if the assignment was created without an alias (or with a + different alias), the calculation returns an empty list. Ensure the `alias:` field on + `Assignment` matches the declared role (or the `via:` step) before expecting results. +- Referencing `Diffo.Provider.Calculations.InheritedPlace` or `InheritedParty` directly in + `calculations do` — these are internal modules injected by the transformer. Use the + `inherited_place` / `inherited_party` DSL entities in `places do` / `parties do` instead. +- Reaching for `FieldViaRelationship` to traverse an `AssignmentRelationship` — that module + traverses `DefinedSimpleRelationship` (forward, source → target). For assignments + (reverse, target → source) use `FieldViaAssignedRelationship` or `FieldFromAssignment`. +- Querying `FieldViaRelationship` without supplying `alias:` or `type:` — a source instance + typically has many forward `DefinedSimpleRelationship` records pointing to unrelated things. + Without at least one filter the result is a noisy mix. Always supply `alias:`, `type:`, or + both. diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index 7094589..20e7773 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -494,6 +494,114 @@ defmodule Diffo.Compute.GPU do end ``` +## Aliases, Inherited DSL, and Field Calculations + +### Aliases on assignment slots + +Every `AssignmentRelationship` carries an optional `:alias` — an atom given to a slot by +the consuming (target) side before or when the assignment is bound. Think of it as a stable +name for the slot: the consumer says "I have a slot called `:primary_gpu`", and the producer +assigns into it carrying `alias: :primary_gpu`. The alias never changes, even if the +assignment is recreated. + +Pass the alias via `Assignment.alias` when assigning: + +```elixir +# Assign a core from gpu_1 into cluster_1's :primary_gpu slot +assignment = %{ + assignment: %Assignment{ + assignee_id: cluster_1.id, + operation: :auto_assign, + alias: :primary_gpu + } +} +gpu_1 = Compute.assign_gpu_core!(gpu_1, assignment) +``` + +The identity constraint `[:target_id, :alias]` on `AssignmentRelationship` guarantees at +most one assignment per (cluster, alias) pair — the `:primary_gpu` slot can only hold one +assignment at a time. + +### Inheriting a place from an assigned resource + +A service or resource can declare that it inherits a place from the instance that assigned +something to it — without creating its own `PlaceRef` edge. The `inherited_place` DSL entity +in `places do` generates an Ash calculation that traverses the assignment graph at read time. + +In our Compute example: if a `GPU` instance has a `:data_centre` place, and a `Cluster` +wants to surface the data centre of its primary GPU, it can declare: + +```elixir +provider do + places do + # Traverses AssignmentRelationship where alias = :primary_gpu, + # reads PlaceRef with role :data_centre from the source GPU instance. + inherited_place :primary_data_centre, via: [:primary_gpu], source_role: :data_centre + end +end +``` + +Load it like any other calculation: + +```elixir +cluster = Ash.load!(cluster_1, [:primary_data_centre], domain: Compute) +# cluster.primary_data_centre => [%DataCentre{...}] +``` + +`inherited_party` works identically for party inheritance: + +```elixir +# Cluster inherits the operating Tenant from the GPU it was assigned from +provider do + parties do + inherited_party :operator, via: [:primary_gpu], source_role: :operator + end +end +``` + +### Reading fields from the assignment graph + +Three calculation modules handle common traversal patterns. All return lists. + +**`FieldFromAssignment`** — reads a field directly from the `AssignmentRelationship` +record. Use it for values that live on the relationship itself: `:value`, `:pool`, +`:thing`, `:alias`. + +```elixir +# Core number assigned to this cluster under the :primary_gpu slot +calculate :primary_core, {:array, :integer}, + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :primary_gpu, field: :value]} +``` + +**`FieldViaAssignedRelationship`** — traverses assignment in reverse (cluster → GPU) +and reads a field from the source instance. Use it for fields that live on the assigning +resource, not the relationship. + +```elixir +# Name of the GPU holding the :primary_gpu slot on this cluster +calculate :primary_gpu_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAssignedRelationship, + [via: [:primary_gpu], field: :name]} +``` + +**`FieldViaRelationship`** — traverses `DefinedSimpleRelationship` in the forward +direction (source → target) filtered by `alias:` and/or `type:`. Use it when this +instance is the *source* of a named forward relationship. + +```elixir +# Name of the downstream node this GPU provides to +calculate :downstream_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaRelationship, + [type: :assignedTo, alias: :downstream, field: :name]} +``` + +| I want… | Use | +|---------|-----| +| Value on the assignment record (`:value`, `:pool`) | `FieldFromAssignment` | +| Field from the instance that assigned to me | `FieldViaAssignedRelationship` | +| Field from an instance I have a forward relationship to | `FieldViaRelationship` | +| Place/party inherited via assignment | `inherited_place` / `inherited_party` | + ## Party Extension `Diffo.Provider.BaseParty` is an Ash Resource Fragment for domain-specific Party kinds, mirroring `BaseInstance`. It provides common Party attributes — `id`, `href`, `name`, `type`, `referred_type` — and the unified `Diffo.Provider.Extension` DSL. Within `provider do`, a Party kind uses `instances do`, `parties do`, and `places do` sections to declare the roles it plays. diff --git a/lib/diffo/provider/components/calculations/field_from_assignment.ex b/lib/diffo/provider/components/calculations/field_from_assignment.ex index f3d4d4c..05dd779 100644 --- a/lib/diffo/provider/components/calculations/field_from_assignment.ex +++ b/lib/diffo/provider/components/calculations/field_from_assignment.ex @@ -3,7 +3,34 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Calculations.FieldFromAssignment do - @moduledoc false + @moduledoc """ + Reads a field directly from an `AssignmentRelationship` record. + + Filters `AssignmentRelationship` by `target_id = current.id` and returns the named + field from each matching record — no second hop to the source instance. This is the + right choice when you want a value that lives on the relationship itself (`:value`, + `:thing`, `:pool`, `:alias`) rather than on the assigning instance. + + Use `FieldViaAssignedRelationship` instead when you need a field from the source + instance (e.g. `:name`). + + ## Options + + - `field:` *(required)* — atom naming the field to read from the relationship record + (e.g. `:value`, `:thing`, `:pool`, `:alias`). + - `alias:` *(optional)* — atom matching the `alias` attribute on the relationship. + When omitted, all assignments where `target_id = current.id` are included. + + ## Examples + + # Port number assigned to this service under the :primary slot + calculate :assigned_port, {:array, :integer}, + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :primary, field: :value]} + + # Pool name for every assignment on this instance + calculate :assignment_pools, {:array, :atom}, + {Diffo.Provider.Calculations.FieldFromAssignment, [field: :pool]} + """ use Ash.Resource.Calculation @impl true diff --git a/lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex b/lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex index 6eab486..bc70678 100644 --- a/lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex +++ b/lib/diffo/provider/components/calculations/field_via_assigned_relationship.ex @@ -3,7 +3,32 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Calculations.FieldViaAssignedRelationship do - @moduledoc false + @moduledoc """ + Reads a field from the source instance of an `AssignmentRelationship`. + + Traverses `AssignmentRelationship` in reverse — filtering by `target_id = current.id` + — to reach the source instances (pool owners) that assigned something to this instance, + then returns the named field from each. + + ## Options + + - `field:` *(required)* — atom naming the field to read from the source instance + (e.g. `:name`, `:type`). + - `via:` *(optional)* — list of alias atoms to step through. Each step filters + `AssignmentRelationship` by the alias and follows `source_id` to the next set of + instances. Multi-hop is supported by chaining steps. When omitted, all assignments + where `target_id = current.id` are traversed without alias filtering. + + ## Examples + + # Name of the CVC that holds the :svlan assignment slot on this AVC + calculate :cvc_id, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAssignedRelationship, [via: [:svlan], field: :name]} + + # Name of every instance that has ever assigned anything to this one + calculate :assigner_names, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAssignedRelationship, [field: :name]} + """ use Ash.Resource.Calculation @impl true diff --git a/lib/diffo/provider/components/calculations/field_via_relationship.ex b/lib/diffo/provider/components/calculations/field_via_relationship.ex index 219965b..af5bf14 100644 --- a/lib/diffo/provider/components/calculations/field_via_relationship.ex +++ b/lib/diffo/provider/components/calculations/field_via_relationship.ex @@ -3,7 +3,39 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Calculations.FieldViaRelationship do - @moduledoc false + @moduledoc """ + Reads a field from target instances reached via `DefinedSimpleRelationship`. + + Traverses `DefinedSimpleRelationship` in the forward direction — filtering by + `source_id = current.id` — and returns the named field from each resolved target + instance. Both `type:` and `alias:` are optional filters; when omitted they match + any value on that dimension. + + ## Options + + - `field:` *(required)* — atom naming the field to read from the target instance + (e.g. `:name`, `:type`). + - `alias:` *(optional)* — atom matching the `alias` attribute on the relationship. + When omitted, relationships with any alias (including nil) are included. + - `type:` *(optional)* — atom matching the `type` attribute on the relationship + (e.g. `:assignedTo`, `:reliesOn`). When omitted, all types are included. + + Providing neither filter returns fields from every forward `DefinedSimpleRelationship` + on this instance. In practice at least one of `alias:` or `type:` should be supplied, + since a source instance typically has many forward relationships pointing to unrelated + things. + + ## Examples + + # Name of the target reached via the :provides alias + calculate :provider_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaRelationship, [alias: :provides, field: :name]} + + # Name of the target reached via the :link alias, restricted to :assignedTo type + calculate :assigned_linked_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaRelationship, + [type: :assignedTo, alias: :link, field: :name]} + """ use Ash.Resource.Calculation @impl true diff --git a/lib/diffo/provider/components/calculations/inherited_party.ex b/lib/diffo/provider/components/calculations/inherited_party.ex index ab58489..7f1ccc3 100644 --- a/lib/diffo/provider/components/calculations/inherited_party.ex +++ b/lib/diffo/provider/components/calculations/inherited_party.ex @@ -3,7 +3,16 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Calculations.InheritedParty do - @moduledoc false + @moduledoc """ + Backing calculation for `inherited_party` DSL declarations. + + Traverses `AssignmentRelationship` by alias to reach source instances, then reads + their `PartyRef` records for the declared `source_role`. Injected automatically by + `TransformInheritedRefs` — do not reference this module directly; use the + `inherited_party` DSL entity instead. + + See `Diffo.Provider.Extension.InheritedPartyDeclaration` for the DSL options. + """ use Ash.Resource.Calculation @impl true diff --git a/lib/diffo/provider/components/calculations/inherited_place.ex b/lib/diffo/provider/components/calculations/inherited_place.ex index cc252bd..b15a2b5 100644 --- a/lib/diffo/provider/components/calculations/inherited_place.ex +++ b/lib/diffo/provider/components/calculations/inherited_place.ex @@ -3,7 +3,16 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Calculations.InheritedPlace do - @moduledoc false + @moduledoc """ + Backing calculation for `inherited_place` DSL declarations. + + Traverses `AssignmentRelationship` by alias to reach source instances, then reads + their `PlaceRef` records for the declared `source_role`. Injected automatically by + `TransformInheritedRefs` — do not reference this module directly; use the + `inherited_place` DSL entity instead. + + See `Diffo.Provider.Extension.InheritedPlaceDeclaration` for the DSL options. + """ use Ash.Resource.Calculation @impl true diff --git a/lib/diffo/provider/extension/inherited_party_declaration.ex b/lib/diffo/provider/extension/inherited_party_declaration.ex index d02437f..5427b3a 100644 --- a/lib/diffo/provider/extension/inherited_party_declaration.ex +++ b/lib/diffo/provider/extension/inherited_party_declaration.ex @@ -3,7 +3,32 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Extension.InheritedPartyDeclaration do - @moduledoc "DSL entity declaring an inherited party role — derived by traversing the assignment graph" + @moduledoc """ + DSL entity for an `inherited_party` declaration inside `parties do` on an Instance resource. + + Generates an Ash calculation of the same name as `role` that traverses the assignment + graph to inherit a party from a related source instance. The calculation is injected + by `TransformInheritedRefs` at compile time — no `PartyRef` edge is created on the + consuming instance itself. + + ## Fields + + - `role` — atom; the name of the generated calculation (and the party slot name from + the consumer's perspective). + - `source_role` — atom; the `PartyRef` role to read from the resolved source instance + (e.g. `:provider`). Required. + - `via` — optional list of alias atoms for multi-hop traversal. When nil the role name + is used as the single alias step (single-hop default). When provided, each step + filters `AssignmentRelationship` by that alias atom before following `source_id` to + the next set of instances. + + ## Example + + parties do + inherited_party :provider, source_role: :provider + inherited_party :nni_owner, via: [:uplink], source_role: :owner + end + """ defstruct [:role, :via, :source_role, __spark_metadata__: nil] defimpl String.Chars do diff --git a/lib/diffo/provider/extension/inherited_place_declaration.ex b/lib/diffo/provider/extension/inherited_place_declaration.ex index 7f55dc9..147602d 100644 --- a/lib/diffo/provider/extension/inherited_place_declaration.ex +++ b/lib/diffo/provider/extension/inherited_place_declaration.ex @@ -3,7 +3,32 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Extension.InheritedPlaceDeclaration do - @moduledoc "DSL entity declaring an inherited place role — derived by traversing the assignment graph" + @moduledoc """ + DSL entity for an `inherited_place` declaration inside `places do` on an Instance resource. + + Generates an Ash calculation of the same name as `role` that traverses the assignment + graph to inherit a place from a related source instance. The calculation is injected + by `TransformInheritedRefs` at compile time — no `PlaceRef` edge is created on the + consuming instance itself. + + ## Fields + + - `role` — atom; the name of the generated calculation (and the place slot name from + the consumer's perspective). + - `source_role` — atom; the `PlaceRef` role to read from the resolved source instance + (e.g. `:location`). Required. + - `via` — optional list of alias atoms for multi-hop traversal. When nil the role name + is used as the single alias step (single-hop default). When provided, each step + filters `AssignmentRelationship` by that alias atom before following `source_id` to + the next set of instances. + + ## Example + + places do + inherited_place :installation_site, source_role: :location + inherited_place :exchange, via: [:primary, :uplink], source_role: :location + end + """ defstruct [:role, :via, :source_role, __spark_metadata__: nil] defimpl String.Chars do diff --git a/usage-rules.md b/usage-rules.md index 6491e7c..218ae5c 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -549,6 +549,144 @@ defmodule MyApp.GeographicSite do end ``` +## Aliases on relationships + +Both `AssignmentRelationship` and `DefinedSimpleRelationship` carry an optional `:alias` +attribute — an atom given to a relationship slot by the consuming (target) side. + +An alias is the consumer's stable name for a slot before (or when) the relationship is +bound. It survives the relationship's lifetime unchanged. Think of it as a "baby name" +for a slot: the AVC says "I have a slot called `:svlan`"; when the CVC assigns a VLAN to +that AVC, the `AssignmentRelationship` record carries `alias: :svlan`. No matter which +CVC fills the slot or how many times the assignment is changed, the alias stays fixed. + +Identity constraints enforce uniqueness: +- `AssignmentRelationship` — `[:target_id, :alias]` — at most one assignment per + (target, alias) pair. This is how the consumer guarantees slot uniqueness. +- `DefinedSimpleRelationship` — `[:source_id, :alias]` — at most one outgoing + relationship per (source, alias) pair. + +Aliases are the join key for the first-order expectation system (issue #74): an +expectation declares an alias for a slot it expects to be filled; the actual relationship +carries the same alias, so intent and fulfilment can be matched precisely. Without the +expectation system in place, aliases appear to be optional metadata — with it, they are +the primary correlation key. + +```elixir +# Assigning with an alias — the AVC names its SVLAN slot :svlan +Servo.assign_port(cvc, %{ + assignment: %Assignment{ + assignee_id: avc.id, + operation: :auto_assign, + alias: :svlan + } +}) +``` + +## `inherited_place` and `inherited_party` DSL + +Declare `inherited_place` or `inherited_party` inside `places do` / `parties do` on an +Instance resource to generate an Ash calculation that traverses the assignment graph and +inherits a place or party from the source instance. + +No `PlaceRef` or `PartyRef` edge is created on the consuming instance — the calculation +IS the reference. The result is a list (consistent with all traversal calculations). + +```elixir +provider do + places do + # Single-hop: traverses AssignmentRelationship where alias = :installation_site, + # reads PlaceRef with role :location from the source instance + inherited_place :installation_site, source_role: :location + + # Explicit alias (same as above written long-form) + inherited_place :exchange, via: [:exchange], source_role: :location + + # Multi-hop: :primary slot on this instance → :uplink slot on that instance → + # reads :location PlaceRef from the final source + inherited_place :exchange, via: [:primary, :uplink], source_role: :location + end + + parties do + inherited_party :provider, source_role: :provider + end +end +``` + +Options: +- `source_role:` *(required)* — the `PlaceRef`/`PartyRef` role to read from the resolved + source instance. +- `via:` *(optional)* — explicit list of alias atoms for multi-hop traversal. When + omitted, the role name itself is used as the single alias step. + +The DSL entity must be declared in the correct section (`places do` for `inherited_place`, +`parties do` for `inherited_party`). The generated calculation name matches the declared role. + +## Field calculation modules + +Three general-purpose calculation modules cover reading fields across the assignment and +relationship graph. Declare them in a `calculations do` block on any Instance resource. + +### `FieldFromAssignment` + +Reads a field directly from an `AssignmentRelationship` record — no hop to the source +instance. Use this when you want a value that lives on the relationship itself. + +```elixir +# Port number assigned to this service under the :svlan slot +calculate :assigned_vlan, {:array, :integer}, + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :svlan, field: :value]} + +# Pool name for every assignment on this instance (no alias filter) +calculate :assignment_pools, {:array, :atom}, + {Diffo.Provider.Calculations.FieldFromAssignment, [field: :pool]} +``` + +Options: `field:` (required), `alias:` (optional). + +### `FieldViaAssignedRelationship` + +Traverses `AssignmentRelationship` in reverse (target → source) and reads a field from +each source instance. Use this when you want a field that belongs to the assigning +instance, not the relationship record. + +```elixir +# Name of the CVC holding the :svlan assignment slot on this AVC +calculate :cvc_id, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaAssignedRelationship, [via: [:svlan], field: :name]} +``` + +Options: `field:` (required), `via:` (optional list of alias steps — omit for unaliased). + +### `FieldViaRelationship` + +Traverses `DefinedSimpleRelationship` in the forward direction (source → target) filtered +by `alias:` and/or `type:`, and reads a field from each target instance. + +```elixir +# Name of the target reached via the :provides alias +calculate :downstream_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaRelationship, [alias: :provides, field: :name]} + +# Name narrowed by both type and alias +calculate :assigned_node_name, {:array, :string}, + {Diffo.Provider.Calculations.FieldViaRelationship, + [type: :assignedTo, alias: :node, field: :name]} +``` + +Options: `field:` (required), `alias:` (optional), `type:` (optional). Provide at least +one of `alias:` or `type:` — querying by `source_id` alone returns all forward +relationships mixed together, which is rarely useful. + +### Choosing between the three + +| I want… | Use | +|---------|-----| +| A value stored on the assignment record itself (`:value`, `:pool`, `:alias`) | `FieldFromAssignment` | +| A field from the instance that assigned something to me | `FieldViaAssignedRelationship` | +| A field from the instance I have a forward relationship to | `FieldViaRelationship` | +| A place/party inherited from the assigning instance | `inherited_place` / `inherited_party` DSL | + ## Common mistakes - **Do not add your resources to `Diffo.Provider`** — that domain is closed. Build your own From cdbbea4dea04516147b0d398a10af049a1bbf2e3 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 20 May 2026 23:16:21 +0930 Subject: [PATCH 23/23] release 0.4.0 --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ mix.exs | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e78c41f..c438506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,54 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline +## [v0.4.0](https://github.com/diffo-dev/diffo/compare/v0.3.0...v0.4.0) (2026-05-20) + +### Breaking Changes + +* `Diffo.Provider.AssignedToRelationship` replaced by `Diffo.Provider.AssignmentRelationship` — stores pool assignments with top-level `pool`, `thing`, `value`, and `alias` scalar attributes, enabling graph-level filtering in AshNeo4j queries. Any existing graph data on `AssignedToRelationship` nodes must be migrated. +* `create_assigned_to_relationship` code interface removed — use `create_assignment_relationship` instead. +* `instance.assignments` now returns `AssignmentRelationship` records (struct name change only). + +### Features + +* **`DefinedSimpleRelationship`** — new resource for relationships carrying an optional single embedded `NameValuePrimitive` characteristic, frozen at creation. Used by the Assigner and available as a general-purpose committed-relationship primitive. Accessible via `instance.assignments`. +* **`AssignmentRelationship` aliases** — the `alias` attribute on `AssignmentRelationship` (identity `[:target_id, :alias]`) gives a consuming instance a stable name for an assignment slot. Mirrors the `[:source_id, :alias]` identity on `DefinedSimpleRelationship`. Alias semantics are the foundation of the first-order expectation system (#74). +* **`relationships do` DSL** — source and target validation pipeline for Instance resources. `ValidateRelationshipPermitted` is injected automatically into relate actions. Supports `:all`, `:none`, and explicit role-name lists. +* **Resource lifecycle states** — `resource_state` attribute on Instance resources with standard TMF states (`:installed`, `:operating`, `:retired`, etc.). The Assigner enforces `:operating` before allowing assignment. +* **`inherited_place` / `inherited_party` DSL** — declare inside `places do` / `parties do` on an Instance resource to generate an Ash calculation that traverses the assignment graph by alias and inherits a place or party from the source instance. No `PlaceRef`/`PartyRef` edge is created — the calculation is the reference. Supports single-hop (default: role name as alias) and multi-hop (`via:` list). +* **`FieldFromAssignment`** (`Diffo.Provider.Calculations.FieldFromAssignment`) — reads a field directly from an `AssignmentRelationship` record (`:value`, `:pool`, `:thing`, `:alias`). Filtered by optional `alias:`. Returns a list. +* **`FieldViaAssignedRelationship`** (`Diffo.Provider.Calculations.FieldViaAssignedRelationship`) — traverses `AssignmentRelationship` in reverse (target → source) and reads a named field from each source instance. Supports multi-hop `via:` traversal. Returns a list. +* **`FieldViaRelationship`** (`Diffo.Provider.Calculations.FieldViaRelationship`) — traverses `DefinedSimpleRelationship` forward (source → target) filtered by optional `alias:` and/or `type:`, and reads a named field from each target instance. Returns a list. + +### Notable Changes + +* Assigner rearchitected — `AssignmentRelationship` carries `pool`, `thing`, `value`, `alias` as top-level attributes for AshNeo4j-level filtering; `assigned_values` and `free_values` use query-level filtering rather than in-memory computation where possible. +* `TransformBehaviour` moved from persister pipeline to transformer pipeline for correct Spark ordering relative to Ash's own transformers. +* Characteristic type verifier improved — rejects `characteristic` DSL declarations whose type module is not derived from `BaseCharacteristic`. + +### Documentation + +* `usage-rules.md` — new sections covering alias semantics, `inherited_place`/`inherited_party` DSL, and all three field calculation modules including a decision table. +* `AGENTS.md` — updated project structure, DSL inline examples for inherited refs, and new common mistakes section entries. +* Provider Extension livebook — new section "Aliases, Inherited DSL, and Field Calculations" with Compute-domain examples. + +### What's Changed + +* defined_simple_relationship by @matt-beanland in https://github.com/diffo-dev/diffo/pull/142 +* refactored assigner using defined_simple_relationship by @matt-beanland in https://github.com/diffo-dev/diffo/pull/143 +* relationships DSL by @matt-beanland in https://github.com/diffo-dev/diffo/pull/146 +* relationships target side validation by @matt-beanlanda in https://github.com/diffo-dev/diffo/pull/148 +* clean code by @matt-beanland in https://github.com/diffo-dev/diffo/pull/150 +* improved assigner using aggregates by @matt-beanland in https://github.com/diffo-dev/diffo/pull/151 +* refactor transformers and persisters by @matt-beanland in https://github.com/diffo-dev/diffo/pull/152 +* resource lifecycle state by @matt-beanland in https://github.com/diffo-dev/diffo/pull/154 +* inherited party and place via instance DSL by @matt-beanland in https://github.com/diffo-dev/diffo/pull/155 +* agent guidance by @matt-beanland in https://github.com/diffo-dev/diffo/pull/161 +* FieldViaAssignedRelationship calculation by @matt-beanland in https://github.com/diffo-dev/diffo/pull/162 +* FieldViaRelationship calculation by @matt-beanland in https://github.com/diffo-dev/diffo/pull/165 +* FieldFromAssignment calculation by @matt-beanland in https://github.com/diffo-dev/diffo/pull/164 +* docs pass — inherited DSL, aliases, and field calculations by @matt-beanland in https://github.com/diffo-dev/diffo/pull/166 + ## [v0.3.0](https://github.com/diffo-dev/diffo/compare/v0.2.2...v0.3.0) (2026-05-17) ### Breaking Changes diff --git a/mix.exs b/mix.exs index e22ab76..5bc13cd 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,7 @@ defmodule Diffo.MixProject do @moduledoc false use Mix.Project - @version "0.3.0" + @version "0.4.0" @name "Diffo" @description "TMF Service and Resource Manager with a difference" @github_url "https://github.com/diffo-dev/diffo"