feat: root capabilities and extend(), and make materialization idempotent - #70
Merged
Conversation
dcj
force-pushed
the
feat/root-and-extend
branch
2 times, most recently
from
August 21, 2026 01:12
ca10e45 to
ed65538
Compare
…tent
Two gaps reported against 0.21.0's DeviceTreeBuilder, plus the
idempotence both of them need.
root's own surfaces (an enclosure's aggregate metering, its state, its
controls) had no declarative expression. A producer had to hand-roll the
root beside the builder: one model, two construction styles, and the root
outside every guarantee the builder gives. The root already exists, so
this materializes onto it; the model group defaults to the root's device
id, matching how add() keys a child.
first published. add() short-circuits an already-built spec, so the
builder modeled devices appearing and disappearing but not growing. The
workaround was unsafe rather than absent: Device.add_node and
Node.add_property both replace wholesale, so re-declaring a live device
dropped the previous node's properties from $description while leaving
their retained topics on the broker, and only delete_node clears those.
Description and broker then disagree, across restarts.
Idempotence, at three levels, which both features need and which
build_from_declarations now gets too:
node reused rather than replaced (the mechanism behind the
description-versus-broker divergence above)
property reused rather than re-added (add_property replaces and
republishes with force=True)
the transition itself not opened at all when nothing would be
created. An empty state_transition still emits init -> ready,
and that edge forces every controller on the bus to resync,
so a re-declaration that changes nothing must not cost one.
The third was found by a test asserting extend() publishes nothing on a
re-declare: content was already idempotent, the state flap was not.
Closes #67
Closes #68
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dcj
force-pushed
the
feat/root-and-extend
branch
from
August 21, 2026 01:14
ed65538 to
01b17f8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #67, closes #68. Builds on #69 (the reuse fix), which should merge first.
#67: the root can carry its own capabilities
add()only ever creates children, so a root's own surfaces — an enclosure's aggregate metering, its state, its control surfaces — had no declarative expression. A producer had to hand-roll the root beside the builder: one model, two construction styles, and the root outside every guarantee the builder gives.add_root_capabilities(specs, *, model_group=None)materializes onto the root, which already exists. The group defaults to the root's device id, matching howadd()keys a child.root_capabilities()reads back what has accumulated.#68: an existing device can grow
A capability set is not always known when a device is first published: a storage system commissioned at runtime gives an enclosure shed and forecast surfaces it did not have at boot.
The workaround was unsafe rather than merely absent.
Device.add_nodeandNode.add_propertyboth replace wholesale, so re-declaring a live device throughbuild_from_declarationsdropped the previous node's properties from$descriptionwhile leaving their retained topics on the broker — onlydelete_nodeclears those. Description and broker then disagree, and it persists across restarts.extend(spec, specs)materializes inside onestate_transition()and folds the new model keys into the same bookkeepingremove()uses. There's a test asserting the pre-existing properties survive, which is the specific hazard above.The idempotence both needed
Three levels, and
build_from_declarationsgets all three:Device.add_nodeis a wholesaleself._nodes.update(...)— the mechanism behind the divergence aboveNode.add_propertyreplaces and republishes withforce=Truestate_transition()still emitsinitthenreadyThe third is the one worth reading. A materialization that would create nothing now doesn't open a transition at all. An empty init→ready edge forces every controller on the bus to resync, which the SDK's own comments call a correctness concern, so a re-declaration that changes nothing must not cost one.
I found it because
test_extend_is_idempotentasserted zero publishes on a re-declare and got two. The content was already idempotent; the state flap wasn't. Weakening the test to allow the flap would have hidden a real cost.Verification
680 tests pass (671 before, 9 new). ruff and markdownlint clean.
🤖 Generated with Claude Code