Skip to content

Commit 4fa185d

Browse files
committed
source side validation
1 parent bcd4431 commit 4fa185d

31 files changed

Lines changed: 695 additions & 104 deletions

.formatter.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ spark_locals_without_parens = [
1515
feature: 1,
1616
feature: 2,
1717
id: 1,
18+
instance_ref: 2,
1819
is_enabled?: 1,
1920
major_version: 1,
2021
minor_version: 1,
@@ -23,14 +24,21 @@ spark_locals_without_parens = [
2324
parties: 3,
2425
party: 2,
2526
party: 3,
27+
party_ref: 2,
28+
party_ref: 3,
2629
patch_version: 1,
2730
place: 2,
2831
place: 3,
32+
place_ref: 2,
33+
place_ref: 3,
2934
places: 2,
3035
places: 3,
36+
pool: 2,
3137
reference: 1,
3238
role: 2,
3339
role: 3,
40+
source: 1,
41+
target: 1,
3442
tmf_version: 1,
3543
type: 1,
3644
update: 1,

AGENTS.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,14 @@ lib/diffo/provider/
4141
place_declaration.ex # PlaceDeclaration struct
4242
party_role.ex # PartyRole struct (Party/Place kinds)
4343
place_role.ex # PlaceRole struct (Party/Place kinds)
44-
persisters/ # Spark transformers — bake DSL state into module
45-
transformers/ # TransformBehaviour — action argument injection
46-
verifiers/ # Compile-time DSL correctness checks
44+
relationship_step.ex # RelationshipStep struct — pipeline step for relationships do
45+
persisters/ # Terminal bakers — run after all transformers; only read DSL state and bake module functions
46+
transformers/
47+
transform_relationships.ex # TransformRelationships — resolves relationships pipeline, bakes permitted_source_roles/0 and permitted_target_roles/0
48+
verifiers/
49+
verify_relationships.ex # Verifies relationship role declarations are atoms
50+
changes/
51+
validate_relationship_permitted.ex # ValidateRelationshipPermitted — enforces relationships do policy on relate actions
4752
assigner/
4853
assigner.ex # Diffo.Provider.Assigner — assign/3 (pools do) and assign/4
4954
assignable_characteristic.ex # AssignableCharacteristic — pool bounds + algorithm
@@ -72,6 +77,7 @@ test/provider/extension/ # All provider extension tests
7277
instance_verifier_test.exs
7378
party_verifier_test.exs
7479
place_verifier_test.exs
80+
relationship_dsl_test.exs # Transformer baking, verifier errors, integration enforcement
7581
party_test.exs # Integration: parties enforcement
7682
place_test.exs # Integration: places enforcement
7783
specification_test.exs # Integration: spec roundtrip
@@ -112,6 +118,11 @@ provider do
112118
pool :vlans, :vlan
113119
end
114120

121+
relationships do
122+
source [:provides, :requires] # pipeline — last step wins; omitting defaults to :none
123+
target :all
124+
end
125+
115126
features do
116127
feature :advanced_routing, is_enabled?: false do
117128
characteristic :policy, MyApp.RoutingPolicy
@@ -179,6 +190,22 @@ whose names end in the same word get the same label, which causes read collision
179190
E.g. `Diffo.Test.Instance.CardInstance` → label `:CardInstance`,
180191
and `Diffo.Test.Characteristic.CardCharacteristic` → label `:CardCharacteristic` — no collision.
181192

193+
## Spark transformer vs persister pipeline
194+
195+
Spark runs two separate pipelines during compilation, in this order:
196+
197+
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.
198+
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.
199+
3. **Verifiers** — read-only, run last.
200+
201+
**Rules:**
202+
- A module that injects into actions, modifies DSL state, or needs to order itself relative to Ash's own transformers belongs in `transformers:`.
203+
- A module that only reads final DSL state and bakes module functions belongs in `persisters:`.
204+
- 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`.
205+
- Do not put a transformer in `persisters:` hoping `after?` declarations will order it relative to transformers — those declarations are silently ignored across pipeline boundaries.
206+
207+
**Current state:** `TransformBehaviour` is misregistered under `persisters:` — a known issue tracked for refactoring. New transformers go under `transformers:`.
208+
182209
## Common agent mistakes
183210

184211
- Using old `structure do` / top-level `instances do` — use `provider do` only.
@@ -188,12 +215,18 @@ and `Diffo.Test.Characteristic.CardCharacteristic` → label `:CardCharacteristi
188215
- Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`.
189216
- Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically.
190217
- Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here.
191-
- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship``AssignedToRelationship` is not a characteristic; use `pools do / pool :name, :thing / end` instead.
192-
- Querying `Diffo.Provider.Relationship` for assignment records — assignment relationships are on `Diffo.Provider.AssignedToRelationship`; access them via `instance.assignments`.
218+
- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship``AssignedToRelationship` no longer exists; use `pools do / pool :name, :thing / end` instead.
219+
- Querying `Diffo.Provider.Relationship` for assignment records — assignments are stored as `Diffo.Provider.DefinedSimpleRelationship`; access them via `instance.assignments`.
193220
- Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly.
194221
- Calling `build_before/1` or `build_after/2` in actions — these run automatically.
195222
- Declaring `:specified_by`, `:features`, `:characteristics` as action arguments.
223+
- Using module names (e.g. `MyApp.CardInstance`) as role values in `relationships do` — roles are atoms like `:provides`, not module references.
224+
- 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.
225+
- 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.
196226
- Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated;
197-
run `mix spark.cheat_sheets` to regenerate it.
227+
run `mix spark.cheat_sheets` to regenerate it. Whenever you add, rename, or remove a DSL
228+
entity or section, also check `.formatter.exs` — new entity names must be added to
229+
`spark_locals_without_parens` (with each arity) so the Spark formatter omits parentheses.
230+
Run `mix format` afterward to verify.
198231
- Editing content between `<!-- usage-rules-start -->` markers in `CLAUDE.md` — that is
199232
auto-generated by `mix usage_rules.sync`.

documentation/dsls/DSL-Diffo.Provider.Extension.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ Provider DSL — structure, roles, and behaviour for this resource kind
111111
* [instances](#provider-instances)
112112
* role
113113
* instance_ref
114+
* [relationships](#provider-relationships)
115+
* source
116+
* target
114117
* [behaviour](#provider-behaviour)
115118
* actions
116119
* create
@@ -712,6 +715,75 @@ Declares a reference instance role — no direct edge created, reachable by grap
712715
Target: `Diffo.Provider.Extension.InstanceRole`
713716

714717

718+
### provider.relationships
719+
Relationship role permissions for this Instance — declares which aliases it may participate in as source or target. Omitting defaults to `:none` per direction.
720+
721+
### Nested DSLs
722+
* [source](#provider-relationships-source)
723+
* [target](#provider-relationships-target)
724+
725+
726+
### Examples
727+
```
728+
relationships do
729+
source [:provides, :requires]
730+
target :all
731+
end
732+
733+
```
734+
735+
736+
737+
738+
### provider.relationships.source
739+
```elixir
740+
source roles
741+
```
742+
743+
744+
Declares permitted source relationship roles — pipeline step, last declaration wins
745+
746+
747+
748+
749+
750+
### Arguments
751+
752+
| Name | Type | Default | Docs |
753+
|------|------|---------|------|
754+
| [`roles`](#provider-relationships-source-roles){: #provider-relationships-source-roles .spark-required} | `any` | | `:all`, `:none`, or a list of role name atoms. |
755+
756+
757+
758+
759+
760+
761+
762+
### provider.relationships.target
763+
```elixir
764+
target roles
765+
```
766+
767+
768+
Declares permitted target relationship roles — pipeline step, last declaration wins
769+
770+
771+
772+
773+
774+
### Arguments
775+
776+
| Name | Type | Default | Docs |
777+
|------|------|---------|------|
778+
| [`roles`](#provider-relationships-target-roles){: #provider-relationships-target-roles .spark-required} | `any` | | `:all`, `:none`, or a list of role name atoms. |
779+
780+
781+
782+
783+
784+
785+
786+
715787
### provider.behaviour
716788
Defines the behavioural wiring for the Instance — actions, and in future triggers
717789

documentation/how_to/use_diffo_provider_extension.livemd

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ arguments automatically onto that action.
153153

154154
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 `<Module>.Value` TypedStruct for ordered JSON encoding. The TypedStruct uses [AshJason.TypedStruct](https://hexdocs.pm/ash_jason/) to control field order in the JSON output.
155155

156-
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`.
156+
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`.
157157

158158
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.
159159

@@ -278,6 +278,11 @@ defmodule Diffo.Compute.Cluster do
278278
place :data_centre, Diffo.Compute.DataCentre
279279
end
280280

281+
relationships do
282+
source :all
283+
target :all
284+
end
285+
281286
behaviour do
282287
actions do
283288
create :build
@@ -426,6 +431,11 @@ defmodule Diffo.Compute.GPU do
426431
pool :cores, :core
427432
end
428433

434+
relationships do
435+
source :all
436+
target :all
437+
end
438+
429439
behaviour do
430440
actions do
431441
create :build
@@ -783,16 +793,16 @@ gpu_1 = Compute.assign_gpu_core!(gpu_1, assignment)
783793
gpu_2 = Compute.assign_gpu_core!(gpu_2, assignment)
784794
```
785795

786-
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).
796+
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).
787797

