UX-02: the validator's refusals, next to the field, before the save #61

Merged
admin merged 3 commits from UX-02 into release/1.0 2026-09-06 22:45:49 +02:00
Owner

What this is

The Configuration tab can only render types and ranges. validate.py's most
useful refusals are cross-field — two inverters naming one
setpoint_entity, a maintenance_charge_w above max_w, a flat
setpoint_entity left set beside a declared roster — and the Supervisor's UI has
no way to express a rule spanning two keys. Today the operator meets those as
exit 78 in a log, after saving and restarting.

This puts them on the screen, beside the field they name, before anything is
written
.

Styling is the smaller half and it came almost free: the groups, labels,
descriptions and units all come out of UX-01's translations by way of
GET /addons/self/info, so this card ships no new words about any option.

The four things that could hurt somebody

how it is held how it is checked
An invalid blob must never be written — invalid blob + restart is exit 78, and the panel is gone with the add-on serving it validate first, refuse, keep the edits on screen. Not "warn and write" test_selfconfig.py part 2 attempts it and counts what the Supervisor received: nothing
A blob that moved underneath must not be clobbered — two surfaces, one blob read hands out a sha256 of the canonical blob; save hands it back; the stored blob is re-read immediately before the write part 3 simulates the other surface writing between the page's read and its save
Every message must come from the shipped validator the UI files contain no rule, no label and no option key part 6 inserts one new rule into a copy of validate.py, hashes every file under app/ to show validate.py is the only one that differs, and serves the real /config/validate route out of that copy in a subprocess
Nothing may command the inverter the routes read self/info, run the validator, write self/options, and restart the add-on. Nothing else part 7 lists the routes and greps for set_number, write_setpoint, handle_action, command_site_zero, sign_wd, reset(

Saving costs nothing; applying costs a control interruption

Saving does not restart. The page then shows which stored settings are not
what the add-on is running. Restart to apply is a separate button that names
the cost before it is pressed — the wind-down commands 0 W on every
inverter
(Controller.shutdown()) — and the request carries an intent token,
so a reload or a stray POST cannot spend a control interruption. A restart
clears nothing: SAFETY-02's latches are restored from the Store on the way back
up.

Verified again by calling it, not by citing the card

tools/supervisor_selfapi_probe.py runs Supervisor 2026.08.0's own code out
of the release image this site runs. It contacts no Supervisor, POSTs no options
and restarts nothing. On the tree in this PR: 30 PASS, exit 0.

It confirmed what the card asserted — the three self/ paths are permitted and
no hassio_role is involved — and found four things the card did not say:

  1. /addons/self/options/validate is NOT reachable from inside an add-on.
    Two path segments after self, so [^/]+ cannot match it, and it is not a
    listed literal. There is exactly one validator this panel can call, and it
    is ours
    — which is the one with the cross-field rules.
  2. /addons/self/info is admitted by the default role as well (/.+/info),
    while options and restart rest on the bypass alone. The probe's first
    draft claimed the role admitted none of the three and went red; that is
    why the split is written down.
  3. A POST that omits a key does not preserve it — the key reverts to
    config.yaml's default (App.options is deepmerge(defaults, persist) and
    the setter replaces persist). Run on real objects: max_w: 4200 stored,
    max_w omitted from the POST → 2000, the shipped default. Hence
    whole-blob read-modify-write, and hence the compare-and-swap.
  4. The Configuration tab can be removed — and it takes the add-on with it.
    schema: is optional and a manifest without it is accepted; schema_ui is
    then []. But AppOptions drops every option the schema does not declare:
    0 of 71 survive, /data/options.json becomes {}, the startup gate
    refuses the empty config, and the ingress panel goes down with the process.
    So the tab stays by decision, not by constraint — which is a better
    reason, and it is what DECISIONS/UX-02-two-config-surfaces.md is for.

Which surface is authoritative

Neither. The stored options blob is, and both UIs are editors of it. The
panel is the everyday surface because it is the only one that can refuse before
a save; the Configuration tab is the recovery surface and the only one that
still exists when the add-on is down. UX-01 is a dependency, not something this
supersedes — its translations are where every label, description and section
heading on the new page comes from, so the two surfaces share one vocabulary.

Recorded in full, with the measurements, in
DECISIONS/UX-02-two-config-surfaces.md.

