Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions lib/diffo/provider/assigner/assigner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions lib/diffo/provider/assigner/assignment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 20 additions & 3 deletions lib/diffo/provider/components/assignment_relationship.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions lib/diffo/provider/components/calculations/inherited_party.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.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
37 changes: 37 additions & 0 deletions lib/diffo/provider/components/calculations/inherited_place.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.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
4 changes: 3 additions & 1 deletion lib/diffo/provider/components/relationship.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 61 additions & 5 deletions lib/diffo/provider/extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ defmodule Diffo.Provider.Extension do
Characteristic,
Feature,
InstanceRole,
InheritedPartyDeclaration,
InheritedPlaceDeclaration,
PartyDeclaration,
PartyRole,
PlaceDeclaration,
Expand Down Expand Up @@ -292,17 +294,43 @@ 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
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
Expand All @@ -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 ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand All @@ -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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions lib/diffo/provider/extension/inherited_party_declaration.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.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
12 changes: 12 additions & 0 deletions lib/diffo/provider/extension/inherited_place_declaration.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.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
Loading
Loading