788798
The GPU's `assignments` hold each assignment, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`:
789799

790800
```elixir
791801
Jason.encode!(gpu_1, pretty: true) |> IO.puts
792802
```
793803

794-
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.
795-
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`.
804+
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.
805+
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`.
796806

797807
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.
798808

@@ -805,7 +815,7 @@ In this tutorial you've used Diffo's unified `provider do` extension to define a
805815

806816
- A composite Cluster resource that receives GPU cores via `Diffo.Provider.Assigner`
807817
- A GPU resource using `pools do` to declare the `:cores` assignable pool — `pool :cores, :core` replaces the old `characteristic :cores, AssignableValue` pattern
808-
- Assignments stored on `Diffo.Provider.AssignedToRelationship` nodes (Neo4j label `:AssignmentRelationship`, distinct from TMF `:Relationship` nodes); accessible via `instance.assignments`
818+
- Assignments stored as `Diffo.Provider.DefinedSimpleRelationship` records with `type: :assignedTo` (distinct from TMF `Relationship` nodes); accessible via `instance.assignments`
809819
- `Tenant` and `Engineer` Party kinds declared with `provider do` that express which instances they operate and manage
810820
- A `DataCentre` Place kind that declares the instances located at it
811821

lib/diffo/provider/assigner/assignable_characteristic.ex

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ defmodule Diffo.Provider.AssignableCharacteristic do
2020
plural_name :assignable_characteristics
2121
end
2222