The honest weakening, stated rather than discovered

SAFETY-28 makes a second writer on an actuator inexpressible. This cannot:
the Supervisor's tab is a second writer and stays one. What the compare-and-swap
gets is the weaker property — a second writer is detected, not silently
overwritten
— and claiming more would be the "check that observes something
other than what it claims to observe" this repository keeps finding.

Two more limits are on the page itself rather than only in a comment:

  • the panel shows fewer refusals than the startup gate can, never more. Two
    rules need the setpoint device's own range; when ESPHome is silent they are
    skipped — here and at startup, identically — and the page prints a note
    naming the entity that did not answer.
  • anchoring is by name occurrence. A refusal that never spells an option key
    cannot sit beside a box and goes to the top-level block instead. Part 4
    measures the coverage over six broken configs rather than claiming a figure:
    7 refusals, 7 anchored, 0 unanchored.

Evidence

  • Suite: 24 files, all PASS, in goodwe-addon-ci (alpine:3, :ro mount,
    python3 -B, __pycache__ purged and the count printed as 0 before each
    run).
  • Probe: 30 PASS, exit 0 against
    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0.
  • Mutation run: 5 mutations, 5 red, 0 survivors — bytecode purged and the
    count printed for each, per MUTATION.md:
    mutation result
    save() stops validating red — "THE SUPERVISOR RECEIVED NOTHING"
    save() stops comparing the stored blob red — "the save raises SaveRefused(stale)"
    restart() stops requiring the intent token red — "reaches it only when confirmed"
    anchoring drops the word boundaries red — a per-unit refusal lands under sign_disagree_w
    the write is removed altogether red — "a valid save with the right token is written, once"

Not touched

config.yaml (no schema change — the permissions were already declared),
main.py, and every control path. web.start()'s signature is unchanged.

