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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,9 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i
- Using `characteristic :name, Diffo.Provider.AssignableCharacteristic` for pools — use `pools do / pool :name, :thing / end` instead.
- 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.
- Hand-writing the `:define` / `:relate` / `:assign_*` after-action plumbing — use `Diffo.Provider.Changes.Define`, `Diffo.Provider.Changes.Relate`, and `{Diffo.Provider.Changes.Assign, pool: :name}` (since 0.4.1). The change modules thread `Characteristic.update_all/3`, `Pool.update_pools/3`, `Relationship.relate_instance/2` and `Assigner.assign/3` together and reload via the resource's primary `:read` action.
- Hand-writing the `:create` / `:update` accept lists on a `BaseCharacteristic`-derived resource — they are synthesised from the resource's public attributes (since 0.4.1). Declare your own only when you need a narrower accept list.
- Calling `Assigner.assign/3` on an instance that is not in the correct lifecycle state — the assigner enforces: resource instances must have `resource_state` of `:installing` or `:operating`; service instances must have `service_state` of `:feasibilityChecked`, `:reserved`, `:inactive`, `:active`, or `:suspended` (since 0.4.1). The full lists are exposed via `Assigner.assignable_resource_states/0` and `Assigner.assignable_service_states/0`. Lifecycle state transitions are an internal domain concern managed by the provider; assignment actions are external-facing.
- 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`.
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline

<!-- changelog -->

## [v0.4.1](https://github.com/diffo-dev/diffo/compare/v0.4.0...v0.4.1) (2026-05-22)

### Bug Fixes

* **Assigner lifecycle** (#168) — broadened the lifecycle states permitted to make assignments. Services may now assign from `:feasibilityChecked`, `:reserved`, `:inactive`, `:active`, or `:suspended` (was `:active` / `:inactive` only). Resources may now assign from `:installing` or `:operating` (was `:operating` only). `Assigner.assignable_state?/1` exposes the policy directly.

### Features

* **`Diffo.Provider.Changes.Define` / `Relate` / `Assign`** (#170) — change modules that wrap the standard after-action patterns every Instance consumer writes. Replace the hand-written `after_action` body threading `Characteristic.update_all` / `Pool.update_pools` / `Relationship.relate_instance` / `Assigner.assign` together with a one-liner:

```elixir
update :define do
argument :characteristic_value_updates, {:array, :term}
change Diffo.Provider.Changes.Define
end

update :relate do
argument :relationships, {:array, :struct}
change Diffo.Provider.Changes.Relate
end

