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
425 changes: 425 additions & 0 deletions documentation/how_to/use_diffo_provider_versioning.livemd

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/diffo/provider.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ defmodule Diffo.Provider do
define :suspend_service, action: :suspend
define :terminate_service, action: :terminate
define :status_service, action: :status
define :specify_instance, action: :specify
define :respecify_instance, action: :specify
define :relate_instance_features, action: :relate_features
define :unrelate_instance_features, action: :unrelate_features
define :relate_instance_characteristics, action: :relate_characteristics
Expand Down
20 changes: 20 additions & 0 deletions lib/diffo/provider/components/base_instance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,26 @@ defmodule Diffo.Provider.BaseInstance do
lightweight admin create), you can override `build_before/1` or `build_after/2` on your
resource, or use Ash's `skip_unknown_inputs` to absorb the injected arguments without
declaring them.

## Instance versioning

Each Instance kind is tied to a specific major version of its Specification via the `id`
declared in `specification do`. Patch and minor version bumps update the existing
Specification node in place and require no instance changes. Major version bumps introduce
a new Instance kind module (e.g. `BroadbandV2`) with a new `id` and `major_version`,
leaving the original module and all its instances untouched.

To migrate an existing instance from one major version to another, call
`Diffo.Provider.respecify_instance/2` with the new specification's id:

{:ok, v2_spec} = Diffo.Provider.get_specification_by_id(BroadbandV2.specification()[:id])
{:ok, migrated} = Diffo.Provider.respecify_instance(instance, %{specified_by: v2_spec.id})

Any breaking data changes (e.g. a characteristic value that no longer exists in V2) must
be handled before or as part of respecification — either via Cypher directly against the
graph or via a domain-specific migration action you build on your own resource.

See `Diffo.Provider.Specification` for the full versioning lifecycle.
"""
use Spark.Dsl.Fragment,
of: Ash.Resource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ defmodule Diffo.Provider.Instance.Specification do
def relate_instance(result, changeset)
when is_struct(result) and is_struct(changeset, Ash.Changeset) do
specified_by = Ash.Changeset.get_argument(changeset, :specified_by)
Provider.specify_instance(%Instance{id: result.id}, %{specified_by: specified_by})
Provider.respecify_instance(%Instance{id: result.id}, %{specified_by: specified_by})
end

defimpl String.Chars do
Expand Down
48 changes: 47 additions & 1 deletion lib/diffo/provider/components/specification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,53 @@

defmodule Diffo.Provider.Specification do
@moduledoc """
Ash Resource for a TMF Service or Resource Specification
Ash Resource for a TMF Service or Resource Specification.

A Specification identifies the kind of a TMF Service or Resource Instance. Every instance
carries a relationship to exactly one Specification node in the graph, established at build
time and changeable via `Diffo.Provider.respecify_instance/2`.

## Identity

A Specification is uniquely identified by `{name, major_version}`. The `id` is a stable
UUID4 that is the same across all environments for a given `{name, major_version}` pair —
it is typically declared as a constant in the Instance Extension DSL and committed to source
control.

## Versioning

Diffo uses semantic versioning for Specifications with three independent mechanisms:

| Change | Mechanism | Instance impact | Intended usage |
| ------ | ----------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Patch | `next_patch_specification!/1` | None — internal fix | Corrections to metadata: description wording, category typos |
| Minor | `next_minor_specification!/1` | None — all instances immediately reflect new version | Backward-compatible additions: new optional characteristics, new enum values |
| Major | New module, new `id`, new `major_version` | Instances stay on old spec until explicitly migrated | Breaking changes |

What constitutes a breaking change is deliberately vague — it depends on the specification
domain and may require negotiation between provider and consumers.

## Major version lifecycle

Major versions are decoupled across the provider/consumer boundary:

1. **Provider publishes V2** — deploys a new Instance kind module (e.g. `BroadbandV2`)
with the same specification `name`, a new `id`, and `major_version: 2`. V1 and V2
coexist; both can be used to create instances.
2. **Consumers adopt at their own pace** — each consumer (e.g. an RSP) decides when to
start creating V2 instances and when to migrate existing V1 instances.
3. **Provider withdraws V1** — removes the V1 module. Existing V1 instances remain in
the graph and continue to operate; the domain API for creating new V1 instances is
removed.
4. **Consumers complete migration** — each consumer migrates remaining V1 instances to V2
via `Diffo.Provider.respecify_instance/2`, handling any breaking data changes (e.g.
remapping or removing an enum value) before or as part of the respecification.

## create upsert behaviour

