Skip to content

feat: key the builder on device id, and give extend() an inverse - #80

Merged
dcj merged 1 commit into
mainfrom
feat/id-keyed-idempotence-and-node-removal
Aug 21, 2026
Merged

feat: key the builder on device id, and give extend() an inverse#80
dcj merged 1 commit into
mainfrom
feat/id-keyed-idempotence-and-node-removal

Conversation

@dcj

@dcj dcj commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #74, closes #78. Stacked on #79, which should merge first.

#74 — keyed on the resolved device id

DeviceTreeBuilder keyed its bookkeeping on DeviceSpec object identity. A producer deriving its spec set from a manifest re-derives equal-but-distinct objects on every pass, so each pass made a new device. The alternative was an obligation the API never stated: hold a device_id -> DeviceSpec map for the process lifetime and never re-derive, which is precisely what a declarative API exists to avoid.

add(), remove(), extend(), device_for() and homie_properties() now all answer for any spec naming the same device. Deferred specs stay keyed by identity, having no id yet by definition.

One semantic decided explicitly: add() is idempotent on the device, not on the declaration. A differing capability set on an already-built id returns the existing device unchanged rather than applying the difference, because add() silently mutating a live tree is not what its name suggests. extend() is how a built device grows. test_a_differing_spec_on_a_built_id_returns_the_existing_device pins that.

The test that asserted the old contract is rewritten rather than deleted, so the change is visible in the diff:

-def test_specs_are_compared_by_identity(root):
-    builder.add(a)
-    assert builder.device_for(b) is None
+def test_a_respec_of_the_same_device_id_is_the_same_device(root):
+    device = builder.add(a)
+    assert builder.add(b) is device

#78remove_capabilities()

The inverse of extend(). A capability that becomes relevant at runtime can stop being relevant, and its node otherwise stayed advertised in $description with retained topics behind it.

Device.delete_node() already clears those and re-announces, so what this closes is the bookkeeping: reaching around the builder to call it left model_keys and created_groups describing properties that no longer existed, and a later remove() working from that stale record. Bookkeeping now carries the capability, so a node's share of it is identifiable.

Idempotent like extend(), and there's a test for the full lifecycle the issue describes: relevant, then not, then relevant again.

Verification

700 tests pass (692 before, 8 new plus 3 replacing the rewritten one). ruff and markdownlint clean.

Release note

This plus #79 makes 0.23.0, one release for all seven issues rather than a patch now and a minor later — each version bump costs a Yocto image rebuild downstream, so batching them is the point.

🤖 Generated with Claude Code

#74: DeviceTreeBuilder keyed its bookkeeping on DeviceSpec object
identity. A producer deriving its spec set from a manifest re-derives
equal-but-distinct objects on every pass, so identity keying made each
pass a new device. The alternative was an unstated obligation: hold a
device_id -> DeviceSpec map for the process lifetime and never re-derive,
which is exactly what a declarative API exists to avoid.

Now keyed on the resolved device id. add(), remove(), extend(),
device_for() and homie_properties() all answer for any spec naming the
same device. Deferred specs stay keyed by identity, having no id yet by
definition.

One semantic decided explicitly: add() is idempotent on the DEVICE, not
on the declaration. A differing capability set on an already-built id
returns the existing device unchanged rather than applying the
difference, because add() silently mutating a live tree is not what its
name suggests. extend() is how a built device grows.

The test that asserted the old contract (device_for on an equal spec
returns None) is rewritten to assert the new one rather than deleted, so
the change of contract is visible in the diff.

#78: remove_capabilities(), the inverse of extend(). A capability that
becomes relevant at runtime can stop being relevant, and its node
otherwise stayed advertised with retained topics behind it.
Device.delete_node already clears those and re-announces; the gap was the
bookkeeping, since reaching around the builder left model_keys and
created_groups describing properties that no longer existed and a later
remove() working from that stale record. Bookkeeping now carries the
capability, so a node's share of it is identifiable.

Closes #74
Closes #78

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcj
dcj force-pushed the feat/id-keyed-idempotence-and-node-removal branch from a2c9ad7 to ae1154d Compare August 21, 2026 02:29
@dcj
dcj merged commit cc9cf1b into main Aug 21, 2026
5 checks passed
@dcj
dcj deleted the feat/id-keyed-idempotence-and-node-removal branch August 21, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant