Skip to content

fix: refuse a control the panel declares non-settable, and pin the capture that proves it (#161) - #164

Merged
cayossarian merged 3 commits into
mainfrom
fix/161-refuse-nonsettable-controls
Aug 26, 2026
Merged

fix: refuse a control the panel declares non-settable, and pin the capture that proves it (#161)#164
cayossarian merged 3 commits into
mainfrom
fix/161-refuse-nonsettable-controls

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Closes #161.

A circuit whose relay the panel declares locked was still handed a /set topic, and the write went out. Nothing was user-visible, because the Home Assistant integration gates entity creation on is_user_controllable — but settability changes at runtime, set_circuit_relay is public API, and the refusal contract belongs in the library rather than in each consumer.

The rule is the specification's

packages/schema-1/spec/catalogs/switch.json, vendored in this repo, declares relay "Settable when relay-controllable = true" and defines relay-controllable: false as locked. So set_circuit_relay_target and set_circuit_priority_target now return ControlTarget | None, and the client methods raise SpanPanelServerError the way set_evse_charge_limit already did.

For the relay, either signal refuses: $settable absent on switch/relay, or relay-controllable false. Not belt-and-braces — SPAN's response to the July 2026 consumer-feedback round discloses a firmware bug where the $settable re-toggle on the runtime re-commissioning path is skipped until the service restarts, with their own guidance to "treat a cached $settable defensively". The panel rejects an out-of-policy write regardless, so the conjunction only ever refuses a write the panel would have refused anyway — it needs no unwinding when that bug is fixed.

Two tests read the vendored catalog and pin the clauses this depends on, so a specification that stops saying it fails here rather than leaving a stale rule in force.

Priority answers the other way — absent $settable means settable — because its catalog entry carries no condition. The asymmetry is deliberate and documented.

Refusals stay visible

Every pre-publish refusal routes through one _refuse_control, which fires after_publish with FAILED and a specific reason. before_publish is deliberately not consulted: it exists to authorise a command that would otherwise be published, and a veto would replace a precise reason with "vetoed". Downstream consumers build audit trails on after_publish, and a refused relay command is the event such a trail most needs.

The capture that proves it

The fixture encoded the bug it was meant to catch: its locked circuit advertised settable: true, because it came from an emitter that did. Tests written against it passed while proving nothing.

There was no way to regenerate it — the flat schema had capture_flat_reference.py, the parent/child tree had nothing, and no commit recorded how the bytes were made. So this adds scripts/capture_parent_child_reference.py, which injects a recorder through the emitter's own BYO-transport seam and runs the producer's real path, and scripts/reference_panel.yaml, committed so the capture is reproducible.

The manifest uses spec-legal priority values rather than the emitter's shipped example, which uses NICE_TO_HAVE — a REST-generation value the emitter degrades to UNKNOWN, and which no production panel publishes (electrification-bus/distribution-enclosure-simulator#51). UNKNOWN remains covered by a synthetic test built from the catalog's declared format, since the spec makes it baseline for every host. Contract obligations come from the catalog; representativeness comes from the capture.

Provenance, so this cannot go stale silently

spec_lock.json's peer becomes a peers map, and ebus-panel-sim joins it — repo, ref, tag, commit, released version, the capture script and the manifest. The capture script reads that pin and refuses to write a capture taken from any other version, so the bytes and the claim about them cannot drift apart.

peer-drift.yml gains an emitter job. Unlike panelbench, this peer is a released distribution, so regenerating is gated on a release: it asks PyPI for the latest, fails when that is not the pinned version, and reports PyPI being unreachable as unknown rather than as drift. Schedule- and dispatch-only, never pull_request, for the reason the existing job already states.

Also corrects firmware.family from spanos2 to spanos3: the range was right, the family was stale.

Verification

1061 passed / 7 skipped, mypy clean across 43 source files, all hooks green, 95.79% coverage.

A revert of the fixture to the buggy version fails test_the_capture_carries_a_locked_relay_and_a_controllable_one, which asserts the premise rather than assuming it. Each signal is pinned independently, so neither can silently carry the other.

Not published

Folds into the unreleased 3.1.0 — no version bump, no pin changes for consumers.

`switch` 0.3, vendored at packages/schema-1/spec/catalogs/switch.json, declares
`relay` "Settable when `relay-controllable = true`" and defines
`relay-controllable` false as "locked (for example a circuit commissioned as
permanently on)". `set_circuit_relay_target` and `set_circuit_priority_target`
were pure string formatting from a circuit id and consulted no declaration, so
both setters published to circuits the specification says are not settable --
while the same adapters were already reading exactly that into
`is_user_controllable` and `is_never_backup` for the snapshot. Both now return
`ControlTarget | None`, joining the two controls that have always refused this
way, and the client raises `SpanPanelServerError` the way
`set_evse_charge_limit` does.

The condition lives in the catalog's prose: `settable: true` stands
unconditionally in the JSON beside it, because that field describes the
property across the capability while the narrowing applies per device. So the
rule is encoded rather than derived, and a test reads the catalog and pins the
clause -- a specification that stops saying it fails there rather than leaving
a stale rule in force.

Under parent/child both halves of the condition are on the wire and the relay
refuses when either says no. SPAN reports a firmware defect in which the
`$settable` re-toggle on the runtime re-commissioning path is skipped until the
service restarts, so declaration and value can disagree on a live panel; the
panel rejects an out-of-policy write regardless, so the conjunction only refuses
writes the panel would refuse. Absence reads as locked on `switch/relay` and as
settable on `load-shed/priority`, and that asymmetry is each catalog entry's:
`switch` carries a condition, `load-shed` carries none. Flat predates capability
nodes and declares settability per device type, so it cannot vary per circuit;
it spells the same fact `always-on`, and the priority's `never-backup`.

Nothing was user-visible, because the integration gates entity creation on
`is_user_controllable`. That is not enough: re-commissioning a circuit in place
republishes its `$description` with a new `$settable`, so an entity can outlive
its own controllability, and `set_circuit_relay` is public API reachable
without an entity.

Route the refusals through the interceptor. Five refusals happened while
resolving the address and so never reached `_publish_control`, which is where
interception lives -- meaning `after_publish`, contracted to see every command
including refusals, was missing precisely the commands a panel had refused. A
consumer building a security audit on it would have had its hole where the
relay is. `ControlCommand.topic` and `PublishOutcome.topic` become `str | None`
so a command that never resolved to a topic is recorded truthfully rather than
named a topic nothing would publish to. `before_publish` stays unconsulted for
them: there is nothing to authorise, and a veto would replace a specific reason
with "vetoed".

Track the producer that made the reference capture, which is what blocked the
fix. `ebus-panel-sim` is published by electrification-bus -- the organisation
that writes the specification -- and is conformed against live panel output: the
specification in runnable form, and the right thing to test a consumer against.
Depending on it is correct. Depending on a frozen, unrecorded copy of it was
not: the capture was taken once, nothing wrote down what made it, and three
emitter releases went by while this repository asserted a producer defect as
fact across roughly thirty test files.

So `spec_lock.json` grows `peers`, keyed by name because both readers select a
peer by identity and neither iterates for its own sake. `ebus-panel-sim` is
pinned there with its repo, ref, commit, tag, released version and the
specification commit it implements -- which is the same commit we pin, and a
test now asserts that. panelbench keeps `fixtures`, paths inside panelbench that
are byte-copied here; the emitter gets `produces`, paths inside this repository
generated by its `capture_script` from its `manifest`. One key would have meant
two things depending on which peer you read it from.

`scripts/capture_parent_child_reference.py` substitutes the transport rather
than reassembling the emitter, reads its expected release out of the lockfile
rather than restating it, and refuses to write when the checkout disagrees.
`scripts/reference_panel.yaml` is its input, committed and pinned: a capture
whose input is not in the tree is the same problem as one whose producer is not
recorded. It mirrors the emitter's own example key for key and marks its two
divergences at the head of the file -- spec-legal shed priorities in place of a
value the emitter degrades to UNKNOWN (electrification-bus/
distribution-enclosure-simulator#51, open), and the identity properties a real
panel publishes.

The capture was three producer releases behind, and the suite had been reading
its errors as facts:

  - `switch/relay` loses `$settable` on the locked circuit and its
    `relay-requester` becomes CONFIGURATION -- the case this fix needed.
  - `connection/count` is gone; no configuration could publish it.
  - `power-flows/{pv,battery,grid}` and the BESS meter are in the frame
    `power-flows` 0.3 defines, where the old bytes carried the pre-0.6.0 one
    and did not balance. The parser was already right. Two tests contradicted
    each other under the old fixture: one derived a charging battery from the
    flows, the other asserted the snapshot reported it discharging.
  - The lugs integrate their own meter, so the capture runs three ticks --
    two intervals, one importing and one exporting -- because one interval
    can now only populate one energy register.
  - `load-shed/priority` carries values a real panel publishes. UNKNOWN is
    spec-legal and every parser must handle it, so that obligation moves to
    where it belongs: a test parametrised over `load-shed` 0.3's declared
    `$format` rather than over whatever the capture happens to contain.
    Contract obligations come from the catalog; representativeness comes from
    the capture.

`peer-drift.yml` gains an emitter job. It is a released distribution rather
than a repository we copy out of, so "has it moved" has two answers and only
one is a reason to recapture: the job asks PyPI for the latest release and
reports commits past the pin as context. Schedule and dispatch only, never
`pull_request`, for the reason the existing job states.

`tests/fixtures/panelbench_unvalued_by_both.json` is refreshed from the
panelbench commit `spec_lock.json` already pins. The vendored copy carried 32
`connection/count` entries that commit had itself already dropped, so the
cross-check passed only because both sides were stale.
…y targets

`firmware.family` read `spanos2` while `firmware.range` read `r202633+`. The
range is right and the family is stale: production enclosures on r202633
report a `spanos3` build, which is also what the live panel this range was
pinned from returns from `GET /api/v2/status`.

Nothing read the field -- `test_the_peer_targets_the_same_firmware` asserts on
`range` alone -- so this corrected no behaviour. It is provenance, and this
file just became the thing peer-drift reads to decide whether a producer has
moved past its pin, which makes a wrong value here worse than it was.

The `spanos2` strings elsewhere are left alone deliberately: schema-0's
changelog records the flat capture at `spanos2/r202603/05`, which is the
family that firmware genuinely was, and the test fixtures carry synthetic
version strings that assert nothing about the family.

Noted in #161.
Two corrections from review of this branch.

The remediation text in the panelbench job still named `peer.ref` and
`peer.commit`, keys that stopped existing when `spec_lock.json` moved to a
`peers` map. Advice that names a key the file does not have is worse than no
advice: it sends the reader looking for something they will not find.

More substantially, the emitter job could not notify anyone. Its release
comparison printed an accurate, actionable summary -- here is the newer
version, here is the command to regenerate -- and then exited 0, so the daily
run went green in every outcome. GitHub notifies on a failed scheduled run and
says nothing about a successful one, so the one path that had something to
report was the one nobody would hear. This job exists because the last drift of
its kind "took nine days to be noticed by hand", and a buried step summary
would have repeated that exactly.

It now exits non-zero when PyPI's latest is not the pinned release, and stays
red until the capture is regenerated or the pin moves -- which is the honest
state of a reference tree describing a producer that has been superseded. The
job is schedule- and dispatch-only and gates no pull request, so red costs a
notification and nothing else. PyPI being unreachable still reports unknown
rather than drift: not having asked is not the same fact as there being
nothing new.

The commit-distance step gains `if: always()`, because it is context for that
verdict rather than part of it. Without it, going red would suppress the commit
subjects that make the failure worth reading, and the job header's reasoning --
"a red check with no names is a chore" -- would defeat itself.
@cayossarian
cayossarian merged commit d13774b into main Aug 26, 2026
6 checks passed
@cayossarian
cayossarian deleted the fix/161-refuse-nonsettable-controls branch August 26, 2026 05:40
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.

Circuit relay and priority setters publish a /set topic for circuits the panel declares non-settable

1 participant