`create_specification/1` uses `upsert? true` on the `{name, major_version}` identity.
Calling it for an existing `{name, major_version}` pair preserves any attributes not
supplied — a second call without `category` leaves the existing category intact.
"""
require Ash.Resource.Change.Builtins

Expand Down
6 changes: 3 additions & 3 deletions test/provider/instance_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ defmodule Diffo.Provider.InstanceTest do
Diffo.Provider.create_specification!(%{name: "wifiAccess", major_version: 2})

updated_instance =
instance |> Diffo.Provider.specify_instance!(%{specified_by: new_specification.id})
instance |> Diffo.Provider.respecify_instance!(%{specified_by: new_specification.id})

assert updated_instance.specification.id == new_specification.id
end
Expand All @@ -463,15 +463,15 @@ defmodule Diffo.Provider.InstanceTest do
instance = Diffo.Provider.create_instance!(%{specified_by: specification.id})

{:error, _error} =
instance |> Diffo.Provider.specify_instance(%{specified_by: UUID.uuid4()})
instance |> Diffo.Provider.respecify_instance(%{specified_by: UUID.uuid4()})
end

test "update a service instance specification - failure - not a uuid" do
specification = Diffo.Provider.create_specification!(%{name: "wifiAccess"})
instance = Diffo.Provider.create_instance!(%{specified_by: specification.id})

{:error, _error} =
instance |> Diffo.Provider.specify_instance(%{specified_by: "not a uuid"})
instance |> Diffo.Provider.respecify_instance(%{specified_by: "not a uuid"})
end

test "annotate a service instance with a note - success" do
Expand Down
152 changes: 152 additions & 0 deletions test/provider/versioning_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Provider.VersioningTest do
@moduledoc false
use ExUnit.Case

alias Diffo.Test.Servo
alias Diffo.Test.Broadband
alias Diffo.Test.BroadbandV2

setup_all do
AshNeo4j.BoltyHelper.start()
end

setup do
on_exit(fn ->
AshNeo4j.Neo4jHelper.delete_all()
end)
end

describe "minor version — backward-compatible change" do
# A minor version represents a non-breaking change such as adding a new technology type.
# The specification node is updated in place — no migration of any kind is required.
# All existing instances immediately reflect the new version.

test "minor version bump updates the specification node to v1.1.0" do
Servo.build_broadband(%{})
{:ok, spec} = Diffo.Provider.get_specification_by_id(Broadband.specification()[:id])
assert spec.version == "v1.0.0"

minored = Diffo.Provider.next_minor_specification!(spec)
assert minored.version == "v1.1.0"
end

test "all existing V1 instances immediately reflect the new minor version" do
{:ok, v1_a} = Servo.build_broadband(%{})
{:ok, v1_b} = Servo.build_broadband(%{})

{:ok, spec} = Diffo.Provider.get_specification_by_id(Broadband.specification()[:id])
Diffo.Provider.next_minor_specification!(spec)

{:ok, reloaded_a} = Diffo.Provider.get_instance_by_id(v1_a.id)
{:ok, reloaded_b} = Diffo.Provider.get_instance_by_id(v1_b.id)
assert reloaded_a.specification.version == "v1.1.0"
assert reloaded_b.specification.version == "v1.1.0"
end

test "minor version freeze — removing behaviour do blocks creation without a new module" do
# When NBN removes behaviour do from Broadband and deploys v1.1, build_broadband
# disappears from the domain API. This is the machine-readable announcement of the freeze.
# Existing instances are unaffected; all other operations continue via the module.
# This cannot be demonstrated in a single test suite since the module is fixed at
# compile time, but the mechanism is proven by the BroadbandV1_1 fixture pattern:
# same spec id, no behaviour do block, no build wired in the domain.
assert Broadband.specification()[:id] == "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
assert function_exported?(Diffo.Test.Servo, :build_broadband, 2)
refute function_exported?(Diffo.Test.Servo, :build_broadband_v1_1, 2)
end
end

describe "major version — concurrent V1 and V2" do
test "V1 and V2 specifications coexist with same name and different major_version" do
Servo.build_broadband(%{})
Servo.build_broadband_v2(%{})

specs = Diffo.Provider.find_specifications_by_name!("broadband")
assert length(specs) == 2

versions = Enum.map(specs, & &1.major_version) |> Enum.sort()
assert versions == [1, 2]
end

test "V1 instance is linked to V1 specification" do
{:ok, v1} = Servo.build_broadband(%{})
assert v1.specification_id == Broadband.specification()[:id]
end

test "V2 instance is linked to V2 specification" do
{:ok, v2} = Servo.build_broadband_v2(%{})
assert v2.specification_id == BroadbandV2.specification()[:id]
end

test "V1 and V2 instances operate concurrently" do
{:ok, v1} = Servo.build_broadband(%{})
{:ok, v2} = Servo.build_broadband_v2(%{})

v1_instances = Diffo.Provider.find_instances_by_specification_id!(Broadband.specification()[:id])
v2_instances = Diffo.Provider.find_instances_by_specification_id!(BroadbandV2.specification()[:id])

assert length(v1_instances) == 1
assert length(v2_instances) == 1
assert v1.specification_id != v2.specification_id
end
end

describe "major version — RSP migration from V1 to V2" do
# V2 must be published (specification node created) before any instance can be
# respecified to it. Building the first V2 instance is what publishes the specification.
setup do
{:ok, _} = Servo.build_broadband_v2(%{})
:ok
end

test "V1 instance is respecified to V2 via respecify_instance" do
{:ok, v1} = Servo.build_broadband(%{})
{:ok, instance} = Diffo.Provider.get_instance_by_id(v1.id)

{:ok, migrated} = Diffo.Provider.respecify_instance(instance, %{
specified_by: BroadbandV2.specification()[:id]
})

assert migrated.specification.id == BroadbandV2.specification()[:id]
end

test "migrated instance is found by V2 specification" do
{:ok, v1} = Servo.build_broadband(%{})
{:ok, instance} = Diffo.Provider.get_instance_by_id(v1.id)
{:ok, _} = Diffo.Provider.respecify_instance(instance, %{
specified_by: BroadbandV2.specification()[:id]
})

v2_instances = Diffo.Provider.find_instances_by_specification_id!(BroadbandV2.specification()[:id])
assert Enum.any?(v2_instances, &(&1.id == v1.id))
end

test "migrated instance is no longer found by V1 specification" do
{:ok, v1} = Servo.build_broadband(%{})
{:ok, instance} = Diffo.Provider.get_instance_by_id(v1.id)
{:ok, _} = Diffo.Provider.respecify_instance(instance, %{
specified_by: BroadbandV2.specification()[:id]
})

v1_instances = Diffo.Provider.find_instances_by_specification_id!(Broadband.specification()[:id])
refute Enum.any?(v1_instances, &(&1.id == v1.id))
end

test "V1 withdrawal — all V1 instances migrated, none remain on V1" do
{:ok, v1_a} = Servo.build_broadband(%{})
{:ok, v1_b} = Servo.build_broadband(%{})

{:ok, instance_a} = Diffo.Provider.get_instance_by_id(v1_a.id)
{:ok, instance_b} = Diffo.Provider.get_instance_by_id(v1_b.id)
{:ok, _} = Diffo.Provider.respecify_instance(instance_a, %{specified_by: BroadbandV2.specification()[:id]})
{:ok, _} = Diffo.Provider.respecify_instance(instance_b, %{specified_by: BroadbandV2.specification()[:id]})

assert Diffo.Provider.find_instances_by_specification_id!(Broadband.specification()[:id]) == []
assert length(Diffo.Provider.find_instances_by_specification_id!(BroadbandV2.specification()[:id])) == 3
end
end
end
50 changes: 50 additions & 0 deletions test/support/resource/broadband.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Test.Broadband do
@moduledoc """
Diffo - TMF Service and Resource Management with a difference