⚠️ Two things a reviewer should check me on

  1. The branch is off 464d398, not the 0581648 the ticket named.
    release/1.0 had moved (PR #59, SAFETY-42) before this started. The ticket's
    own environment note says 23 test files, which is the count at 464d398
    and not at 0581648 — so the two halves of that note disagree and the test
    count is the half that matches the repository. Branching off the named SHA
    would have put avoidable conflicts in CHANGELOG.md, DOCS.md and
    translations/ into a gate that grades the merge (CI-02). Say so if you
    would rather it were rebased.
  2. Three stale prose spots corrected, in files this card touches. DOCS.md,
    translations/en.yaml and two check names in test_translations.py said
    "70 options"; the probe counts 71 (config.yaml grew a key in
    SAFETY-42 and the sentences did not follow). The assertions themselves were
    already derived from the file — only the prose was wrong. Called out because
    it is a change outside this card's subject.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## What this is The Configuration tab can only render **types and ranges**. `validate.py`'s most useful refusals are **cross-field** — two inverters naming one `setpoint_entity`, a `maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a declared roster — and the Supervisor's UI has no way to express a rule spanning two keys. Today the operator meets those as **exit 78 in a log**, after saving and restarting. This puts them **on the screen, beside the field they name, before anything is written**. Styling is the smaller half and it came almost free: the groups, labels, descriptions and units all come out of UX-01's translations by way of `GET /addons/self/info`, so this card ships **no new words about any option**. ## The four things that could hurt somebody | | how it is held | how it is checked | |---|---|---| | **An invalid blob must never be written** — invalid blob + restart is exit 78, and the panel is gone with the add-on serving it | validate **first**, refuse, keep the edits on screen. Not "warn and write" | `test_selfconfig.py` part 2 attempts it and **counts what the Supervisor received: nothing** | | **A blob that moved underneath must not be clobbered** — two surfaces, one blob | read hands out a sha256 of the canonical blob; save hands it back; the stored blob is re-read **immediately before the write** | part 3 simulates the other surface writing between the page's read and its save | | **Every message must come from the shipped validator** | the UI files contain no rule, no label and no option key | part 6 inserts **one new rule** into a *copy* of `validate.py`, hashes every file under `app/` to show validate.py is the only one that differs, and serves the real `/config/validate` route out of that copy in a subprocess | | **Nothing may command the inverter** | the routes read `self/info`, run the validator, write `self/options`, and restart the add-on. Nothing else | part 7 lists the routes and greps for `set_number`, `write_setpoint`, `handle_action`, `command_site_zero`, `sign_wd`, `reset(` | ### Saving costs nothing; applying costs a control interruption Saving **does not restart**. The page then shows which stored settings are not what the add-on is running. **Restart to apply** is a separate button that names the cost *before* it is pressed — the wind-down commands **0 W on every inverter** (`Controller.shutdown()`) — and the request carries an intent token, so a reload or a stray POST cannot spend a control interruption. A restart clears nothing: SAFETY-02's latches are restored from the Store on the way back up. ## Verified again by calling it, not by citing the card `tools/supervisor_selfapi_probe.py` runs **Supervisor 2026.08.0's own code** out of the release image this site runs. It contacts no Supervisor, POSTs no options and restarts nothing. On the tree in this PR: **30 PASS, exit 0.** It confirmed what the card asserted — the three `self/` paths are permitted and no `hassio_role` is involved — and found **four things the card did not say**: 1. **`/addons/self/options/validate` is NOT reachable from inside an add-on.** Two path segments after `self`, so `[^/]+` cannot match it, and it is not a listed literal. **There is exactly one validator this panel can call, and it is ours** — which is the one with the cross-field rules. 2. **`/addons/self/info` is admitted by the `default` role as well** (`/.+/info`), while `options` and `restart` rest on the bypass **alone**. The probe's first draft claimed the role admitted none of the three and **went red**; that is why the split is written down. 3. **A POST that omits a key does not preserve it** — the key reverts to `config.yaml`'s default (`App.options` is `deepmerge(defaults, persist)` and the setter replaces `persist`). Run on real objects: `max_w: 4200` stored, `max_w` omitted from the POST → **2000**, the shipped default. Hence whole-blob read-modify-write, and hence the compare-and-swap. 4. **The Configuration tab *can* be removed — and it takes the add-on with it.** `schema:` is optional and a manifest without it is accepted; `schema_ui` is then `[]`. But `AppOptions` drops every option the schema does not declare: **0 of 71 survive**, `/data/options.json` becomes `{}`, the startup gate refuses the empty config, and the ingress panel goes down with the process. So the tab stays **by decision, not by constraint** — which is a better reason, and it is what `DECISIONS/UX-02-two-config-surfaces.md` is for. ## Which surface is authoritative **Neither. The stored options blob is**, and both UIs are editors of it. The panel is the everyday surface because it is the only one that can refuse before a save; the Configuration tab is the **recovery** surface and the only one that still exists when the add-on is down. UX-01 is a dependency, not something this supersedes — its translations are where *every* label, description and section heading on the new page comes from, so the two surfaces share one vocabulary. Recorded in full, with the measurements, in `DECISIONS/UX-02-two-config-surfaces.md`. ### The honest weakening, stated rather than discovered SAFETY-28 makes a second writer on an actuator **inexpressible**. This cannot: the Supervisor's tab is a second writer and stays one. What the compare-and-swap gets is the weaker property — **a second writer is detected, not silently overwritten** — and claiming more would be the "check that observes something other than what it claims to observe" this repository keeps finding. Two more limits are on the page itself rather than only in a comment: * **the panel shows fewer refusals than the startup gate can, never more.** Two rules need the setpoint device's own range; when ESPHome is silent they are skipped — here and at startup, identically — and the page prints a note **naming the entity that did not answer**. * **anchoring is by name occurrence.** A refusal that never spells an option key cannot sit beside a box and goes to the top-level block instead. Part 4 *measures* the coverage over six broken configs rather than claiming a figure: **7 refusals, 7 anchored, 0 unanchored.** ## Evidence * **Suite: 24 files, all PASS**, in `goodwe-addon-ci` (`alpine:3`, `:ro` mount, `python3 -B`, `__pycache__` purged and the count printed as 0 before each run). * **Probe: 30 PASS, exit 0** against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`. * **Mutation run: 5 mutations, 5 red, 0 survivors** — bytecode purged and the count printed for each, per `MUTATION.md`: | mutation | result | |---|---| | `save()` stops validating | red — *"THE SUPERVISOR RECEIVED NOTHING"* | | `save()` stops comparing the stored blob | red — *"the save raises SaveRefused(stale)"* | | `restart()` stops requiring the intent token | red — *"reaches it only when confirmed"* | | anchoring drops the word boundaries | red — a per-unit refusal lands under `sign_disagree_w` | | the write is removed altogether | red — *"a valid save with the right token is written, once"* | ## Not touched `config.yaml` (no schema change — the permissions were already declared), `main.py`, and every control path. `web.start()`'s signature is unchanged. ## ⚠️ Two things a reviewer should check me on 1. **The branch is off `464d398`, not the `0581648` the ticket named.** `release/1.0` had moved (PR #59, SAFETY-42) before this started. The ticket's own environment note says *23 test files*, which is the count at `464d398` and not at `0581648` — so the two halves of that note disagree and the test count is the half that matches the repository. Branching off the named SHA would have put avoidable conflicts in `CHANGELOG.md`, `DOCS.md` and `translations/` into a gate that grades the **merge** (CI-02). Say so if you would rather it were rebased. 2. **Three stale prose spots corrected, in files this card touches.** `DOCS.md`, `translations/en.yaml` and two check names in `test_translations.py` said *"70 options"*; the probe counts **71** (`config.yaml` grew a key in SAFETY-42 and the sentences did not follow). The assertions themselves were already derived from the file — only the prose was wrong. Called out because it is a change outside this card's subject. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-06 21:38:41 +02:00
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.

WHAT IT ADDS
  app/configui.py     the field model, built from `GET /addons/self/info`'s own
                      `schema` + `translations`. No option key, no label, no
                      rule of its own. UX-01's `Group · ` label prefix becomes a
                      real section heading, in whichever language resolved.
  app/selfconfig.py   the Supervisor conversation and the write gate.
  app/config_page.py  the page. Its longest function decides which HTML input a
                      type deserves; it contains no rule.
  app/web.py          five routes. Still the only file a reviewer has to read to
                      answer "does anything here reach the inverter".

THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
  1. A blob the SHIPPED validator refuses is never written - not warned about,
     not written. An invalid blob plus a restart is exit 78, and the panel is
     gone with the add-on that serves it.
  2. A blob that moved underneath is refused, not clobbered. Read hands out a
     sha256 of the canonical blob, save hands it back, and the stored blob is
     re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
     applied to configuration - "a blob reaches the Supervisor only if it was
     derived from the blob stored right now" - with the honest weakening stated
     in the decision record: a second writer is DETECTED, not made
     inexpressible.
  3. Every message is the validator's own, byte for byte. test_selfconfig.py
     part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
     under app/ to show validate.py is the only one that differs, and serves the
     real /config/validate route out of that copy in a subprocess. The new
     sentence arrives, filed under the key it names.
  4. Nothing on the surface commands an inverter or clears a latch. Saving does
     not restart: applying costs a control interruption - the wind-down commands
     0 W on every inverter - so that is a separate, confirmed button that says
     so first.

MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
  - the three self-paths are permitted, and `/addons/self/info` is admitted by
    the `default` role too while `options` and `restart` rest on the bypass
    ALONE. The probe's first draft claimed the role admitted none of the three
    and went red.
  - `/addons/self/options/validate` is NOT reachable from inside an add-on, so
    the only validator this panel can call is ours - which is the one with the
    cross-field rules.
  - a POST that omits a key does not preserve it: the key reverts to
    config.yaml's default. Hence whole-blob read-modify-write.
  - the Configuration tab CAN be removed, and doing so drops 0-of-71 options
    through and takes the add-on down. The card said it could not be; it stays
    by decision, which is a better reason.

DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.

Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

---

⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.

So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin force-pushed UX-02 from f0f7b30098 to fdaf3fb54f 2026-09-06 21:38:41 +02:00 Compare
admin added 1 commit 2026-09-06 22:17:39 +02:00
UX-02: the panel was weaker than the startup gate for one field
checks / unit (pull_request) Successful in 6m9s
2bb07ccdde
Applying review finding N1, which was non-blocking because it fails closed, and
N2, which is a fabricated number.

N1. Clearing the `max_w` box answered HTTP 500 on a non-JSON body, which the
page rendered as "cannot reach the add-on" - untrue, and for the most prominent
limit field's most likely transient state. The cause was a disagreement between
two files that `inverter.py`'s own docstring says to keep in step:

  validate.py `_number()`  reads "" as "use the default", so rule 9 saw 2000
                           and was content
  inverter.py line 515     did a bare `float(opts.get("max_w", 2000))`, which
                           raises ValueError on ""

So a shape rule 9 TOLERATED could not be composed at all - the docstring's rule
inverted. The roster path never had this: `_entry_max_w` already guards. Only
the flat single-inverter path did. `_flat_max_w()` now reads empty as the
default exactly as `_number()` does, and unparseable as 0.0 exactly as
`_entry_max_w` does, where rule 9 refuses it by name at line 790.

⚠️ THE FIRST ATTEMPT AT THIS MADE IT WORSE, AND THE PROBE CAUGHT IT. Guarding
`build_roster` in `selfconfig` with `except: return validate(opts), []` turned
the 500 into ZERO PROBLEMS for `max_w: ""` - the validator has nothing to say
about it, so an empty list came back and the page would have ENABLED SAVE. A
crash became a silent acceptance. The guard is kept as a backstop for shapes
neither file anticipates, but it can no longer return an empty list: if the
roster cannot be composed and the validator names no reason, that disagreement
is itself reported.

MEASURED after the fix, `problems_and_ranges` with no HA:
  max_w 5000, slew_w 400   0 problems      (the live shape)
  max_w "",   slew_w 400   1 problem       rule 10, "against a site rail of
                                           2000 W" - clearing the box FAILS
                                           CLOSED on the live install rather
                                           than silently halving the rail
  max_w "",   slew_w 0     0 problems      accepted at the 2000 default
  max_w "abc"              1 problem       "max_w 'abc' is not a number"
Before the fix, both "" and "abc" raised ValueError instead.

N2. `translations/en.yaml:7` said "all 71 keys ... so ALL SEVENTY are rendered".
The 70->71 correction had been made in one half of the sentence and not the
other. 59-of-71 required is correct and was re-measured.

Suite: 24 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-06 22:38:57 +02:00
Review of the fix commit found that BOTH halves reverted with the whole 24-file
suite still green - including the half that turns a crash into a SILENT
ACCEPTANCE, which was my own wrong first attempt at this fix. A fix nothing
grades is a fix that comes back. MUTATION.md habit 4.

⚠️ AND THE OBVIOUS PIN HAD THE SAME DEFECT IT WAS WRITTEN TO CATCH. "problems_
and_ranges does not raise" is ALSO satisfied by the backstop swallowing the
exception and returning [] - the silent acceptance half two exists to prevent.
MEASURED: with both halves reverted, every max_w check reported `ok` and only
the forced-boom pins went red. So half one is now pinned on `build_roster`
ITSELF, where the backstop cannot answer for it. That is the same defect as the
one being fixed - a check whose data cannot reach the failure it names - written
into the check for it, and it is the twelfth recorded instance.

New section 2b of test_selfconfig.py:
  - `_flat_max_w` agrees with `_number()` across ten shapes, so composition
    tolerates exactly what rule 9 tolerates.
  - ⭐ `build_roster(max_w="")` does not raise - half one, standing alone.
  - a cleared box answers with the validator's own verdict; an unparseable one
    answers "max_w 'abc' is not a number".
  - ⭐ clearing max_w on the LIVE shape (5000 W rail, explicit slew_w 400) is
    REFUSED by rule 10 "against a site rail of 2000 W". `unit_caps_indexed`
    reads the flat rail through the same `_number`, so the rail collapses in
    lockstep and rule 10 catches the collapse. This is what makes the fix safe
    rather than merely non-crashing: the box cannot be emptied into a quietly
    halved rail.
  - ⭐ the backstop never returns an empty list, forced by monkeypatching
    build_roster to raise; and the patch is shown undone.

DRIVEN, all four arms through the real harness:
  clean            rc=0  fails=0
  revert half one  rc=1  fails=2     (_flat_max_w -> bare float())
  revert half two  rc=1  fails=2     (backstop returns [] again)
  revert BOTH      rc=1  fails=3
Each half is caught on its own, which is the property the earlier draft lacked.

Suite: 24 files, all pass.

Not addressed here, filed instead: N3 (`/config/` 404s), N4 (undeclared keys on
a hand-crafted POST, which the page cannot produce and Supervisor drops), N5
(saving pins every default as an explicit option), the page reporting "would
start" for a blob Supervisor's own schema refuses, and 2000.0 hardcoded in three
places that agree today with nothing pinning them together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit 3d2999337d into release/1.0 2026-09-06 22:45:49 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#61