update :assign_port do
argument :assignment, :struct, constraints: [instance_of: Assignment]
change {Diffo.Provider.Changes.Assign, pool: :ports}
end
```

Reload happens via the resource's primary `:read` action, so no consumer-specific reader is needed.
* **BaseCharacteristic auto-generated `:create` / `:update` actions** (#171) — `BaseCharacteristic`-derived resources now get default `:create` and `:update` actions synthesised from their public attributes. `:create` accepts `[:name | <public_attrs>]` with `:instance_id` / `:feature_id` arguments and `manage_relationship` changes; `:update` accepts `<public_attrs>`. Consumers may still declare their own actions to override the defaults.
* **Typed characteristics and pools in Instance JSON** (#169) — `BaseInstance` now loads two new calculations (`:typed_characteristics`, `:pool_characteristics`) by default and the jason customize merges their values into the `serviceCharacteristic` / `resourceCharacteristic` array. Typed `BaseCharacteristic` records and `AssignableCharacteristic` pool records that were already present in the graph are now visible at the TMF JSON surface.

### Notable Changes

* `Diffo.Provider.Calculations.TypedCharacteristics` and `Diffo.Provider.Calculations.PoolCharacteristics` — new calc modules backing the JSON surfacing for #169.
* Regression test added for #62 (characteristic update validation) — typed `BaseCharacteristic` updates now reject unknown fields and invalid types through Ash's standard changeset machinery.

## [v0.4.0](https://github.com/diffo-dev/diffo/compare/v0.3.0...v0.4.0) (2026-05-20)

### Breaking Changes
Expand Down
43 changes: 31 additions & 12 deletions lib/diffo/provider/assigner/assigner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,26 @@ defmodule Diffo.Provider.Assigner do
alias Diffo.Provider.AssignableCharacteristic
alias Diffo.Provider.AssignmentRelationship

@assignable_resource_states [:installing, :operating]
@assignable_service_states [:feasibilityChecked, :reserved, :inactive, :active, :suspended]

@doc """
The resource lifecycle states from which an instance may make assignments.
"""
def assignable_resource_states, do: @assignable_resource_states

@doc """
The service lifecycle states from which an instance may make assignments.
"""
def assignable_service_states, do: @assignable_service_states

@doc """
Assign a thing using the pool declared via `pools do` on the instance module.
The thing name is looked up from the pool declaration.
"""
def assign(result, changeset, pool_name)
when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(pool_name) do
with :ok <- check_lifecycle(result) do
with :ok <- assignable_state?(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)
Expand Down Expand Up @@ -61,17 +74,23 @@ 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
@doc """
Returns `:ok` if the instance is in a lifecycle state that permits assignment,
otherwise `{:error, reason}`.
"""
def assignable_state?(%{type: :resource, resource_state: state})
when state not in @assignable_resource_states,
do:
{:error,
"cannot assign: resource lifecycle state is #{inspect(state)}, must be one of #{inspect(@assignable_resource_states)}"}

def assignable_state?(%{type: :service, service_state: state})
when state not in @assignable_service_states,
do:
{:error,
"cannot assign: service state is #{inspect(state)}, must be one of #{inspect(@assignable_service_states)}"}

def assignable_state?(_), do: :ok

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
Expand Down
56 changes: 56 additions & 0 deletions lib/diffo/provider/changes/assign.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Provider.Changes.Assign do
@moduledoc """
After-action change for the standard `:assign_*` pattern.

Performs an assignment for the named pool via `Assigner.assign/3`, then
reloads the result via the resource's primary `:read` action.

## Usage

update :assign_port do
argument :assignment, :struct, constraints: [instance_of: Assignment]
change {Diffo.Provider.Changes.Assign, pool: :ports}
end

## Options

- `:pool` (required) — the pool name (atom) declared via `pools do` on the
consuming instance resource.
"""
use Ash.Resource.Change

require Ash.Query

alias Diffo.Provider.Assigner

@impl true
def init(opts) do
case Keyword.fetch(opts, :pool) do
{:ok, pool} when is_atom(pool) and not is_nil(pool) ->
{:ok, opts}

_ ->
{:error, "Diffo.Provider.Changes.Assign requires a :pool atom option"}
end
end

@impl true
def change(changeset, opts, _context) do
pool = Keyword.fetch!(opts, :pool)

Ash.Changeset.after_action(changeset, fn changeset, result ->
with {:ok, result} <- Assigner.assign(result, changeset, pool) do
id = result.id

changeset.resource
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(id == ^id)
|> Ash.read_one()
end
end)
end
end
49 changes: 49 additions & 0 deletions lib/diffo/provider/changes/define.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Provider.Changes.Define do
@moduledoc """
After-action change for the standard `:define` pattern.

Applies `characteristic_value_updates` to the instance's declared typed and
dynamic characteristics (`Characteristic.update_all/3`) and to its declared
pools (`Pool.update_pools/3`), then reloads the result via the resource's
primary `:read` action.

## Usage

update :define do
argument :characteristic_value_updates, {:array, :term}
change Diffo.Provider.Changes.Define
end

This replaces the hand-written `after_action` block that threads
`characteristics()`, `pools()`, `Characteristic.update_all/3` and
`Pool.update_pools/3` together on every consumer.
"""
use Ash.Resource.Change

require Ash.Query

alias Diffo.Provider.Extension.Characteristic
alias Diffo.Provider.Extension.Pool

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn changeset, result ->
module = changeset.resource

with {:ok, result} <- Ash.load(result, [:characteristics]),
{:ok, result} <- Characteristic.update_all(result, changeset, module.characteristics()),
{:ok, result} <- Pool.update_pools(result, changeset, module.pools()) do
id = result.id

module
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(id == ^id)
|> Ash.read_one()
end
end)
end
end
39 changes: 39 additions & 0 deletions lib/diffo/provider/changes/relate.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Provider.Changes.Relate do
@moduledoc """
After-action change for the standard `:relate` pattern.

Creates relationships from the `:relationships` argument on the changeset via
`Relationship.relate_instance/2`, then reloads the result via the resource's
primary `:read` action.

## Usage

update :relate do
argument :relationships, {:array, :struct}
change Diffo.Provider.Changes.Relate
end
"""
use Ash.Resource.Change

require Ash.Query

alias Diffo.Provider.Instance.Relationship

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn changeset, result ->
with {:ok, result} <- Relationship.relate_instance(result, changeset) do
id = result.id

changeset.resource
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(id == ^id)
|> Ash.read_one()
end
end)
end
end
15 changes: 15 additions & 0 deletions lib/diffo/provider/components/base_instance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ defmodule Diffo.Provider.BaseInstance do
:features,
Instance.derive_feature_list_name(record.type)
)
|> Instance.merge_typed_and_pool_characteristics(record)
|> Util.suppress_rename(
:characteristics,
Instance.derive_characteristic_list_name(record.type)
Expand Down Expand Up @@ -688,6 +689,8 @@ defmodule Diffo.Provider.BaseInstance do
:notes,
:features,
:characteristics,
:typed_characteristics,
:pool_characteristics,
:places,
:parties
],
Expand All @@ -699,5 +702,17 @@ defmodule Diffo.Provider.BaseInstance do
calculate :href, :string, Diffo.Provider.Calculations.InstanceHref do
description "the inventory href of the service or resource instance"
end

calculate :typed_characteristics,
{:array, :struct},
Diffo.Provider.Calculations.TypedCharacteristics do
description "typed BaseCharacteristic records declared via the characteristics DSL"
end

calculate :pool_characteristics,
{:array, :struct},
Diffo.Provider.Calculations.PoolCharacteristics do
description "AssignableCharacteristic records declared via the pools DSL"
end
end
end
31 changes: 31 additions & 0 deletions lib/diffo/provider/components/calculations/pool_characteristics.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Provider.Calculations.PoolCharacteristics do
@moduledoc """
Loads the `AssignableCharacteristic` pool records associated with an
instance, one per `pool :name, :thing` declaration on the resource module.

Used by `BaseInstance` to surface pool characteristics alongside the
dynamic `Diffo.Provider.Characteristic` records in the
`serviceCharacteristic` / `resourceCharacteristic` JSON view (#169).
"""
use Ash.Resource.Calculation

@impl true
def load(_query, _opts, _context), do: []

@impl true
def calculate(records, _opts, _context) do
Enum.map(records, fn record ->
if function_exported?(record.__struct__, :pools, 0) and record.__struct__.pools() != [] do
Diffo.Provider.AssignableCharacteristic
|> Ash.Query.filter_input(instance_id: record.id)
|> Ash.read!(domain: Diffo.Provider)
else
[]
end
end)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Provider.Calculations.TypedCharacteristics do
@moduledoc """
Loads all typed `BaseCharacteristic`-derived records associated with an
instance.

For each `characteristic :role, ValueModule` declaration on the instance's
resource module — including both singular and `{:array, ValueModule}` forms
— this calculation queries `ValueModule` by `instance_id == record.id` and
returns the collected records as a flat list.

Used by `BaseInstance` to surface typed characteristics alongside the
dynamic `Diffo.Provider.Characteristic` records in the
`serviceCharacteristic` / `resourceCharacteristic` JSON view (#169).
"""
use Ash.Resource.Calculation

alias Diffo.Provider.Extension.Characteristic, as: CharacteristicHelper

@impl true
def load(_query, _opts, _context), do: []

@impl true
def calculate(records, _opts, _context) do
Enum.map(records, fn record ->
record
|> typed_value_modules()
|> Enum.flat_map(fn module ->
module
|> Ash.Query.filter_input(instance_id: record.id)
|> Ash.read!(domain: Diffo.Provider)
end)
end)
end

defp typed_value_modules(record) do
module = record.__struct__

if function_exported?(module, :characteristics, 0) do
module.characteristics()
|> Enum.map(fn %{value_type: value_type} ->
case value_type do
{:array, mod} -> mod
mod when is_atom(mod) -> mod
end
end)
|> Enum.uniq()
|> Enum.filter(&CharacteristicHelper.typed?/1)
else
[]
end
end
end
Loading
Loading