From 4fa185d571d4ba7fe80e195dd3d09d7dcbdf2883 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 10:47:19 +0930 Subject: [PATCH] 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.