AI agent guidance for the Diffo source repository.
Diffo is a Telecommunications Management Forum (TMF) Service and Resource Manager, built on Ash Framework + AshNeo4j + Neo4j. It models TMF 638/639 Service and Resource inventory and provides a Spark DSL for defining domain-specific instance, party, and place kinds.
- Read
usage-rules.md— Diffo-specific DSL rules. - Read
CLAUDE.md— dependency usage rules (Ash, Elixir, OTP, AshNeo4j, Spark). - Consult the skill at
.claude/skills/diffo-framework/for Ash ecosystem patterns.
When updating a dependency (e.g. bumping ash_neo4j, ash, spark in mix.exs), always
run mix usage_rules.sync immediately after mix deps.get. Dependencies publish their own
usage rules; syncing pulls those changes into CLAUDE.md so you are working from the
up-to-date guidance before touching any code.
lib/diffo/type/
primitive.ex # Diffo.Type.Primitive — discriminated union of primitive Elixir types
value.ex # Diffo.Type.Value — union of Primitive and Dynamic
dynamic.ex # Diffo.Type.Dynamic — runtime-typed value (NewType with map storage)
name_value_primitive.ex # Diffo.Type.NameValuePrimitive — name/Primitive pair TypedStruct
name_value_array_primitive.ex # Diffo.Type.NameValueArrayPrimitive — name/[Primitive] pair TypedStruct
lib/diffo/provider/
extension.ex # Unified Spark DSL extension (provider do)
extension/
info.ex # Runtime introspection via Spark.InfoGenerator
characteristic.ex # Characteristic build helpers
feature.ex # Feature build helpers
pool.ex # Pool struct + create_pools/2 + update_pools/3
instance_role.ex # InstanceRole struct
party_declaration.ex # PartyDeclaration struct
place_declaration.ex # PlaceDeclaration struct
party_role.ex # PartyRole struct (Party/Place kinds)
place_role.ex # PlaceRole struct (Party/Place kinds)
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_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/
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
base_instance.ex # Ash Fragment for Instance resources
base_party.ex # Ash Fragment for Party resources
base_place.ex # Ash Fragment for Place resources
components/
base_characteristic.ex # Ash Fragment for typed characteristic resources
base_relationship.ex # Ash Fragment for shared Relationship structure
defined_simple_relationship.ex # DefinedSimpleRelationship — relationship with one optional embedded characteristic, frozen at creation
assignment_relationship.ex # AssignmentRelationship — pool assignment relationship with top-level pool/thing/value/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
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
test/provider/
defined_simple_relationship_test.exs # Integration: DefinedSimpleRelationship create/destroy + DefinedCharacteristic encoding
test/provider/extension/ # All provider extension tests
instance_transformer_test.exs
party_transformer_test.exs
place_transformer_test.exs
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
characteristic_test.exs # Integration: characteristic creation
feature_test.exs # Integration: feature creation
assigner_test.exs # Integration: resource assignment
test/support/
resources/ # Test domain resources (Shelf, Card, Organization, etc.)
domains/ # Test domains (Servo, Nbn)
All DSL declarations use a single provider do section — there is no structure do,
top-level behaviour do, or bare instances/parties/places do.
provider do
specification do
id "da9b207a-..." # stable UUID4 — never change after first commit
name "myService" # camelCase
type :serviceSpecification
major_version 1
description "..."
category "..."
end
characteristics do
characteristic :slot_value, MyApp.SlotCharacteristic
characteristic :ports, {:array, MyApp.PortCharacteristic}
end
pools do
pool :cores, :core # assignable pool; thing name is :core
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
end
end
parties do
party :provider, MyApp.RSP # singular, direct edge
parties :engineers, MyApp.Engineer, constraints: [min: 1, max: 5]
party_ref :owner, MyApp.Organization # no direct edge
party :operator, MyApp.RSP, calculate: :derive_operator
end
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
actions do
create :build # injects :specified_by, :features, :characteristics
end
end
endprovider do
instances do
role :provider, MyApp.BroadbandService
instance_ref :manages, MyApp.InternalService # no direct edge
end
parties do
role :employer, MyApp.Organization
end
places do
role :headquarters, MyApp.GeographicSite
end
endDiffo supports three distinct usage patterns. Every test is tagged with one or more of these atoms — absence of all three means the test has not yet been classified.
| Tag | Scenario | Description |
|---|---|---|
:provider_only |
Vanilla Provider | Uses Diffo.Provider resources as-is. No custom domains, no extensions. Good for basic TMF inventory and for introducing Diffo incrementally. |
:provider_extended |
Extended within Provider | New resource types defined inside Diffo.Provider itself, extending base fragments (e.g. DefinedSimpleRelationship). Pain point: external users can't add to the Provider domain without forking Diffo. |
:domain_extended |
True domain extension | The recommended pattern. An external domain (e.g. MyApp.SRM) owns resources using BaseInstance, BaseParty, BasePlace, and BaseCharacteristic fragments. Exposes its own API; consumers need not know about Diffo internals. |
Tests may carry :provider_extended and :domain_extended together when they span both.
:provider_only is mutually exclusive with the other two.
Any domain whose resources carry belongs_to :instance, Diffo.Provider.Instance (or
belongs_to :party, Diffo.Provider.Party) and use manage_relationship to relate them
must include Diffo.Provider.DomainFragment:
defmodule MyApp.SRM do
use Ash.Domain, fragments: [Diffo.Provider.DomainFragment]
...
endWhy this is necessary. AshNeo4j 0.6.0 matches nodes using
label_pair = [domain_label, module_label]. Ash.get(Diffo.Provider.Instance, uuid) builds
MATCH (n:Provider:Instance {uuid: $uuid}). A ShelfInstance node in MyApp.SRM has
labels [:SRM, :ShelfInstance, :Instance] — :Provider is absent, so the lookup returns
not-found and manage_relationship fails.
Diffo.Provider.DomainFragment tells AshNeo4j to write :Provider as an extra label on
every node in the domain at CREATE time. ShelfInstance then carries
[:SRM, :ShelfInstance, :Instance, :Provider]. Neo4j matches nodes that have all
specified labels regardless of extras, so MATCH (n:Provider:Instance {uuid: $uuid}) finds
it. label_pair for direct reads on ShelfInstance is still [:SRM, :ShelfInstance] —
its own-domain reads remain correctly scoped.
A separate constraint applies when a has_many relationship uses manage_relationship on
the source side: AshNeo4j 0.6.0's accessing_from path calls
Ash.Resource.Info.reverse_relationship/2, which does a strict type-equality check. If
Characteristic.belongs_to :instance targets Diffo.Provider.Instance but the actual
source is ShelfInstance, the check fails and the edge is not created.
The fix used in Diffo's extension helpers (Characteristic.relate_instance,
Feature.relate_instance) is to bypass manage_relationship on the source side entirely
and call AshNeo4j.Neo4jHelper.relate_nodes/6 directly, using the concrete
result.__struct__ label pair. See
lib/diffo/provider/components/instance/extension/characteristic.ex and feature.ex.
Integration tests require a running Neo4j instance.
mix test # full suite
mix test --only domain_extended # scenario 3 tests only
mix test --only provider_only # vanilla provider tests only
mix test --only provider_extended # extended-within-provider tests only
mix test test/provider/extension/ # extension directory only
mix test path/to/test.exs:LINE # single test
mix test --max-failures 5 # stop earlyAshNeo4j derives a node label from the last segment of the module name. Two resources whose names end in the same word get the same label, which causes read collisions.
Rule: suffix every resource module with its kind so the last segment is unique:
- Instance resources:
MyApp.Instance.WidgetInstance(notMyApp.Instance.Widget) - Characteristic resources:
MyApp.Characteristic.WidgetCharacteristic(notMyApp.Characteristic.Widget) - Party/Place resources: follow the same convention if ambiguity is possible.
E.g. Diffo.Test.Instance.CardInstance → label :CardInstance,
and Diffo.Test.Characteristic.CardCharacteristic → label :CardCharacteristic — no collision.
Spark runs two separate pipelines during compilation, in this order:
- Transformers (
transformers:in the extension) — run in dependency order viabefore?/after?. Can read and modify DSL state. May also callTransformer.persist/3to bake results — a transformer that had to compute something to do its job should persist that result rather than delegating to a separate persister. - 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. - 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/3inline and emit the module function viaTransformer.eval/3. - Do not put a transformer in
persisters:hopingafter?declarations will order it relative to transformers — those declarations are silently ignored across pipeline boundaries.
New transformers go under transformers:. New persisters go under persisters:.
Whenever you add, rename, or remove a DSL entity or section in Diffo.Provider.Extension
(or any Spark extension in this project), run this checklist in order:
-
Update
.formatter.exs— add new entity names tospark_locals_without_parenswith each supported arity. Without this,mix formatwill add unwanted parentheses to every DSL call site. -
Run
mix format— apply formatting across the codebase and verify the output looks correct. Runmix format --check-formattedto confirm nothing was missed. -
Run
mix spark.cheat_sheets— regeneratesdocumentation/dsls/DSL-Diffo.Provider.Extension.md. This file is Spark-generated; never edit it by hand. Commit the regenerated file alongside the DSL change. -
Run
mix test— confirm no regressions.
Do not skip step 1 even for a "small" entity addition — the formatter will silently reformat every call site in CI and produce noisy diffs in future PRs.
When a bug is found in a dependency (e.g. AshNeo4j, Bolty), raise a GitHub issue on that repository. Use diffo issue #125 as the style reference:
- ## Description — explain what the system does, what the code path is, and where it breaks. Include a short code snippet if it makes the failure concrete.
- ## What we need — state the correct behaviour plainly.
- ## Why it matters — explain the practical impact on Diffo and why fixing it unblocks real work.
- Optionally add ## A possible direction if there is a plausible fix worth suggesting.
Do not use a step-by-step reproduction template; write in the same explanatory prose style as #125.
Once the issue is raised, stop. Do not attempt to locate or fix the root cause in the dependency — the upstream maintainers have the full context of their own codebase; you do not. Add any useful hypotheses as a follow-up comment on the issue, then leave it with them.
- Using old
structure do/ top-levelinstances do— useprovider doonly. - Using
party :role, Type, reference: true— useparty_ref :role, Type. - Using a plain
Ash.TypedStructas acharacteristicDSL target — use aBaseCharacteristic-derived resource instead; the TypedStruct belongs in<Module>.Value. - Using
characteristic :name, Diffo.Provider.AssignableCharacteristicfor pools — usepools do / pool :name, :thing / endinstead. - Using the removed
AssignableValueTypedStruct — it no longer exists; usepools do. - Calling
Assigner.assign/4when apools dodeclaration exists — preferAssigner.assign/3which looks up the thing automatically. - Hand-writing the
:define/:relate/:assign_*after-action plumbing — useDiffo.Provider.Changes.Define,Diffo.Provider.Changes.Relate, and{Diffo.Provider.Changes.Assign, pool: :name}(since 0.4.1). The change modules threadCharacteristic.update_all/3,Pool.update_pools/3,Relationship.relate_instance/2andAssigner.assign/3together and reload via the resource's primary:readaction. - Hand-writing the
:create/:updateaccept lists on aBaseCharacteristic-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/3on an instance that is not in the correct lifecycle state — the assigner enforces: resource instances must haveresource_stateof:installingor:operating; service instances must haveservice_stateof:feasibilityChecked,:reserved,:inactive,:active, or:suspended(since 0.4.1). The full lists are exposed viaAssigner.assignable_resource_states/0andAssigner.assignable_service_states/0. Lifecycle state transitions are an internal domain concern managed by the provider; assignment actions are external-facing. - Wondering why
RelationshipandAssignmentRelationshipboth have analiasattribute 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—AssignedToRelationshipno longer exists; usepools do / pool :name, :thing / endinstead. - Querying
Diffo.Provider.Relationshipfor assignment records — assignments are stored asDiffo.Provider.DefinedSimpleRelationship; access them viainstance.assignments. - Filtering
instance.forward_relationshipsfortype == :assignedTo— those records no longer exist there; useinstance.assignmentsdirectly. - Calling
build_before/1orbuild_after/2in actions — these run automatically. - Declaring
:specified_by,:features,:characteristicsas action arguments. - Using module names (e.g.
MyApp.CardInstance) as role values inrelationships do— roles are atoms like:provides, not module references. - Forgetting that
relationships doomitted means:nonefor both source and target — any update action withargument :relationships, {:array, :struct}will fail unless the resource declares permissions. - Thinking the Assigner requires
relationships dopermissions — it does not. The Assigner writesDefinedSimpleRelationshiprecords directly via the Provider domain;ValidateRelationshipPermittedonly runs on actions that carryargument :relationships, {:array, :struct}, which the Assigner'sassign_*actions do not. - Editing
documentation/dsls/DSL-Diffo.Provider.Extension.md— it is Spark-generated. See the DSL shape changes section above for the full checklist. - Editing content between
<!-- usage-rules-start -->markers inCLAUDE.md— that is auto-generated bymix usage_rules.sync. - Forgetting
Diffo.Provider.DomainFragmenton a scenario 3 domain — any domain whose resources relate back to Provider base types (belongs_to :instance, Diffo.Provider.Instanceetc.) viamanage_relationshipwill getAsh.Error.Query.NotFoundat runtime without it. See the Domain extension pattern section above. - Bypassing
manage_relationshipby replacingargument + manage_relationshipwith bareacceptfor relationship IDs in scenario 3 resources — the correct fix is the domain fragment, not removing the relationship management. - Writing
Ash.Resource.Validationwith fail-fast short-circuits between independent checks — Ash uses Splode to accumulate errors, so all independent validations should run and all errors should be collected before returning. Resist the imperative instinct to return on the first failure; instead collect errors from every check and return the full list in one{:error, errors}. Only short-circuit when a later check genuinely cannot run without the earlier one succeeding (e.g. the earlier check resolves data the later check depends on). - Using
Ash.Resource.Changefor pure permission or constraint checks — anything that only decides valid/invalid with no side effects belongs inAsh.Resource.Validation, not a change. Changes are for mutations. - Using
inherited_placeorinherited_partywithout 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 thealias:field onAssignmentmatches the declared role (or thevia:step) before expecting results. - Referencing
Diffo.Provider.Calculations.InheritedPlaceorInheritedPartydirectly incalculations do— these are internal modules injected by the transformer. Use theinherited_place/inherited_partyDSL entities inplaces do/parties doinstead. - Reaching for
FieldViaRelationshipto traverse anAssignmentRelationship— that module traversesDefinedSimpleRelationship(forward, source → target). For assignments (reverse, target → source) useFieldViaAssignedRelationshiporFieldFromAssignment. - Querying
FieldViaRelationshipwithout supplyingalias:ortype:— a source instance typically has many forwardDefinedSimpleRelationshiprecords pointing to unrelated things. Without at least one filter the result is a noisy mix. Always supplyalias:,type:, or both.