23+
actions do
24+
create :create do
25+
accept [:name, :first, :last, :assignable_type, :algorithm, :thing]
26+
argument :instance_id, :uuid
27+
argument :feature_id, :uuid
28+
change manage_relationship(:instance_id, :instance, type: :append)
29+
change manage_relationship(:feature_id, :feature, type: :append)
30+
end
31+
32+
update :update do
33+
accept [:first, :last, :assignable_type, :algorithm]
34+
end
35+
end
36+
2337
attributes do
2438
attribute :first, :integer do
2539
description "the first assignable value in the pool"
@@ -56,13 +70,13 @@ defmodule Diffo.Provider.AssignableCharacteristic do
5670
end
5771

5872
calculations do
59-
calculate :value, Diffo.Type.CharacteristicValue,
73+
calculate :value,
74+
Diffo.Type.CharacteristicValue,
6075
Diffo.Provider.Calculations.CharacteristicValue do
6176
public? true
6277
end
6378

64-
calculate :assigned_values, {:array, :integer},
65-
Diffo.Provider.Calculations.AssignedValues do
79+
calculate :assigned_values, {:array, :integer}, Diffo.Provider.Calculations.AssignedValues do
6680
public? true
6781
argument :thing, :atom, allow_nil?: false
6882
end
@@ -72,20 +86,6 @@ defmodule Diffo.Provider.AssignableCharacteristic do
7286
end
7387
end
7488