Broadband - V1 broadband service, demonstrating the simple BaseInstance pattern.
Technology options include :fttb. The breaking change in BroadbandV2 is the
removal of :fttb from the supported technology types.
"""
alias Diffo.Provider.BaseInstance
alias Diffo.Test.Servo

use Ash.Resource,
fragments: [BaseInstance],
domain: Servo

resource do
description "A Broadband Service Instance (V1)"
plural_name :broadbands
end

structure do
specification do
id "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
name "broadband"
type :serviceSpecification
major_version 1
description "A broadband access service"
category "Access"
end
end

behaviour do
actions do
create :build
end
end

actions do
create :build do
accept [:id, :name, :type]
change set_attribute(:type, :service)
change load [:href]
upsert? false
end
end
end
50 changes: 50 additions & 0 deletions test/support/resource/broadband_v2.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule Diffo.Test.BroadbandV2 do
@moduledoc """
Diffo - TMF Service and Resource Management with a difference

BroadbandV2 - V2 broadband service. Breaking change from V1: :fttb has been
removed from supported technology types, requiring data remediation on any V1
instance with technology: :fttb before respecification.
"""
alias Diffo.Provider.BaseInstance
alias Diffo.Test.Servo

use Ash.Resource,
fragments: [BaseInstance],
domain: Servo

resource do
description "A Broadband Service Instance (V2)"
plural_name :broadband_v2s
end

structure do
specification do
id "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c"
name "broadband"
type :serviceSpecification
major_version 2
description "A broadband access service — :fttb technology retired"
category "Access"
end
end

behaviour do
actions do
create :build
end
end

actions do
create :build do
accept [:id, :name, :type]
change set_attribute(:type, :service)
change load [:href]
upsert? false
end
end
end
Loading
Loading