UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is LIVE, so all three describe the running install. No behaviour, no schema and no default moves here: config.yaml, translations/ and every module under app/ are byte-identical to6e7b233. ⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and the arithmetic is the reason rather than the effort. options: and schema: both declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key only when it is ?-optional AND absent from options:, and nothing is absent. But the deeper fact is that no manifest could have met it: Supervisor's own UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter - `schema.filter(e => e.name in opts || e.required)`, transcribed out of the bundle in the release image - shows a required field unconditionally. So 59 is the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything reachable. ⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH. A key absent from options: has no default for the Supervisor to deep-merge into an existing box's stored blob. That is exactly the delivery_hold_s mechanism this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to its options: default of 185 instead of becoming exit 78. Hiding a key is precisely the edit that removes that. It would also promote the 27 unpinned in-code literal fallbacks the new check ledgers into the ACTUAL source of the defaults on a live install - the CFG-03 defect the same card's AC 4 exists to police, made load-bearing by its AC 3. ⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW. supervisor_shape_probe.py section F has printed "the Configuration tab renders 71 fields" since the day it was written and exited 0 through UX-01's whole review while displaying the number that contradicted the criterion it was evidence for. Three assertions replace the bare print, every number computed from the manifest and from Supervisor's own `required` flag rather than typed, including an ISOLATED COUNTERFACTUAL that empties options: and nothing else. MEASURED RED: delete one key (price_feed_url) from options: and two of them fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1. --- AC 4: the AST check, built -------------------------------------------- AC 4's first clause is VACUOUS over an empty set (see above) and its second - "the table changed goes red" - already works six times over. The third, the one with the content, was never written: the only literal scan in the repository is test_validate.py's regex over eight maintenance_* keys, which is a grep and predates the card. test_defaults_pin.py walks ast.Call nodes over app/*.py. It: - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than naming them. Every existing pin names its own table by hand, so a SEVENTH table added tomorrow is pinned by nobody and nothing says so; - grades all 30 mirrored keys against config.yaml, and requires that no module carries a literal fallback for any of them; - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by pair and not by count - a count goes green again the moment one site is deleted and another added. Line numbers are deliberately out of the key: a ledger that reddens on an unrelated reformat is one people re-bless without reading. It can only shrink; - grades every remaining literal AGAINST config.yaml, which is the half that catches CFG-04's actual failure - two copies of one number, one moves. ⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for: main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10. It cannot reach an operator - the deep-merge guarantees the key is present - and the only paths that hand over a partial dict are sim/inject.py and this repo's fixtures. Recorded as the single permitted divergence with its reason rather than corrected; changing it alters the control loop's target and belongs to a card that grades the loop. A second divergence goes red. ⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic module containing a deliberate violation of each rule and REQUIRES it to be caught, in-process, through the same function the graded parts call - so a scanner that goes blind stops working there too - and the site total is graded against a floor. And a literal was planted in the REAL tree: main.py:290 float(opts.get("maintenance_charge_w", 2000)) -> FAIL no literal fallback survives for any of the 30 keys a mirror already answers for exit 1 main.py:290 opts.get("p1_host", "x"), opts.get("slew_w", 999) -> FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')] FAIL every remaining literal still AGREES with config.yaml main.py slew_w: code 999 vs config.yaml 0 exit 1 Both plants removed. --- AC 5: CFG-04 is closed, and not by this card's predecessor -------------fef6ef2touched config.yaml, translations/, test_translations.py and the probe - no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same triple mutation on a throwaway copy of6e7b233: 26 files, RED at test_selfconfig.py, exit 1 - 1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the rail collapsing to the 2000 default under an explicit 400 W slew git log -L 310,325:test_selfconfig.py attributes that check tobcee235, "UX-02: pin both halves of the max_w fix, because neither was graded". The AC is met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done. --- the 8 per-entry translations: they stay -------------------------------- Measured negative and not a defect in them: UiOptions gives inverters type schema WITH multiple, the frontend sends that case to ha-selector-object, which labels fields `fields[name].label || name`, and the converter sets neither label nor translation_key. The Supervisor tab CANNOT be made to render them. They stay because UX-02's ingress panel is the surface that can, because they cost nothing, and because deleting them removes the only thing that would catch a per-entry key added without a translation - which is how SAFETY-32's REQUIRED bms_soc_floor would have joined the roster unlabelled. A written decision, not a change; translations/ is untouched. tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7. That is TOOL-04, already filed, and it is not re-filed or fixed here. --- evidence --------------------------------------------------------------- positive control test_defaults_pin.py's mirror floor moved 6 -> 7 inside the harness container: "### RED: test_defaults_pin.py", exit 1 suite 27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the checkout mounted read-only) shape probe all cases passed, exit 0, on ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or sim/. Both probe runs were throwaway containers over a read-only mount. DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four decisions, the frontend transcription, and the two counterfactual transcripts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
18 KiB
UX-04 — what the Configuration tab renders, and the pin that was never built
Status: DECIDED 2026-09-07. Three decisions and one confirmation, in the order UX-01's unmet acceptance criteria are numbered:
- UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
unsatisfiable, not deferred. The target was arithmetically unreachable, and
the part of it that was reachable would be bought with the upgrade path.
The measurement is now graded in
tools/supervisor_shape_probe.py§F rather than printed, so the withdrawal cannot be quietly reversed. - UX-01 AC 4's third clause is now BUILT:
test_defaults_pin.py, an AST check, with its red direction executed on every run. - UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
release/1.0, by UX-02'sbcee235, not by UX-01'sfef6ef2. The attribution is corrected here; no work was re-done. - The 8 per-entry
inverters:translations STAY. They do not reach the Supervisor tab and cannot be made to; UX-02's ingress panel is where they become visible.
Filed with UX-04. Card 6a9ec94617e9c48ed74ca5dd. Every number below was
measured on release/1.0 at 6e7b233 on 2026-09-07, against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 — the version
ha info reports on the live install — and against the 26-file check suite in
goodwe-addon-checks:1. Nothing here contacted the live Supervisor at
192.168.2.158, posted options, or restarted anything.
1. Hiding: withdrawn, and why that is the safe answer
1.1 What AC 3 claimed
"The visible count drops to ~15, with the rest behind HA's show unused optional configuration options toggle."
1.2 What the tab actually renders
config.yaml declares 71 keys under options: and the same 71 under
schema:. The set that HA's toggle is able to hide — schema-only keys — is
therefore empty:
hideable = set(schema) - set(options) -> [] (0 of 71)
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
before UX-01, nothing is hidden after it, and no line of fef6ef2 moved a key
either way. AC 3 is not partially met; it is unstarted.
1.3 The rule that decides it, replayed rather than paraphrased
From the frontend bundle shipped in this image,
api/panel/frontend_latest/5680.*.js (frontend 20250925.1):
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
opts is the add-on's options as Supervisor serves them — the operator's
stored blob after options: has been deep-merged into it. So a field is hidden
only when it is both absent from options: and not required. The
probe now runs that filter on this manifest rather than quoting it.
1.4 Reason one: the target was never reachable
Supervisor's own UiOptions marks required on 59 of the 71 fields; 12 are
optional. The filter above shows a required field unconditionally. So the
floor — what the tab renders with options: emptied altogether, which is
the most hiding that is even expressible — is 59 fields.
options: block |
fields rendered |
|---|---|
| as shipped (71 keys) | 71 |
| emptied entirely (0 keys) | 59 |
| AC 3's target | ~15 |
AC 3 asked for a number roughly four times below the floor. No arrangement of
options: reaches it. The criterion could not have been met by any
implementation; it was mis-specified, and the honest disposal of a
mis-specified criterion is withdrawal, not a partial gesture toward it.
1.5 Reason two: the 12 hides that are expressible cost the deep-merge
Moving a key out of options: is not free. App.options is
deepmerge(config.yaml defaults, the stored user options), so a key absent
from options: has no default for the Supervisor to merge.
That is exactly the mechanism the 0.9.0 upgrade rests on. From CHANGELOG.md,
the pre-deploy check run against this release on 2026-09-07:
The reference install's stored blob has 70 keys, not 71.
delivery_hold_sarrived with SAFETY-42 in 0.8.0, so an install running 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing?, which is the shape that becomes exit 78. It resolves to itsoptions:default of 185 because the Supervisor deep-merges.
Hiding a key is done by taking it out of options: and marking it ?. That
is, precisely, the edit that removes a key's merge default — the thing standing
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
fewer rows on a form, on a site where the add-on is holding a real house's grid
exchange at zero.
1.6 Reason three: it would promote 27 unpinned literals to load-bearing
And the cost compounds with §2 of this document. test_defaults_pin.py part 4
ledgers 27 (module, key) pairs in app/ that still answer a missing option
with an in-code literal. Today those literals are unreachable on a Supervisor
install, because the deep-merge guarantees every options: key is present in
the served blob. Remove keys from options: to hide them and those literals
stop being dead code: they become the actual source of the defaults on a live
install — the CFG-03 defect the same card's AC 4 exists to police, made
load-bearing by the card's own AC 3.
1.7 What now grades this
tools/supervisor_shape_probe.py §F. The count was printed there from the day
it was written — "the Configuration tab renders 71 fields" — and the probe
exited 0 through UX-01's whole review while displaying the number that
contradicted the criterion it was evidence for. A measurement that is
displayed but not asserted is how this passed review. Three assertions
replace the bare print:
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
- the only set HA's toggle can hide - is EMPTY. Hideable: []
PASS ...so replaying the frontend's own _filteredSchema on the served options
hides NOTHING: 71 of 71 fields render
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
expressible - the tab still renders 59 required fields. THAT is the
floor, and AC 3 asked for ~15.
Every number in them is computed from the manifest and from Supervisor's own
required flag; none is typed. The red direction was run: deleting one key
(price_feed_url) from options: and nothing else turns the first two to FAIL
— Hideable: ['price_feed_url'], 70 of 71 fields render — and the probe
exits 1. The counterfactual is isolated: one key varied, nothing else.
So if a future editor empties options: to shorten the form, this goes red and
somebody has to read §1.5 before merging.
2. The defaults pin: test_defaults_pin.py
2.1 What AC 4 asked for and what shipped
AC 4 has three clauses.
| clause | state before UX-04 |
|---|---|
"every schema: key absent from options: has exactly one defaults-table entry" |
vacuous — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
| "the table changed → red" | works, six times over, once per mirror in its own suite |
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | never built |
The only literal scan in the repository before this card is
test_validate.py:1025: a re.findall restricted to eight maintenance_*
keys across three named files. It is a grep, it predates UX-01, and it was not
written for this.
2.2 What an "in-code literal fallback" is, structurally
A call in app/ taking a string literal that names a schema: key, with a
constant in the next positional slot:
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
Found by walking ast.Call nodes — so a multi-line call and a renamed opts
are found, and the same text inside a comment or docstring is not. Two
exclusions, both structural and both argued at the site:
getattr(obj, "max_w", 0.0)reads an attribute off an object, not an option off a blob. Excluded by callee name.- the empty string.
opts.get("static_profile", "")is Python's "not stated", and all six such sites invalidate.pynormalise it immediately —str(opts.get(k, "") or "").strip(). It cannot be a stale copy of a number, which is the whole of the CFG-04 defect class. Excluded by value.0is not excluded:slew_w: 0andintegrator_max_w: 0are real shipped defaults with real meanings.
2.3 What it grades, measured on release/1.0 @ 6e7b233
2. the mirrors, DISCOVERED from the tree rather than listed here
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
30 mirrored key(s) of 71 in `schema:`
3. the AST scan
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
34 over unmirrored ones
4. the CFG-03 ledger
27 (module, key) pair(s) carry a literal for a key no mirror answers for
Four properties, none of them vacuous:
- The mirrors are discovered, not listed. Six suites already pin their own
table to
config.yaml— and every one of those pins names its table by hand, so a seventh table added tomorrow would be pinned by nobody and nothing would say so. This walksapp/for module-level*_OPTION_DEFAULTSassignments and grades whatever it finds againstconfig.yaml. That is the property the six hand-written pins cannot have. - No literal survives for a key a mirror already answers for. 30 keys, 0 violations.
- The CFG-03 ledger is FROZEN, by (module, key) pair, not by count. A
count goes green again the moment somebody deletes one site and adds
another. Line numbers are deliberately not in the key: a ledger that goes
red on an unrelated reformat is a ledger people learn to re-bless without
reading. The 27 pairs are the open CFG-03 backlog; they are not removed
here, because that is behaviour change across
main.py,p1.py,inverter.pyandvalidate.pythat no single card should make in passing. The set can now only shrink. - Every remaining literal is graded against
config.yaml— the half that catches CFG-04's actual failure mode, two copies of one number and one moves.
2.4 One divergence found, and it is new
main.py target_grid_w: code 0 vs config.yaml -10
main.py:290 reads target_grid_w=float(opts.get("target_grid_w", 0)) while
config.yaml ships -10. A dict handed to Controller without the key holds
net exchange at 0 W instead of the shipped -10 W bias into import. It cannot
reach an operator: on a Supervisor install the deep-merge guarantees the key
is present, and the only paths that hand over a partial dict are
sim/inject.py and this repository's own fixtures. It is recorded in the check
as the single permitted divergence, with the reason, rather than corrected —
changing it alters the control loop's target and belongs to a card that grades
the loop. A second divergence goes red.
2.5 ⭐ The red direction, shown rather than claimed
A check that has never been shown failing is not a check — which is exactly what clause one of this AC was. Two things follow from that:
(a) The red direction runs on every invocation. Part 5 feeds the scanner a
synthetic module containing a deliberate violation of each rule and requires
it to be flagged, in-process, through the same literal_fallbacks() the graded
parts call. A scanner that goes blind — a renamed helper, an ast change, a
SCHEMA that comes back empty — stops working here too, and the total site
count is additionally graded against a floor.
(b) A literal was planted in the real tree and the check went red. Planted
at main.py:290, float(opts.get("maintenance_charge_w", 2000)) — a mirrored
key:
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
ok the scanner is not blind: it found 35 sites (floor 20)
main.py:290 get('maintenance_charge_w', 2000)
FAIL no literal fallback survives for any of the 30 keys a mirror already
answers for - those sites must index the mirror
1 FAILED (exit 1)
And part 4's two directions, planted the same way with
opts.get("p1_host", "x") and opts.get("slew_w", 999):
4. the CFG-03 ledger
28 (module, key) pair(s) carry a literal for a key no mirror answers for
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
main.py p1_host: code 'x' vs config.yaml ''
main.py slew_w: code 999 vs config.yaml 0
main.py target_grid_w: code 0 vs config.yaml -10
FAIL every remaining literal still AGREES with config.yaml, except the 1
divergence(s) recorded above it
(exit 1)
The second of those is the CFG-04 defect class itself: a literal that has
drifted away from config.yaml while the file still says something else. Both
plants were removed; the tree is byte-identical to 6e7b233 apart from this
card's own files.
3. CFG-04: closed on release/1.0, by UX-02, not by UX-01
UX-01's AC 5 claimed the double-literal max_w hole. It did not close it.
fef6ef2 touched config.yaml, translations/, test_translations.py and the
probe — no defaults code at all — and on the 0.7.0 tree the triple mutation
survives: 21 files, 0 RED.
Confirmed on current release/1.0 @ 6e7b233, 2026-09-07. The same triple
mutation, 2000 -> 2500 at the three descendants of 0.7.0's
inverter.py:438 / validate.py:383 / validate.py:684:
| 0.7.0 | release/1.0 @ 6e7b233 |
|---|---|
inverter.py:438 max_w=float(opts.get("max_w", 2000)) |
inverter.py:640 raw = opts.get("max_w", 2000) |
validate.py:383 cap = _number(opts, "max_w", 2000.0) |
validate.py:466, same line |
validate.py:684 max_w = _number(opts, "max_w", 2000.0) |
validate.py:836, same line |
Result — 26 files, RED at test_selfconfig.py, exit 1:
1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the
rail collapsing to the 2000 default under an explicit 400 W slew, so the box
cannot be emptied into a quietly halved rail
git log -L 310,325:goodwe_controller/test_selfconfig.py attributes that check
to bcee235, "UX-02: pin both halves of the max_w fix, because neither was
graded", glenn schrooyen, 2026-09-06 — not to UX-01. The hole ships closed
with the pending 0.9.0 deploy. The AC is met by UX-02. No work was re-done
here; the mutation was run once, on a throwaway copy, to confirm it.
4. The 8 per-entry translations: they stay
4.1 The measurement, and it is negative
translations/{en,nl}.yaml carry a name and a description for all 8 per-entry
keys of the repeatable inverters: section. They are loaded by Supervisor's own
loader, they pass SCHEMA_APP_TRANSLATIONS, and test_translations.py grades
their coverage. They do not reach the screen, and this is not a defect in
them. UiOptions gives inverters type: "schema" with multiple: True, and
the frontend's _convertSchemaElement sends exactly that case to a selector
rather than to a nested ha-form:
if ("schema" === t.type && !t.multiple) return {...type:"expandable"...} // not us
if ("schema" === t.type) { const o = {}; for (const a of t.schema)
o[a.name] = {required: a.required, selector: ...}; return {object:{multiple:t.multiple, fields:o}} }
and ha-selector-object labels those fields fields[name].label || name, while
_convertSchemaElementToSelector sets neither label nor translation_key.
So an operator sees raw name, setpoint_entity, max_w, bms_soc_floor. The
probe already asserts this, negatively and by name.
4.2 Decision: keep them. Four reasons.
- The Supervisor tab cannot be made to render them. The gap is in somebody else's converter, not in this repository. There is no change here that fixes it; deleting the translations does not fix it either.
- UX-02's ingress panel is where they become visible, and it renders
the roster itself rather than handing it to
ha-selector-object. Thefields:block is the shape Supervisor's own schema defines for exactly this, and it is what a surface that wireslabelthrough consumes. - They cost nothing. No runtime path reads them; they are two YAML blocks in files the Supervisor already parses, and the parse is graded green.
- Deleting them would remove the only thing that catches a per-entry key
added without a translation.
test_translations.pygrades top-level and per-entry coverage symmetrically; drop the per-entry half and SAFETY-32'sbms_soc_floor— a required per-entry key added after UX-01 — would have joined the roster with no label and nothing to say so.
This is a written decision, not a change. The tree is unmodified in
translations/.
4.3 Not re-filed
tools/supervisor_selfapi_probe.py has been known red since OPT-09 — it
hardcodes len(_sub) == 7 against 8 per-entry keys. That is TOOL-04,
already filed, and it is not re-filed or fixed here. It is named only so a
reader of §4.1 knows why the other probe is the one quoted.
5. What this card did not do
- It did not remove a key from
options:. See §1.5 — the deep-merge default is what makes the pending 0.9.0 upgrade safe on a 0.7.0 box. - It did not pay down the 27-pair CFG-03 ledger. See §2.3.
- It did not correct
main.py'starget_grid_wfallback. See §2.4. - It did not touch
translations/. See §4.2. - It did not re-do CFG-04. See §3.
- It did not touch hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.