75-
actions do
76-
create :create do
77-
accept [:name, :first, :last, :assignable_type, :algorithm, :thing]
78-
argument :instance_id, :uuid
79-
argument :feature_id, :uuid
80-
change manage_relationship(:instance_id, :instance, type: :append)
81-
change manage_relationship(:feature_id, :feature, type: :append)
82-
end
83-
84-
update :update do
85-
accept [:first, :last, :assignable_type, :algorithm]
86-
end
87-
end
88-
8989
preparations do
9090
prepare build(load: [:value, :free])
9191
end

lib/diffo/provider/assigner/assignable_characteristic/value.ex

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@ defmodule Diffo.Provider.AssignableCharacteristic.Value do
66
@moduledoc "JSON value struct for AssignableCharacteristic."
77
use Ash.TypedStruct, extensions: [AshJason.TypedStruct]
88

9+
jason do
10+
pick [:first, :last, :assignable_type, :algorithm]
11+
compact true
12+
rename assignable_type: :type
13+
end
14+
915
typed_struct do
1016
field :first, :integer, description: "the first assignable value in the pool"
1117
field :last, :integer, description: "the last assignable value in the pool"
1218
field :assignable_type, :string, description: "the type label of the assignable thing"
1319
field :algorithm, :atom, description: "the selection algorithm for auto-assign"
1420
end
1521

16-
jason do
17-
pick [:first, :last, :assignable_type, :algorithm]
18-
compact true
19-
rename assignable_type: :type
20-
end
21-
2222
defimpl String.Chars do
2323
def to_string(struct), do: inspect(struct)
2424
end

lib/diffo/provider/assigner/assigner.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ defmodule Diffo.Provider.Assigner do
154154
when is_struct(instance) and is_atom(pool) and is_atom(thing) and is_integer(value) do
155155
case pool_characteristic(instance.id, pool, thing) do
156156
{:ok, nil} -> false
157-
{:ok, char} -> value in Enum.to_list(char.first..char.last) -- char.assigned_values
157+
{:ok, char} -> value in (Enum.to_list(char.first..char.last) -- char.assigned_values)
158158
{:error, _} -> false
159159
end
160160
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Changes.ValidateRelationshipPermitted do
6+
@moduledoc false
7+
use Ash.Resource.Change
8+
9+
@impl true
10+
def change(changeset, _opts, _context) do
11+
case Ash.Changeset.get_argument(changeset, :relationships) do
12+
nil -> changeset
13+
[] -> changeset
14+
rels -> validate_source_roles(changeset, rels)
15+
end
16+
end
17+
18+
defp validate_source_roles(changeset, rels) do
19+
permitted = changeset.resource.permitted_source_roles()
20+
21+
Enum.reduce(rels, changeset, fn rel, cs ->
22+
role = Map.get(rel, :alias) || Map.get(rel, "alias")
23+
24+
case check_permitted(role, permitted) do
25+
:ok -> cs
26+
{:error, msg} -> Ash.Changeset.add_error(cs, msg)
27+
end
28+
end)
29+
end
30+
31+
defp check_permitted(_role, :all), do: :ok
32+
33+
defp check_permitted(_role, :none),
34+
do: {:error, "relationships are not permitted as source on this resource"}
35+
36+
defp check_permitted(role, roles) when is_list(roles) do
37+
if role in roles do
38+
:ok
39+
else
40+
{:error, "relationship role #{inspect(role)} is not permitted as source on this resource"}
41+
end
42+
end
43+
end

0 commit comments

Comments
 (0)