Skip to content

Commit 2cb7374

Browse files
Merge pull request #98 from diffo-dev/96-document-instance-versioning
document instance versioning lifecycle
2 parents 77f5c66 + 7b2f6eb commit 2cb7374

11 files changed

Lines changed: 762 additions & 6 deletions

File tree

documentation/how_to/use_diffo_provider_versioning.livemd

Lines changed: 425 additions & 0 deletions
Large diffs are not rendered by default.

lib/diffo/provider.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ defmodule Diffo.Provider do
4747
define :suspend_service, action: :suspend
4848
define :terminate_service, action: :terminate
4949
define :status_service, action: :status
50-
define :specify_instance, action: :specify
50+
define :respecify_instance, action: :specify
5151
define :relate_instance_features, action: :relate_features
5252
define :unrelate_instance_features, action: :unrelate_features
5353
define :relate_instance_characteristics, action: :relate_characteristics

lib/diffo/provider/components/base_instance.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,26 @@ defmodule Diffo.Provider.BaseInstance do
144144
lightweight admin create), you can override `build_before/1` or `build_after/2` on your
145145
resource, or use Ash's `skip_unknown_inputs` to absorb the injected arguments without
146146
declaring them.
147+
148+
## Instance versioning
149+
150+
Each Instance kind is tied to a specific major version of its Specification via the `id`
151+
declared in `specification do`. Patch and minor version bumps update the existing
152+
Specification node in place and require no instance changes. Major version bumps introduce
153+
a new Instance kind module (e.g. `BroadbandV2`) with a new `id` and `major_version`,
154+
leaving the original module and all its instances untouched.
155+
156+
To migrate an existing instance from one major version to another, call
157+
`Diffo.Provider.respecify_instance/2` with the new specification's id:
158+
159+
{:ok, v2_spec} = Diffo.Provider.get_specification_by_id(BroadbandV2.specification()[:id])
160+
{:ok, migrated} = Diffo.Provider.respecify_instance(instance, %{specified_by: v2_spec.id})
161+
162+
Any breaking data changes (e.g. a characteristic value that no longer exists in V2) must
163+
be handled before or as part of respecification — either via Cypher directly against the
164+
graph or via a domain-specific migration action you build on your own resource.
165+
166+
See `Diffo.Provider.Specification` for the full versioning lifecycle.
147167
"""
148168
use Spark.Dsl.Fragment,
149169
of: Ash.Resource,

lib/diffo/provider/components/instance/extension/specification.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ defmodule Diffo.Provider.Instance.Specification do
3838
def relate_instance(result, changeset)
3939
when is_struct(result) and is_struct(changeset, Ash.Changeset) do
4040
specified_by = Ash.Changeset.get_argument(changeset, :specified_by)
41-
Provider.specify_instance(%Instance{id: result.id}, %{specified_by: specified_by})
41+
Provider.respecify_instance(%Instance{id: result.id}, %{specified_by: specified_by})
4242
end
4343

4444
defimpl String.Chars do

lib/diffo/provider/components/specification.ex

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,53 @@
44

55
defmodule Diffo.Provider.Specification do
66
@moduledoc """
7-
Ash Resource for a TMF Service or Resource Specification
7+
Ash Resource for a TMF Service or Resource Specification.
8+
9+
A Specification identifies the kind of a TMF Service or Resource Instance. Every instance
10+
carries a relationship to exactly one Specification node in the graph, established at build
11+
time and changeable via `Diffo.Provider.respecify_instance/2`.
12+
13+
## Identity
14+
15+
A Specification is uniquely identified by `{name, major_version}`. The `id` is a stable
16+
UUID4 that is the same across all environments for a given `{name, major_version}` pair —
17+
it is typically declared as a constant in the Instance Extension DSL and committed to source
18+
control.
19+
20+
## Versioning
21+
22+
Diffo uses semantic versioning for Specifications with three independent mechanisms:
23+
24+
| Change | Mechanism | Instance impact | Intended usage |
25+
| ------ | ----------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- |
26+
| Patch | `next_patch_specification!/1` | None — internal fix | Corrections to metadata: description wording, category typos |
27+
| Minor | `next_minor_specification!/1` | None — all instances immediately reflect new version | Backward-compatible additions: new optional characteristics, new enum values |
28+
| Major | New module, new `id`, new `major_version` | Instances stay on old spec until explicitly migrated | Breaking changes |
29+
30+
What constitutes a breaking change is deliberately vague — it depends on the specification
31+
domain and may require negotiation between provider and consumers.
32+
33+
## Major version lifecycle
34+
35+
Major versions are decoupled across the provider/consumer boundary:
36+
37+
1. **Provider publishes V2** — deploys a new Instance kind module (e.g. `BroadbandV2`)
38+
with the same specification `name`, a new `id`, and `major_version: 2`. V1 and V2
39+
coexist; both can be used to create instances.
40+
2. **Consumers adopt at their own pace** — each consumer (e.g. an RSP) decides when to
41+
start creating V2 instances and when to migrate existing V1 instances.
42+
3. **Provider withdraws V1** — removes the V1 module. Existing V1 instances remain in
43+
the graph and continue to operate; the domain API for creating new V1 instances is
44+
removed.
45+
4. **Consumers complete migration** — each consumer migrates remaining V1 instances to V2
46+
via `Diffo.Provider.respecify_instance/2`, handling any breaking data changes (e.g.
47+
remapping or removing an enum value) before or as part of the respecification.
48+
49+
## create upsert behaviour
50+
51+
`create_specification/1` uses `upsert? true` on the `{name, major_version}` identity.
52+
Calling it for an existing `{name, major_version}` pair preserves any attributes not
53+
supplied — a second call without `category` leaves the existing category intact.
854
"""
955
require Ash.Resource.Change.Builtins
1056

test/provider/instance_test.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ defmodule Diffo.Provider.InstanceTest do
453453
Diffo.Provider.create_specification!(%{name: "wifiAccess", major_version: 2})
454454

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

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

465465
{:error, _error} =
466-
instance |> Diffo.Provider.specify_instance(%{specified_by: UUID.uuid4()})
466+
instance |> Diffo.Provider.respecify_instance(%{specified_by: UUID.uuid4()})
467467
end
468468

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

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

477477
test "annotate a service instance with a note - success" do

test/provider/versioning_test.exs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.VersioningTest do
6+
@moduledoc false
7+
use ExUnit.Case
8+
9+
alias Diffo.Test.Servo
10+
alias Diffo.Test.Broadband
11+
alias Diffo.Test.BroadbandV2
12+
13+
setup_all do
14+
AshNeo4j.BoltyHelper.start()
15+
end
16+
17+
setup do
18+
on_exit(fn ->
19+
AshNeo4j.Neo4jHelper.delete_all()
20+
end)
21+
end
22+
23+
describe "minor version — backward-compatible change" do
24+
# A minor version represents a non-breaking change such as adding a new technology type.
25+
# The specification node is updated in place — no migration of any kind is required.
26+
# All existing instances immediately reflect the new version.
27+
28+
test "minor version bump updates the specification node to v1.1.0" do
29+
Servo.build_broadband(%{})
30+
{:ok, spec} = Diffo.Provider.get_specification_by_id(Broadband.specification()[:id])
31+
assert spec.version == "v1.0.0"
32+
33+
minored = Diffo.Provider.next_minor_specification!(spec)
34+
assert minored.version == "v1.1.0"
35+
end
36+
37+
test "all existing V1 instances immediately reflect the new minor version" do
38+
{:ok, v1_a} = Servo.build_broadband(%{})
39+
{:ok, v1_b} = Servo.build_broadband(%{})
40+
41+
{:ok, spec} = Diffo.Provider.get_specification_by_id(Broadband.specification()[:id])
42+
Diffo.Provider.next_minor_specification!(spec)
43+
44+
{:ok, reloaded_a} = Diffo.Provider.get_instance_by_id(v1_a.id)
45+
{:ok, reloaded_b} = Diffo.Provider.get_instance_by_id(v1_b.id)
46+
assert reloaded_a.specification.version == "v1.1.0"
47+
assert reloaded_b.specification.version == "v1.1.0"
48+
end
49+
50+
test "minor version freeze — removing behaviour do blocks creation without a new module" do
51+
# When NBN removes behaviour do from Broadband and deploys v1.1, build_broadband
52+
# disappears from the domain API. This is the machine-readable announcement of the freeze.
53+
# Existing instances are unaffected; all other operations continue via the module.
54+
# This cannot be demonstrated in a single test suite since the module is fixed at
55+
# compile time, but the mechanism is proven by the BroadbandV1_1 fixture pattern:
56+
# same spec id, no behaviour do block, no build wired in the domain.
57+
assert Broadband.specification()[:id] == "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
58+
assert function_exported?(Diffo.Test.Servo, :build_broadband, 2)
59+
refute function_exported?(Diffo.Test.Servo, :build_broadband_v1_1, 2)
60+
end
61+
end
62+
63+
describe "major version — concurrent V1 and V2" do
64+
test "V1 and V2 specifications coexist with same name and different major_version" do
65+
Servo.build_broadband(%{})
66+
Servo.build_broadband_v2(%{})
67+
68+
specs = Diffo.Provider.find_specifications_by_name!("broadband")
69+
assert length(specs) == 2
70+
71+
versions = Enum.map(specs, & &1.major_version) |> Enum.sort()
72+
assert versions == [1, 2]
73+
end
74+
75+
test "V1 instance is linked to V1 specification" do
76+
{:ok, v1} = Servo.build_broadband(%{})
77+
assert v1.specification_id == Broadband.specification()[:id]
78+
end
79+
80+
test "V2 instance is linked to V2 specification" do
81+
{:ok, v2} = Servo.build_broadband_v2(%{})
82+
assert v2.specification_id == BroadbandV2.specification()[:id]
83+
end
84+
85+
test "V1 and V2 instances operate concurrently" do
86+
{:ok, v1} = Servo.build_broadband(%{})
87+
{:ok, v2} = Servo.build_broadband_v2(%{})
88+
89+
v1_instances = Diffo.Provider.find_instances_by_specification_id!(Broadband.specification()[:id])
90+
v2_instances = Diffo.Provider.find_instances_by_specification_id!(BroadbandV2.specification()[:id])
91+
92+
assert length(v1_instances) == 1
93+
assert length(v2_instances) == 1
94+
assert v1.specification_id != v2.specification_id
95+
end
96+
end
97+
98+
describe "major version — RSP migration from V1 to V2" do
99+
# V2 must be published (specification node created) before any instance can be
100+
# respecified to it. Building the first V2 instance is what publishes the specification.
101+
setup do
102+
{:ok, _} = Servo.build_broadband_v2(%{})
103+
:ok
104+
end
105+
106+
test "V1 instance is respecified to V2 via respecify_instance" do
107+
{:ok, v1} = Servo.build_broadband(%{})
108+
{:ok, instance} = Diffo.Provider.get_instance_by_id(v1.id)
109+
110+
{:ok, migrated} = Diffo.Provider.respecify_instance(instance, %{
111+
specified_by: BroadbandV2.specification()[:id]
112+
})
113+
114+
assert migrated.specification.id == BroadbandV2.specification()[:id]
115+
end
116+
117+
test "migrated instance is found by V2 specification" do
118+
{:ok, v1} = Servo.build_broadband(%{})
119+
{:ok, instance} = Diffo.Provider.get_instance_by_id(v1.id)
120+
{:ok, _} = Diffo.Provider.respecify_instance(instance, %{
121+
specified_by: BroadbandV2.specification()[:id]
122+
})
123+
124+
v2_instances = Diffo.Provider.find_instances_by_specification_id!(BroadbandV2.specification()[:id])
125+
assert Enum.any?(v2_instances, &(&1.id == v1.id))
126+
end
127+
128+
test "migrated instance is no longer found by V1 specification" do
129+
{:ok, v1} = Servo.build_broadband(%{})
130+
{:ok, instance} = Diffo.Provider.get_instance_by_id(v1.id)
131+
{:ok, _} = Diffo.Provider.respecify_instance(instance, %{
132+
specified_by: BroadbandV2.specification()[:id]
133+
})
134+
135+
v1_instances = Diffo.Provider.find_instances_by_specification_id!(Broadband.specification()[:id])
136+
refute Enum.any?(v1_instances, &(&1.id == v1.id))
137+
end
138+
139+
test "V1 withdrawal — all V1 instances migrated, none remain on V1" do
140+
{:ok, v1_a} = Servo.build_broadband(%{})
141+
{:ok, v1_b} = Servo.build_broadband(%{})
142+
143+
{:ok, instance_a} = Diffo.Provider.get_instance_by_id(v1_a.id)
144+
{:ok, instance_b} = Diffo.Provider.get_instance_by_id(v1_b.id)
145+
{:ok, _} = Diffo.Provider.respecify_instance(instance_a, %{specified_by: BroadbandV2.specification()[:id]})
146+
{:ok, _} = Diffo.Provider.respecify_instance(instance_b, %{specified_by: BroadbandV2.specification()[:id]})
147+
148+
assert Diffo.Provider.find_instances_by_specification_id!(Broadband.specification()[:id]) == []
149+
assert length(Diffo.Provider.find_instances_by_specification_id!(BroadbandV2.specification()[:id])) == 3
150+
end
151+
end
152+
end

test/support/resource/broadband.ex

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Test.Broadband do
6+
@moduledoc """
7+
Diffo - TMF Service and Resource Management with a difference
8+
9+
Broadband - V1 broadband service, demonstrating the simple BaseInstance pattern.
10+
Technology options include :fttb. The breaking change in BroadbandV2 is the
11+
removal of :fttb from the supported technology types.
12+
"""
13+
alias Diffo.Provider.BaseInstance
14+
alias Diffo.Test.Servo
15+
16+
use Ash.Resource,
17+
fragments: [BaseInstance],
18+
domain: Servo
19+
20+
resource do
21+
description "A Broadband Service Instance (V1)"
22+
plural_name :broadbands
23+
end
24+
25+
structure do
26+
specification do
27+
id "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
28+
name "broadband"
29+
type :serviceSpecification
30+
major_version 1
31+
description "A broadband access service"
32+
category "Access"
33+
end
34+
end
35+
36+
behaviour do
37+
actions do
38+
create :build
39+
end
40+
end
41+
42+
actions do
43+
create :build do
44+
accept [:id, :name, :type]
45+
change set_attribute(:type, :service)
46+
change load [:href]
47+
upsert? false
48+
end
49+
end
50+
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Test.BroadbandV2 do
6+
@moduledoc """
7+
Diffo - TMF Service and Resource Management with a difference
8+
9+
BroadbandV2 - V2 broadband service. Breaking change from V1: :fttb has been
10+
removed from supported technology types, requiring data remediation on any V1
11+
instance with technology: :fttb before respecification.
12+
"""
13+
alias Diffo.Provider.BaseInstance
14+
alias Diffo.Test.Servo
15+
16+
use Ash.Resource,
17+
fragments: [BaseInstance],
18+
domain: Servo
19+
20+
resource do
21+
description "A Broadband Service Instance (V2)"
22+
plural_name :broadband_v2s
23+
end
24+
25+
structure do
26+
specification do
27+
id "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c"
28+
name "broadband"
29+
type :serviceSpecification
30+
major_version 2
31+
description "A broadband access service — :fttb technology retired"
32+
category "Access"
33+
end
34+
end
35+
36+
behaviour do
37+
actions do
38+
create :build
39+
end
40+
end
41+
42+
actions do
43+
create :build do
44+
accept [:id, :name, :type]
45+
change set_attribute(:type, :service)
46+
change load [:href]
47+
upsert? false
48+
end
49+
end
50+
end

0 commit comments

Comments
 (0)