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
32 changes: 29 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
108 changes: 108 additions & 0 deletions documentation/how_to/use_diffo_provider_extension.livemd
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion lib/diffo/provider/components/calculations/inherited_party.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion lib/diffo/provider/components/calculations/inherited_place.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion lib/diffo/provider/extension/inherited_party_declaration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion lib/diffo/provider/extension/inherited_place_declaration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading