Skip to content

fix: five defects in the declarative builder, all silent in production - #79

Merged
dcj merged 1 commit into
mainfrom
fix/settable-gate-and-live-seed
Aug 21, 2026
Merged

fix: five defects in the declarative builder, all silent in production#79
dcj merged 1 commit into
mainfrom
fix/settable-gate-and-live-seed

Conversation

@dcj

@dcj dcj commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #72, #73, #75, #76, #77. Each came with a runnable reproduction; all five were verified by execution before and after, not by reading.

#72conditionally_settable was inert

_materialize never read the field. The half that looked right is that the property did come out not-settable. The half that bit:

built not-settable                            : True
entity_setter registered on the model         : False
after set_settable(True), settable            : True
subscribed to /set                            : True
has a set_callback behind that topic          : False
a command arriving reaches the entity_setter  : False

So the caller's later set_settable(True) opened a /set topic with no translator, and the property advertised that it accepts commands and discarded them. That is the exact failure the field exists to avoid, one step further along#58's PR argued that advertising settable without subscribing publishes a lie, and then shipped subscribing without a handler.

It is also the only route the API offers for per-instance settability decided at runtime, so the feature was 100% broken for its stated purpose.

The test that shipped with it asserted only the not-settable half, which is why it survived review. A test written from the design rationale checks the rationale, not the feature.

#77 — a live model value never reached the wire

The binding is on-change and a fresh Homie twin starts empty, so nothing pushed the model's current value onto it:

model holds      : 'SN-LIVE'
homie twin holds : None
value published  : NOTHING

A producer whose model predates the tree — the arrangement DeviceTreeBuilder documents as its whole reason for accepting an external model — announced its declared default on every property, and it did not self-heal, because set_value fires callbacks only on an actual change. A value written once at group creation stayed wrong for the process lifetime.

This became reachable in 0.22.0. Before the #66 reuse fix, the model property was replaced, so twin and model started equally empty. Fixing one defect exposed another.

Relatedly, a declared initial_value now seeds rather than overwrites: a model already holding a value holds a fresher one than the declaration. An explicit values= entry still wins, being a statement about this run.

#73, #75, #76 — teardown and bookkeeping

Verification

692 tests pass (680 before, 12 new). Each issue's own reproduction is a test. ruff and markdownlint clean.

Not in this PR

#74 (idempotence keyed on device id rather than DeviceSpec identity) and #78 (node-level removal). #74 changes an API contract and #78 is completeness rather than correctness; both deserve their own design pass.

🤖 Generated with Claude Code

Reported by a consumer converting a multi-device adapter onto the
builder, each with a runnable reproduction. All five verified against the
reproductions before and after.

#72  conditionally_settable was inert: _materialize never read it. The
     property did come out not-settable, which is the half that looked
     right; the entity_setter was registered only when settable was true,
     so the caller's later set_settable(True) opened a /set topic with no
     translator behind it. The property advertised that it accepts
     commands and discarded them, which is the exact failure the field
     exists to avoid, one step further along. The translator is now wired
     at build time even though the property starts not-settable.

     The test that shipped with the feature asserted only the
     not-settable half. A test written from the design rationale checks
     the rationale, not the feature.

#77  A value the model already held never reached the wire. The binding
     is on-change and a fresh twin starts empty, so a producer whose
     model predates the tree announced its declared default on every
     property, and it did not self-heal, because set_value fires only on
     an actual change. Reachable only since 0.22.0: before the reuse fix
     the model property was replaced, so twin and model started equally
     empty. Also: a declared initial_value now seeds rather than
     overwrites, since a live model value is fresher than a declaration.

#73  remove() raised when the producer's model had already dropped the
     group. delete_group removes it BEFORE firing GROUP_DELETED and
     dispatch is synchronous, so a consumer driving remove() from that
     event always hit it, after device.delete() and before the
     bookkeeping pop: gone from the broker, still a corpse in the builder
     short-circuiting the next add().

#75  remove() left deferred descendants queued, so resolve_deferred()
     rebuilt a device that had been torn down.

#76  add() recorded bookkeeping after materializing, so a raise left a
     live broker-visible device with no record, and the same window
     admitted re-entry from a producer observing its own model.

Closes #72
Closes #73
Closes #75
Closes #76
Closes #77

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcj
dcj merged commit f61e76e into main Aug 21, 2026
5 checks passed
@dcj
dcj deleted the fix/settable-gate-and-live-seed branch August 21, 2026 02:28
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.

conditionally_settable is inert: _materialize never reads it

1 participant