Skip to content

feat: root capabilities and extend(), and make materialization idempotent - #70

Merged
dcj merged 1 commit into
mainfrom
feat/root-and-extend
Aug 21, 2026
Merged

feat: root capabilities and extend(), and make materialization idempotent#70
dcj merged 1 commit into
mainfrom
feat/root-and-extend

Conversation

@dcj

@dcj dcj commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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 how add() 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_node and Node.add_property both replace wholesale, so re-declaring a live device through build_from_declarations dropped the previous node's properties from $description while leaving their retained topics on the broker — only delete_node clears those. Description and broker then disagree, and it persists across restarts.

extend(spec, specs) materializes inside one state_transition() and folds the new model keys into the same bookkeeping remove() 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_declarations gets all three:

level why it wasn't idempotent
node Device.add_node is a wholesale self._nodes.update(...) — the mechanism behind the divergence above
property Node.add_property replaces and republishes with force=True
the transition itself an empty state_transition() still emits init then ready

The 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_idempotent asserted 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

@dcj
dcj force-pushed the feat/root-and-extend branch 2 times, most recently from ca10e45 to ed65538 Compare August 21, 2026 01:12
…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
dcj force-pushed the feat/root-and-extend branch from ed65538 to 01b17f8 Compare August 21, 2026 01:14
@dcj
dcj merged commit cbed918 into main Aug 21, 2026
5 checks passed
@dcj
dcj deleted the feat/root-and-extend branch August 21, 2026 01:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No incremental way to add capabilities to an already-built device No way to materialize capabilities onto the tree's root device

1 participant