Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ diffo-*.tar
.DS_Store

# Agent related
.claude/*
.claude/*
CLAUDE.md
21 changes: 20 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ defmodule Diffo.MixProject do
docs: &docs/0,
deps: deps(),
aliases: aliases(),
consolidate_protocols: Mix.env() != :dev
consolidate_protocols: Mix.env() != :dev,
usage_rules: usage_rules()
]
end

Expand Down Expand Up @@ -93,9 +94,27 @@ defmodule Diffo.MixProject do
]
end

defp usage_rules do
[
file: "CLAUDE.md",
usage_rules: ["usage_rules:all"],
skills: [
location: ".claude/skills",
build: [
"diffo-framework": [
description:
"Use when working with Diffo or its underlying Ash ecosystem. Consult when making any domain, resource, or provider changes.",
usage_rules: [:ash, :ash_neo4j, :spark, :reactor, :igniter]
]
]
]
]
end

# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:usage_rules, "~> 1.2", only: [:dev]},
{:ash_outstanding, "~> 0.2.3"},
{:ash_jason, "~> 3.0"},
{:ash_state_machine, "~> 0.2.12"},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"},
"uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
Expand Down
181 changes: 181 additions & 0 deletions usage-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<!--
SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Rules for working with Diffo

## What Diffo is

Diffo is an Ash Framework layer that models [TM Forum](https://www.tmforum.org/) (TMF) Service
and Resource Management domains on top of a Neo4j graph database. It provides three base
fragments — `BaseInstance`, `BaseParty`, `BasePlace` — plus the `Diffo.Provider.Instance.Extension`
and `Diffo.Provider.Party.Extension` DSLs. Read these rules and the Ash/AshNeo4j usage rules
**before** writing any domain code.

## The three kinds of domain resource

| Kind | Base fragment | DSL extension |
|---|---|---|
| Instance (service or resource) | `Diffo.Provider.BaseInstance` | `Diffo.Provider.Instance.Extension` |
| Party (organisation, person, entity) | `Diffo.Provider.BaseParty` | `Diffo.Provider.Party.Extension` |
| Place (site, address, location) | `Diffo.Provider.BasePlace` | `Diffo.Provider.Party.Extension` |

Do **not** use plain `Ash.Resource` + `AshNeo4j.DataLayer` directly for domain resources.
Always start from the appropriate base fragment:

```elixir
defmodule MyApp.BroadbandService do
use Ash.Resource, fragments: [Diffo.Provider.BaseInstance], domain: MyApp.Domain
...
end
```

## Instance Extension DSL

Every resource using `BaseInstance` gains two top-level DSL sections: `structure do` and
`behaviour do`.

### structure

`specification do` — declares the TMF Specification for this Instance kind. The `id` is a
**stable UUID4 that must be the same in every environment** — generate it once and never
change it. A new major version requires a new module with a new `id`.

```elixir
structure do
specification do
id "da9b207a-26c3-451d-8abd-0640c6349979"
name "DSL Access Service"
type :serviceSpecification
major_version 1
description "An access network service connecting a subscriber premises to an NNI via DSL"
category "Network Service"
end
end
```

`characteristics do` — declares typed value slots. Each characteristic is backed by an
`Ash.TypedStruct`. Do **not** add plain Ash attributes for data that belongs in a characteristic.

```elixir
characteristics do
characteristic :downstream_speed, MyApp.Speed
characteristic :access_technology, MyApp.AccessTechnology
end
```

`features do` — declares optional capabilities, each with an enabled/disabled default and
optionally its own typed characteristic payload:

```elixir
features do
feature :voice, is_enabled?: false
feature :static_ip, is_enabled?: false do
characteristic :ip_address, MyApp.IpAddress
end
end
```

`parties do` — declares party roles. Use `party` for singular (at most one) and `parties`
for plural relationships:

```elixir
parties do
party :provider, MyApp.RSP
parties :installer, MyApp.Engineer, constraints: [min: 1, max: 3]
party :owner, MyApp.Organization, reference: true
party :operator, MyApp.RSP, calculate: :derive_operator
end
```

- `reference: true` — no direct `PartyRef` edge is created; the party is reachable by graph
traversal. Do not add a `PartyRef` relationship manually when `reference: true` is set.
- `calculate:` — names an Ash calculation on this resource that produces the party struct at
build time. The calculation runs inside `build_before/1`; do not call it manually.

`places do` — mirrors `parties do` in structure and options:

```elixir
places do
place :installation_site, MyApp.GeographicSite
places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1]
end
```

### behaviour

`behaviour do actions do create :name end end` — marks a named create action for build
wiring. This injects the `:specified_by`, `:features`, and `:characteristics` Ash action
arguments automatically. Do **not** declare these arguments in the action body.

```elixir
behaviour do
actions do
create :build
end
end
```

## Generated functions on Instance resources

Every resource with a complete `specification do` block gets these compile-time generated
functions:

- `specification/0`, `characteristics/0`, `features/0`, `parties/0`, `places/0`
- `characteristic/1`, `feature/1`, `feature_characteristic/2`, `party/1`, `place/1`
- `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
automatically after every create action.

**Never call `build_before/1` or `build_after/2` manually** in action bodies or changesets.
They are wired to every create action via global `BuildBefore` and `BuildAfter` changes on
`BaseInstance`.

## Instance versioning

- **Minor/patch version bumps** — update `minor_version` or `patch_version` in `specification do`.
The existing Specification node is updated in place. No instance changes required.
- **Major version bump** — create a new module (e.g. `BroadbandServiceV2`) with a new `id`
and `major_version 2`. The original module and all its instances remain untouched.
- **Never change the `id`** of an existing specification. It is a stable cross-environment
identity; changing it orphans existing instances.

## Party and Place resources

Party and Place resources use `BaseParty`/`BasePlace` and the Party Extension DSL to declare
the Instance and Party roles they participate in:

```elixir
defmodule MyApp.RSP do
use Ash.Resource, fragments: [Diffo.Provider.BaseParty], domain: MyApp.Domain

instances do
role :provider, MyApp.BroadbandService
role :provider, MyApp.VoiceService
end

parties do
role :employer, MyApp.Organization
end
end
```

Role names are domain nouns from the party's perspective — timeless, `camelCase` when
multi-word (e.g. `:dataCentre`, not `:data_centre`).

## Common mistakes

- **Do not add raw Ash attributes for TMF-modelled data** — use `characteristics`, `features`,
`parties`, and `places` in the DSL instead.
- **Do not declare `:specified_by`, `:features`, or `:characteristics` Ash action arguments**
— the `behaviour do` block injects them automatically.
- **Do not call `build_before/1` / `build_after/2` yourself** — they run automatically.
- **Do not create a separate Specification resource manually** — the Specification node is
managed entirely by the `build_before/1` generated function.
- **Do not use `party/1` in place of `parties/3`** (and vice versa) — `party` declares a
singular role; `parties` declares a plural role. Mismatching causes compile-time errors.
- **Do not set a `referred_type` without also setting `type: :PartyRef`** — TMF requires
both fields when using a party reference.
Loading