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:
/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.
/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.
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.
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
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.
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.
## 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
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
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
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.02026-09-06 22:45:49 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this is
The Configuration tab can only render types and ranges.
validate.py's mostuseful refusals are cross-field — two inverters naming one
setpoint_entity, amaintenance_charge_wabovemax_w, a flatsetpoint_entityleft set beside a declared roster — and the Supervisor's UI hasno 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
test_selfconfig.pypart 2 attempts it and counts what the Supervisor received: nothingvalidate.py, hashes every file underapp/to show validate.py is the only one that differs, and serves the real/config/validateroute out of that copy in a subprocessself/info, run the validator, writeself/options, and restart the add-on. Nothing elseset_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.pyruns Supervisor 2026.08.0's own code outof 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 andno
hassio_roleis involved — and found four things the card did not say:/addons/self/options/validateis NOT reachable from inside an add-on.Two path segments after
self, so[^/]+cannot match it, and it is not alisted literal. There is exactly one validator this panel can call, and it
is ours — which is the one with the cross-field rules.
/addons/self/infois admitted by thedefaultrole as well (/.+/info),while
optionsandrestartrest on the bypass alone. The probe's firstdraft claimed the role admitted none of the three and went red; that is
why the split is written down.
config.yaml's default (App.optionsisdeepmerge(defaults, persist)andthe setter replaces
persist). Run on real objects:max_w: 4200stored,max_womitted from the POST → 2000, the shipped default. Hencewhole-blob read-modify-write, and hence the compare-and-swap.
schema:is optional and a manifest without it is accepted;schema_uiisthen
[]. ButAppOptionsdrops every option the schema does not declare:0 of 71 survive,
/data/options.jsonbecomes{}, the startup gaterefuses 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.mdis 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:
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.
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
goodwe-addon-ci(alpine:3,:romount,python3 -B,__pycache__purged and the count printed as 0 before eachrun).
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0.count printed for each, per
MUTATION.md:save()stops validatingsave()stops comparing the stored blobrestart()stops requiring the intent tokensign_disagree_wNot 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
464d398, not the0581648the ticket named.release/1.0had moved (PR #59, SAFETY-42) before this started. The ticket'sown environment note says 23 test files, which is the count at
464d398and not at
0581648— so the two halves of that note disagree and the testcount is the half that matches the repository. Branching off the named SHA
would have put avoidable conflicts in
CHANGELOG.md,DOCS.mdandtranslations/into a gate that grades the merge (CI-02). Say so if youwould rather it were rebased.
DOCS.md,translations/en.yamland two check names intest_translations.pysaid"70 options"; the probe counts 71 (
config.yamlgrew a key inSAFETY-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
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_01BwRLw7eKYj2byVdwB6kbjUf0f7b30098tofdaf3fb54fApplying 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