Skip to content

span-panel-api 3.1.1 and schema-0 1.1.1: release-review fixes - #169

Merged
cayossarian merged 11 commits into
mainfrom
fix/3.1.1-review-items
Aug 27, 2026
Merged

span-panel-api 3.1.1 and schema-0 1.1.1: release-review fixes#169
cayossarian merged 11 commits into
mainfrom
fix/3.1.1-review-items

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Follow-up to the 3.1.0 release review. No API change.

  • A rejected passphrase echoed back in a field-level 422 (detail[].input with the key only in loc) no longer reaches the debug log; redaction is by key, by sibling loc, and by the value the caller sent.
  • One _request() helper carries every bootstrap REST call: the whole httpx.TransportError family becomes SpanPanelConnectionError, and a 200 with a non-JSON, non-object or key-missing body becomes SpanPanelAPIError instead of a raw JSONDecodeError/KeyError.
  • A plaintext bootstrap transport is warned about once per panel, from the transport, so regenerate_passphrase is covered as well as registration.
  • One reader for /api/v2/status, shared by detection and get_v2_status; detection answers probe_failed on an unreadable status body rather than raising.
  • schema-0: unreferenced denormalize_circuit_id removed.

mypy --strict clean (41 files); ruff clean; 1159 passed, 13 skipped.

A pydantic-v2 field-level 422 reports the failure by pointing `loc` at the
field and putting the rejected value under `input`, where it is a bare
string. The key-based walk had nothing to judge it by, so `_redact` returned
that body byte-identical and the passphrase went into the DEBUG log.

A scalar in an error object whose `loc` names a credential field is now
redacted on the strength of that `loc`; a dict or list under the same key is
still walked, so the surrounding diagnostic survives. And `register_v2` now
passes the passphrase it sent into the redaction, which closes the case
structure cannot reach at all -- a panel quoting the credential back in
free prose.
Seven of the eight caught `httpx.ConnectError` and `httpx.TimeoutException`
and nothing else, so a `ReadError` from a panel resetting its listener
mid-response, or a `RemoteProtocolError` from a proxy closing without
answering, came back as a raw httpx exception -- not a `SpanPanelError`, and
so caught by none of the retry clauses built on that contract. The bodies
were unguarded the same way: a 200 missing a field raised `KeyError` and one
that was not JSON raised `JSONDecodeError`.

`_request` performs the call and translates the whole `TransportError`
family; the `_Reply` it returns decodes the body and names the field the
panel left out. Status classification stays with each caller, because it is
genuinely per-endpoint -- 412 means "no passphrase is set" on one path and
nothing anywhere else. `get_homie_schema` keeps its retryable reading of an
unparseable body by asking for `SpanPanelServerError`, and the detector now
catches this library's own two classes instead of a hand-listed tuple of
httpx ones that had let `ReadError` out of a function documented to return
a result.
The MQTT bridge already says once per bridge that an unpinned trust anchor
is fetched over plaintext HTTP and whatever answers is trusted. The REST
side said nothing, though registration is the request that carries the panel
passphrase up and brings the broker password back -- so anything on the path
reads both, and an operator had no way to tell the encryption was off.

Warned by the two calls that bootstrap a client, not from inside `_request`:
one line per client is a line somebody reads, one per request is a line
somebody filters out. `register_v2` warns for itself and the factory warns
only on the branch that never reaches it, so a caller never hears it twice.
Plaintext remains the default -- requiring a pin would break every install
on upgrade -- but it is now a default the log names.
`/api/v2/status` was parsed in two places -- the detector's, deciding whether
the panel speaks v2 at all, and `get_v2_status`'s, reading the same answer
for a caller that already knows it does -- and the two had drifted. Only the
detector read `proximityProven`, so the same panel reported "proximity
unknown" or "proximity proven" depending on which call had asked.

`V2StatusInfo.from_status_payload` is now the only reader, so the field a
caller gets no longer depends on the route it took. Absence still reads as
`None` rather than false, because firmware below 202609 does not report
proximity at all and that is not the same fact as not proven.
`denormalize_circuit_id` restored dashes to a dashless UUID, and nothing
asked it to. The adapter normalises ids on the way in for entity stability
and never converts them back -- no parser, no snapshot, no topic builder and
no consumer called it, and the only two references anywhere were the tests
written for it. It is not part of the `SchemaAdapter` protocol and was never
re-exported from the package, so nothing outside could reach it either.
The adapter's floor on the bootstrap stays at 3.1.0 and the bootstrap's
extras stay at 1.1.0: nothing in either release asks anything new of the
other, so neither pairing that resolved before stops resolving now.
`detect_api_version` is documented to return a result, and every caller
reads one rather than guarding the call. Since the body validation went in,
a 200 carrying something that is not a status object raised out of it
instead -- and the case that produces that is a proxy in front of a v1 panel
answering its own HTML error page under a 200, which is a panel the caller
was only asking about.

Reported as a failed probe rather than a v1 verdict: no answer was read, so
the honest report is that the question is still open. `probe_failed` now
documents that reading, which covers both a request that did not complete
and a response that could not be understood.
The warning was raised by two call sites, which meant every new call site
had to remember to raise it -- and `regenerate_passphrase` did not. That is
the call a consumer reaches for when reauthenticating: it sends a bearer
token up and brings the new broker password back over the same plaintext
transport registration uses, and it said nothing at all.

Moved into `_request`, so there is one mechanism and nothing to remember,
and the changelog's "once per client" is now literally true of every call
rather than of two of them.

Scoped to the panel rather than the client object, which looks tighter and
is worse: the CA download runs on every MQTT reconnect and builds a fresh
client each time, so that key would produce a warning per reconnect --
exactly what the bridge's own once-per-bridge warning exists to avoid.
…ored in

`_create_ssl_context` is annotated to return an `SSLContext` and its last
statement returned `_ssl_cache.context`, which is typed `SSLContext | None`.
The assignment on the line above does not make the read that follows safe:
the field is shared mutable state, so another task may clear or replace it
in between, and a strict checker is right to object.

Handing back the local the executor just produced says what the function
actually means and needs no narrowing to be true.
Two loose ends from review. `_redact` reads as three independent rules, so
the residual case -- a scalar the walk cannot place -- looks unhandled when
read on its own; it is covered because the sole call site always passes the
passphrase it sent, and the docstring now says so rather than leaving the
reader to find the call site. And the redaction tests carried an import of
`unittest.mock.patch` that nothing used, which the test tree's blanket lint
exclusion had been hiding.
…th the other module state

The comment claimed the once-per-host set had the same scope as the MQTT
bridge's unpinned-CA warning. It does not: the bridge's flag is per instance
and repeats on a config-entry reload; this set is process-wide. The per-host
choice stands -- the CA download builds a fresh client per reconnect -- but
the comment now says what the scope is rather than what it resembles.

The test reset moves to conftest beside the SSL-cache reset, so any test that
drives a plaintext request starts unwarned, not only the ones in the file
that happens to assert on it.
@cayossarian
cayossarian merged commit b41fa7c into main Aug 27, 2026
6 checks passed
@cayossarian
cayossarian deleted the fix/3.1.1-review-items branch August 27, 2026 04:44
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.

1 participant