The review had one blocking finding, and it was not in the design or the
shipped code: tools/supervisor_shape_probe.py §B did not measure what four
documents said it measured, and it was regression-blind for the property it
was cited for.
What was wrong. §B validated the reconstructed live options blob, which
is missing many required keys that have nothing to do with this card. Its
refusal therefore named whichever of those the validator reached first — the
committed transcript recorded meter_freeze_s, the reviewer's run printed p1_phase_export_entities, and a run today printed sign_disagree_w. The
quoted Missing option 'inverters' in root was never produced by the
instrument. Worse, §B asserted a bare not ok, so deleting inverters: []
from config.yaml left it printing PASS — blind to the exact regression it
is the committed evidence for.
The underlying claim is true, and is now measured properly.
What changed.
tools/supervisor_shape_probe.py§B is now the isolated counterfactual:
the full shipped defaults with the key (expect accept), and the same
defaults without it and nothing else varying (expect refusal). It asserts
on the key name in the refusal string, so the line goes red if the
refusal is about anything else. The probe also exits non-zero when any
case fails — without that, a transcript is only evidence to someone who reads
it.
The old live-blob case survives as §B2, relabelled for what it actually
is: an incomplete reconstruction whose raw form is refused for unrelated
missing keys, kept because its merged half genuinely demonstrates deepmerge(options:, stored).
DECISIONS/CFG-04-inverter-roster.md — §4's "§B shows both halves" sentence
and the line-103 transcript corrected; the whole transcript re-run and
replaced with a real one; §3 gains an explicit row for the default.
CHANGELOG.md, test_inverter.py's part-9 comment and its part-12
mutation row re-attributed to §B, with the row now recording that the probe
itself was blind to that mutation until review.
MUTATION.md gains this as the sixth instance of "the check's data
cannot reach the failure it names" — the first one found outside the suite,
in a committed evidence artifact — plus three habits: assert on the content
of a refusal, vary one thing from an accepted baseline, and treat a
committed transcript as a check subject to the mutation battery.
The proof the reviewer asked for — inverters: [] deleted from config.yaml, probe re-run against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
FAIL the shipped `options:` block validates AS IT STANDS - ...
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
...
== 2 CASE(S) FAILED ==
exit 1. Key restored, same image, same command: == all cases passed ==,
exit 0. Full output in the PR comment below.
Also fixed, both raised non-blocking
validate.py rules 5 and 6 named the WRONG INVERTER.unit_caps() skips an entry with no usable max_w, so a caps index stopped being a
roster index the moment one entry was malformed — and both rules fed a caps
index to _unit_label(), which indexes the roster. New unit_caps_indexed() carries the roster index with the cap. Latent (rule
9(d) refuses such a config in the same pass) and fixed anyway, with a
regression check in part 10 that runs the old expressions on the same
fixture and shows them producing the wrong names.
DOCS.md's downgrade note said "safe". True of the schema, incomplete
about the add-on: rule 9(e) requires the flat setpoint_entity be cleared
when a roster is declared, so a downgrade lands on a blank actuator and the
older version refuses to start until someone refills the field. Confirmed by
running release/1.0's own validate_startup_config() on that blob. The note
now says recoverable and loud, not safe, and tells the operator to write the
entity down first.
19/19 green.
The defect
DEV-02 made every per-device value per-instance — rating, SoC, watchdogs,
heartbeat, store keys — so the code stopped assuming one inverter. The options schema did not. build_roster() returned a list of exactly one and
said so in its own docstring, and there was no inverters: key for an operator
to write. The roster could only ever be a roster of one.
The shape, and how it was established
config.yaml had no list-of-objects anywhere, and static_profile carries a
whole tariff structure as a JSON string in a str?precisely because a nested
schema was doubted. Whether the Supervisor's options validator accepts one on
this HA version was unverified, and guessing wrong is not a compile error — it
is an operator whose add-on will not start after an update, with the schema
pinned to the version they cannot install.
So it was run, not reasoned. tools/supervisor_shape_probe.py (committed)
executes Supervisor 2026.08.0's own AppOptions validator and its own SCHEMA_APP_CONFIG, out of ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0,
against this repository's config.yaml. 2026.08.0 is what the live install
reports. Nothing is re-implemented; if the Supervisor changes its rules the
script changes its answer. It touches nothing — it reads the file and runs a
validator in a throwaway container, and never talks to the live Supervisor.
Full transcript and the argument: DECISIONS/CFG-04-inverter-roster.md.
what was tested
what it did
the whole manifest, list-of-objects and all
accepted by SCHEMA_APP_CONFIG
a two-inverter options blob
accepted, per-entry types enforced
an entry with max_w: 9999
refused — "value must be at most 5000.0"
an entry missing name / setpoint_entity / max_w
refused by name — "Missing option 'name' in inverters"
a JSON string where the list belongs
refused — "Invalid list for option 'inverters'"
the Supervisor UI renderer
{"type": "schema", "multiple": true} — a repeatable section an operator can edit
the ROLLBACK direction (new options, old schema)
key dropped with a warning, not refused
⚠️ The measurement that decided the default
§B is an isolated counterfactual: exactly one key differs between its two
runs. The shipped options: block validates as it stands; the same block
with only inverters removed is answered with Missing option 'inverters' in root — a nested list whose element is a dict
is never optional to _check_missing_options(). Every existing install's stored
options are that blob, because the key did not exist when they were written.
The probe asserts on the key name in the refusal, not on "a refusal
happened", so it goes red if the refusal is ever about some other key.
(§B2 is a separate, honestly labelled case: a reconstructed pre-0.6.0 blob,
merged with the defaults, validating. Its RAW half is refused for whichever
unrelated required key the incomplete reconstruction lacks first — not
evidence about inverters. See the fix round at the top of this description.)App.options is the shipped options: block deep-merged with the operator's stored one, so inverters: [] in config.yaml is the difference between an upgrade nobody notices and an add-on
that cannot start. That is docs/deploy-rollback.md §4's schema-conflict trap in
the forward direction, found before it was sprung.
The merge strategy was checked too, because it is the other way this goes wrong: _OPTIONS_MERGER is type_strategies=[(dict, ["merge"])] with an override
fallback, so lists override rather than concatenate — a non-empty default
could not have grown a roster with inverter A declared twice.
What was chosen, and why not the JSON-string precedent
str? + JSON works and is what would have shipped had the list failed. It loses
on three things:
SAFETY-28's writer census stops working.actuator_writers() walks the
options tree for setpoint_entity at any depth — its own docstring
anticipates this exact card. Inside a JSON string those entities are
invisible, so the §1.5 two-writers guarantee would have needed
re-implementing against a parsed copy: a second place for the census to be
wrong.
No per-field validation — one opaque string, and every error message
written here instead of by the Supervisor.
No UI — a text box for hand-typed JSON, versus a repeatable section.
Nothing is inherited by an entry that omits a key, which is DEV-02 restated
as a config rule. name, setpoint_entity, max_w required; a missing soc_entity means this unit has no SoC sensor, never use the other one's.
Defaults still live in exactly one place (CFG-03) — there is no per-entry
default anywhere, because there is no optional per-entry number.
Two permissive readers in shipped code, and three in the checks
validate.py rules 5 and 6 compared their thresholds against the flat max_w. At a roster of two 2000 W units with max_w: 5000 left over from a
single-inverter install, sign_disagree_w: 3000passed rule 5 while
SAFETY-02's watchdog could fire on neither unit — rule 5's own failure mode,
reached through rule 5. They read unit_caps() now; at one inverter that is [max_w] exactly.
main.py's run_control() re-ran the device-rating check behind if self.o.get("setpoint_entity") — the flat key, which a roster leaves
blank — so on a two-inverter site no inverter's max_w was ever graded
against the device it is written to, silently.
test_peakguard.read_option() / _schema_bound() and test_inverter.read_schema_max() matched a stripped line and took the
first hit, so the nested per-inverter max_w: began outranking the flat one — silently and correctly, because the two bounds are the same int(100,5000) today. A reader that is right by coincidence is this project's
named failure mode. test_tariff.py's copy of the idiom went with them.
test_inverter.pypart 11 finds every config.yaml-reading function in the
suite — transitively, because two of the readers this card changed never name
the file — executes each against a synthetic two-inverter config.yaml
whose per-inverter numbers differ from its flat ones, and fails if any answers
with the per-inverter value. The table is not maintained by hand: the signature
decides. config.yaml declares inverters:beforemax_w: on purpose and
part 11 asserts that ordering, so the trap stays baited.
Verified by breaking it
Ten mutations run through tools/mutation_run.sh and observed red, listed in test_inverter.py part 12 with the check that bites each — including reverting
each hardened reader, inheriting the flat soc_entity/max_w, dropping rule
9(e), rule 5 back to the flat max_w, run_control()'s check back inside if entity:, removing the inverters: [] default, and allowing duplicate names.
Two survived the first run and the CHECKS were changed, not the table, both
recorded in MUTATION.md:
the inheritance check had blank flat keys, so "inherited" and "not
inherited" were the same empty string;
the run_control() check asserted a source line was present — and
re-indenting it does not remove the line. It drives the shipped run_control() now, under a deadline, because that mutation's real
symptom is a hang rather than a red.
Acceptance criteria
AC
where
1. Supervisor handling verified on this HA version
tools/supervisor_shape_probe.py §A–§E, DECISIONS/CFG-04-inverter-roster.md §2–4; the inverters measurement is §B, re-run 2026-09-04 against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0
3. one-inverter config loads unchanged, suite unmodified
part 9 ([] and key-absent are byte-identical); 19/19 green
4. every config reader handles it or fails loudly
part 11, fed a two-inverter config.yaml
5. actuator_writers() refuses two inverters on one setpoint_entity
part 10
6. verified by breaking it
part 12, ten mutations, all red
7. defaults in exactly one place
no per-entry default exists; test_maintenance/test_price/test_watchdog CFG-03 pins still green
19/19 green in the goodwe-addon-ci image. Version bumped to 0.6.0 — the
Supervisor keys the built image by version:.
What I am unsure about, and what a reviewer should look hardest at
AC 3's "the exact options blob the live install runs today" is a
RECONSTRUCTION, not the real blob. The HA API rejected the token in ~/.goodwe-env (HTTP 401) and the only backup containing local_goodwe_controller is protected: true, so I could not read /data/options.json. The blob in the probe is assembled from HANDOVER.md
§1 (gain 0.6, max_w 5000, slew 1000, deadband 15, step 10, saturation 500/3,
heartbeat 10 s, maintenance disabled, e-stop fitted) and docs/deploy-rollback.md §0/§B.5 (meter_source: homewizard_local, p1_host, meter_poll_s). The property that actually matters is structural
and holds for any blob: no options written before this version contain inverters, and the merge with the shipped [] is what makes them validate.
But if someone can ssh root@192.168.2.158 'cat /data/.../options.json', it is
worth re-running the probe against the real thing.
validate.py rule 6 grades maintenance_charge_w against the first entry of unit_caps_indexed(),
because Controller.maint_unit is inverters[0]. That is a coupling between
two files. I chose it over min(caps) deliberately — min() would refuse a
5000 W pack being recalibrated beside a 2000 W sibling the cycle never touches
— but it is the line that has to move when a second Maintenance FSM arrives,
and it is only a comment that says so.
The boot-time gate in amain() still probes only the flat setpoint_entity, so at a roster config dev_min/dev_max are None there
and the per-unit range check happens in run_control() instead. That matches
the existing design (ESPHome is usually not up at boot, so the gate tolerates
unknown ranges and run_control() re-checks), but it does mean a two-inverter
site's first range verdict arrives a moment later than a one-inverter site's.
test_peakguard.py failed 7 checks once, on a heavily loaded host, and has
not reproduced since (three clean runs, plus the full suite twice). The
failures clustered on the SAFETY-24 floor-limited scenario, which calls PeakTracker.project(..., datetime.now(timezone.utc)) — a wall-clock read
whose position within the billed quarter changes the projection. I believe it
is pre-existing wall-clock sensitivity rather than anything this branch does
(none of the changed readers alters the values it consumes — verified by
printing old-reader and new-reader answers side by side, identical for every
key), but I did not chase it and it is worth a second opinion.
Two checks in test_validate.py were edited, and I want that seen rather
than skimmed: SAFETY-28 wrote them against a guessed CFG-04 shape (id
keys, entries with only a setpoint_entity). One is updated to the shape this
card actually chose; the other asserted that a blank per-inverter entity left
the whole config accepted, and this card decided the opposite — a roster entry
nothing can command is refused. The census claim it was making is preserved
and now asserted directly on actuator_writers(). The hazard fixture is
deliberately left in the old guessed shape, because its claim is that the
census finds writers structurally under a container it was never told about.
> ## Fix round — 2026-09-04, after review
>
> The review had **one blocking finding**, and it was not in the design or the
> shipped code: **`tools/supervisor_shape_probe.py` §B did not measure what four
> documents said it measured, and it was regression-blind for the property it
> was cited for.**
>
> **What was wrong.** §B validated the *reconstructed live options blob*, which
> is missing many required keys that have nothing to do with this card. Its
> refusal therefore named whichever of *those* the validator reached first — the
> committed transcript recorded `meter_freeze_s`, the reviewer's run printed
> `p1_phase_export_entities`, and a run today printed `sign_disagree_w`. **The
> quoted `Missing option 'inverters' in root` was never produced by the
> instrument.** Worse, §B asserted a bare `not ok`, so deleting `inverters: []`
> from `config.yaml` left it printing **PASS** — blind to the exact regression it
> is the committed evidence for.
>
> **The underlying claim is true**, and is now measured properly.
>
> **What changed.**
> * `tools/supervisor_shape_probe.py` **§B** is now the isolated counterfactual:
> the full shipped defaults **with** the key (expect accept), and the same
> defaults **without it and nothing else varying** (expect refusal). It asserts
> on the **key name in the refusal string**, so the line goes red if the
> refusal is about anything else. The probe also **exits non-zero** when any
> case fails — without that, a transcript is only evidence to someone who reads
> it.
> * The old live-blob case survives as **§B2**, relabelled for what it actually
> is: an incomplete reconstruction whose raw form is refused for **unrelated**
> missing keys, kept because its *merged* half genuinely demonstrates
> `deepmerge(options:, stored)`.
> * `DECISIONS/CFG-04-inverter-roster.md` — §4's "§B shows both halves" sentence
> and the line-103 transcript corrected; **the whole transcript re-run and
> replaced with a real one**; §3 gains an explicit row for the default.
> * `CHANGELOG.md`, `test_inverter.py`'s part-9 comment and its **part-12
> mutation row** re-attributed to §B, with the row now recording that the probe
> itself was blind to that mutation until review.
> * `MUTATION.md` gains this as the **sixth instance** of "the check's data
> cannot reach the failure it names" — the first one found outside the suite,
> in a committed evidence artifact — plus three habits: assert on the *content*
> of a refusal, vary one thing from an *accepted* baseline, and treat a
> committed transcript as a check subject to the mutation battery.
>
> **The proof the reviewer asked for** — `inverters: []` deleted from
> `config.yaml`, probe re-run against
> `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
>
> ```
> == B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
> FAIL the shipped `options:` block validates AS IT STANDS - ...
> Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
> ...
> == 2 CASE(S) FAILED ==
> ```
> exit **1**. Key restored, same image, same command: `== all cases passed ==`,
> exit **0**. Full output in the PR comment below.
>
> ### Also fixed, both raised non-blocking
> * **`validate.py` rules 5 and 6 named the WRONG INVERTER.** `unit_caps()`
> *skips* an entry with no usable `max_w`, so a caps index stopped being a
> roster index the moment one entry was malformed — and both rules fed a caps
> index to `_unit_label()`, which indexes the roster. New
> `unit_caps_indexed()` carries the roster index with the cap. Latent (rule
> 9(d) refuses such a config in the same pass) and fixed anyway, with a
> regression check in part 10 that runs the **old expressions** on the same
> fixture and shows them producing the wrong names.
> * **`DOCS.md`'s downgrade note said "safe".** True of the *schema*, incomplete
> about the *add-on*: rule 9(e) requires the flat `setpoint_entity` be cleared
> when a roster is declared, so a downgrade lands on a blank actuator and the
> older version refuses to start until someone refills the field. Confirmed by
> running `release/1.0`'s own `validate_startup_config()` on that blob. The note
> now says recoverable and loud, not safe, and tells the operator to write the
> entity down first.
>
> 19/19 green.
---
## The defect
DEV-02 made every per-device value per-instance — rating, SoC, watchdogs,
heartbeat, store keys — so the **code** stopped assuming one inverter. The
**options schema** did not. `build_roster()` returned a list of exactly one and
said so in its own docstring, and there was no `inverters:` key for an operator
to write. The roster could only ever be a roster of one.
## The shape, and how it was established
`config.yaml` had no list-of-objects anywhere, and `static_profile` carries a
whole tariff structure as a JSON string in a `str?` *precisely because* a nested
schema was doubted. Whether the Supervisor's options validator accepts one on
this HA version was unverified, and guessing wrong is not a compile error — it
is an operator whose add-on will not start after an update, with the schema
pinned to the version they cannot install.
So it was **run**, not reasoned. `tools/supervisor_shape_probe.py` (committed)
executes **Supervisor 2026.08.0's own `AppOptions` validator and its own
`SCHEMA_APP_CONFIG`**, out of `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`,
against **this repository's `config.yaml`**. 2026.08.0 is what the live install
reports. Nothing is re-implemented; if the Supervisor changes its rules the
script changes its answer. It touches nothing — it reads the file and runs a
validator in a throwaway container, and never talks to the live Supervisor.
Full transcript and the argument: `DECISIONS/CFG-04-inverter-roster.md`.
| what was tested | what it did |
|---|---|
| the whole manifest, list-of-objects and all | **accepted** by `SCHEMA_APP_CONFIG` |
| a two-inverter options blob | **accepted**, per-entry types enforced |
| an entry with `max_w: 9999` | refused — *"value must be at most 5000.0"* |
| an entry missing `name` / `setpoint_entity` / `max_w` | refused **by name** — *"Missing option 'name' in inverters"* |
| a JSON string where the list belongs | refused — *"Invalid list for option 'inverters'"* |
| the Supervisor UI renderer | `{"type": "schema", "multiple": true}` — a repeatable section an operator can edit |
| the ROLLBACK direction (new options, old schema) | key **dropped with a warning**, not refused |
### ⚠️ The measurement that decided the default
**§B is an isolated counterfactual: exactly one key differs between its two
runs.** The shipped `options:` block validates as it stands; the **same block
with only `inverters` removed** is answered with
**`Missing option 'inverters' in root`** — a nested list whose element is a dict
is never optional to `_check_missing_options()`. Every existing install's stored
options are that blob, because the key did not exist when they were written.
The probe asserts on the **key name in the refusal**, not on "a refusal
happened", so it goes red if the refusal is ever about some other key.
*(§B2 is a separate, honestly labelled case: a reconstructed pre-0.6.0 blob,
merged with the defaults, validating. Its RAW half is refused for whichever
unrelated required key the incomplete reconstruction lacks first — **not**
evidence about `inverters`. See the fix round at the top of this description.)* `App.options` is the shipped `options:` block
**deep-merged** with the operator's stored one, so `inverters: []` in
`config.yaml` is the difference between an upgrade nobody notices and an add-on
that cannot start. That is `docs/deploy-rollback.md` §4's schema-conflict trap in
the **forward** direction, found before it was sprung.
The merge strategy was checked too, because it is the other way this goes wrong:
`_OPTIONS_MERGER` is `type_strategies=[(dict, ["merge"])]` with an `override`
fallback, so **lists override rather than concatenate** — a non-empty default
could not have grown a roster with inverter A declared twice.
## What was chosen, and why not the JSON-string precedent
`str?` + JSON works and is what would have shipped had the list failed. It loses
on three things:
1. **SAFETY-28's writer census stops working.** `actuator_writers()` walks the
options *tree* for `setpoint_entity` at any depth — its own docstring
anticipates this exact card. Inside a JSON *string* those entities are
invisible, so the §1.5 two-writers guarantee would have needed
re-implementing against a parsed copy: a second place for the census to be
wrong.
2. **No per-field validation** — one opaque string, and every error message
written here instead of by the Supervisor.
3. **No UI** — a text box for hand-typed JSON, versus a repeatable section.
**Nothing is inherited by an entry that omits a key**, which is DEV-02 restated
as a config rule. `name`, `setpoint_entity`, `max_w` required; a missing
`soc_entity` means *this unit has no SoC sensor*, never *use the other one's*.
Defaults still live in exactly one place (CFG-03) — there is no per-entry
default anywhere, because there is no optional per-entry number.
## Two permissive readers in shipped code, and three in the checks
* **`validate.py` rules 5 and 6** compared their thresholds against the **flat**
`max_w`. At a roster of two 2000 W units with `max_w: 5000` left over from a
single-inverter install, `sign_disagree_w: 3000` **passed rule 5** while
SAFETY-02's watchdog could fire on *neither* unit — rule 5's own failure mode,
reached through rule 5. They read `unit_caps()` now; at one inverter that is
`[max_w]` exactly.
* **`main.py`'s `run_control()`** re-ran the device-rating check behind
`if self.o.get("setpoint_entity")` — the *flat* key, which a roster leaves
blank — so on a two-inverter site **no** inverter's `max_w` was ever graded
against the device it is written to, silently.
* **`test_peakguard.read_option()` / `_schema_bound()` and
`test_inverter.read_schema_max()`** matched a **stripped** line and took the
first hit, so the nested per-inverter `max_w:` began outranking the flat one —
**silently and correctly**, because the two bounds are the same
`int(100,5000)` today. A reader that is right by coincidence is this project's
named failure mode. `test_tariff.py`'s copy of the idiom went with them.
`test_inverter.py` **part 11** finds every `config.yaml`-reading function in the
suite — *transitively*, because two of the readers this card changed never name
the file — **executes** each against a synthetic two-inverter `config.yaml`
whose per-inverter numbers differ from its flat ones, and fails if any answers
with the per-inverter value. The table is not maintained by hand: the signature
decides. `config.yaml` declares `inverters:` **before** `max_w:` on purpose and
part 11 asserts that ordering, so the trap stays baited.
## Verified by breaking it
Ten mutations run through `tools/mutation_run.sh` and observed **red**, listed in
`test_inverter.py` part 12 with the check that bites each — including reverting
each hardened reader, inheriting the flat `soc_entity`/`max_w`, dropping rule
9(e), rule 5 back to the flat `max_w`, `run_control()`'s check back inside
`if entity:`, removing the `inverters: []` default, and allowing duplicate names.
**Two survived the first run and the CHECKS were changed, not the table**, both
recorded in `MUTATION.md`:
* the inheritance check had **blank flat keys**, so "inherited" and "not
inherited" were the same empty string;
* the `run_control()` check asserted a **source line** was present — and
re-indenting it does not remove the line. It drives the shipped
`run_control()` now, **under a deadline**, because that mutation's real
symptom is a hang rather than a red.
## Acceptance criteria
| AC | where |
|---|---|
| 1. Supervisor handling verified on this HA version | `tools/supervisor_shape_probe.py` §A–§E, `DECISIONS/CFG-04-inverter-roster.md` §2–4; the `inverters` measurement is §B, re-run 2026-09-04 against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` |
| 2. two-inverter config → two-unit roster reaching `live_units()` | `test_inverter.py` part 9 |
| 3. one-inverter config loads unchanged, suite unmodified | part 9 (`[]` and key-absent are byte-identical); 19/19 green |
| 4. every config reader handles it or fails loudly | part 11, fed a two-inverter `config.yaml` |
| 5. `actuator_writers()` refuses two inverters on one `setpoint_entity` | part 10 |
| 6. verified by breaking it | part 12, ten mutations, all red |
| 7. defaults in exactly one place | no per-entry default exists; `test_maintenance`/`test_price`/`test_watchdog` CFG-03 pins still green |
19/19 green in the `goodwe-addon-ci` image. Version bumped to **0.6.0** — the
Supervisor keys the built image by `version:`.
## What I am unsure about, and what a reviewer should look hardest at
* **AC 3's "the exact options blob the live install runs today" is a
RECONSTRUCTION, not the real blob.** The HA API rejected the token in
`~/.goodwe-env` (HTTP 401) and the only backup containing
`local_goodwe_controller` is `protected: true`, so I could not read
`/data/options.json`. The blob in the probe is assembled from `HANDOVER.md`
§1 (gain 0.6, `max_w` 5000, slew 1000, deadband 15, step 10, saturation 500/3,
heartbeat 10 s, maintenance disabled, e-stop fitted) and
`docs/deploy-rollback.md` §0/§B.5 (`meter_source: homewizard_local`,
`p1_host`, `meter_poll_s`). **The property that actually matters is structural
and holds for any blob**: no options written before this version contain
`inverters`, and the merge with the shipped `[]` is what makes them validate.
But if someone can `ssh root@192.168.2.158 'cat /data/.../options.json'`, it is
worth re-running the probe against the real thing.
* **`validate.py` rule 6 grades `maintenance_charge_w` against the first entry of `unit_caps_indexed()`,**
because `Controller.maint_unit` is `inverters[0]`. That is a coupling between
two files. I chose it over `min(caps)` deliberately — `min()` would refuse a
5000 W pack being recalibrated beside a 2000 W sibling the cycle never touches
— but it is the line that has to move when a second `Maintenance` FSM arrives,
and it is only a comment that says so.
* **The boot-time gate in `amain()` still probes only the flat
`setpoint_entity`,** so at a roster config `dev_min`/`dev_max` are `None` there
and the per-unit range check happens in `run_control()` instead. That matches
the existing design (ESPHome is usually not up at boot, so the gate tolerates
unknown ranges and `run_control()` re-checks), but it does mean a two-inverter
site's first range verdict arrives a moment later than a one-inverter site's.
* **`test_peakguard.py` failed 7 checks once, on a heavily loaded host, and has
not reproduced since** (three clean runs, plus the full suite twice). The
failures clustered on the SAFETY-24 floor-limited scenario, which calls
`PeakTracker.project(..., datetime.now(timezone.utc))` — a wall-clock read
whose position within the billed quarter changes the projection. I believe it
is pre-existing wall-clock sensitivity rather than anything this branch does
(none of the changed readers alters the values it consumes — verified by
printing old-reader and new-reader answers side by side, identical for every
key), but I did not chase it and it is worth a second opinion.
* **Two checks in `test_validate.py` were edited**, and I want that seen rather
than skimmed: SAFETY-28 wrote them against a *guessed* CFG-04 shape (`id`
keys, entries with only a `setpoint_entity`). One is updated to the shape this
card actually chose; the other asserted that a blank per-inverter entity left
the whole config accepted, and this card decided the opposite — a roster entry
nothing can command is refused. The census claim it was making is preserved
and now asserted directly on `actuator_writers()`. The **hazard** fixture is
deliberately left in the old guessed shape, because its claim is that the
census finds writers structurally under a container it was never told about.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
DEV-02 made every per-device value per-instance - rating, SoC, watchdogs,
heartbeat, store keys - so the code stopped assuming one inverter. The options
schema did not. `build_roster()` returned a list of exactly one and said so in
its own docstring, and there was no `inverters:` key for an operator to write,
so the roster could only ever be a roster of one.
THE SHAPE, AND WHY IT WAS MEASURED RATHER THAN CHOSEN
config.yaml had no list-of-objects anywhere, and `static_profile` carries a
whole tariff structure as a JSON string in a `str?` precisely because a nested
schema was doubted. That is a question about somebody else's software, so it was
answered by running it: `tools/supervisor_shape_probe.py` (committed) runs
Supervisor 2026.08.0's OWN `AppOptions` validator and `SCHEMA_APP_CONFIG`, out
of the release image, against this repository's config.yaml. 2026.08.0 is the
version the live install reports. The full transcript and the argument are in
DECISIONS/CFG-04-inverter-roster.md.
It accepts the list, enforces per-entry types, refuses a missing `name` /
`setpoint_entity` / `max_w` BY NAME, renders the option in the UI as a
repeatable section, and drops the key with a warning under an older schema - so
a downgrade is not the forward trap in reverse.
⚠️ AND THE `inverters: []` DEFAULT IS LOAD-BEARING, WHICH IS THE MEASUREMENT
THAT MATTERED MOST. Supervisor answers `Missing option 'inverters' in root` for
an options blob that has no such key - which every existing install's has, since
the key did not exist when they were written. `App.options` is the shipped
`options:` block deep-merged with the operator's stored one, so the empty
default is the difference between an upgrade nobody notices and an add-on that
cannot start with a schema pinned to the version that would fix it. That is
docs/deploy-rollback.md §4's trap in the FORWARD direction, found before it was
sprung. The merger was checked too: lists override rather than concatenate, so a
non-empty default could not have grown a roster behind the operator's back.
NOTHING IS INHERITED, WHICH IS DEV-02 RESTATED AS A CONFIG RULE
`name`, `setpoint_entity` and `max_w` are required of every entry. An entry that
omits `soc_entity` has no SoC sensor; it does not borrow the flat one. A
three-line fallback would have rebuilt the sibling-grading defect DEV-02 spent a
card removing, in YAML. Defaults still live in exactly one place (CFG-03):
there is no per-entry default anywhere, because there is no optional per-entry
number. A flat `setpoint_entity` left set beside a declared roster is refused
rather than ignored - it names an actuator nothing will write.
TWO PERMISSIVE READERS IN SHIPPED CODE, AND THREE MORE IN THE CHECKS
validate.py rules 5 and 6 compared their thresholds against the FLAT `max_w`.
At a roster of two 2000 W units with `max_w: 5000` left over from a
single-inverter install, `sign_disagree_w: 3000` passed rule 5 while
SAFETY-02's watchdog could fire on NEITHER unit - rule 5's own failure mode,
reached through rule 5. They read `unit_caps()` now, which at one inverter is
`[max_w]` exactly.
main.py's `run_control()` re-ran the device-rating check behind
`if self.o.get("setpoint_entity")` - the flat key, which a roster leaves blank
- so on a two-inverter site no inverter's `max_w` was graded against the
device it is written to, and the skip was silent. `validate_unit_ranges()`
grades each unit against its own ESPHome and runs unconditionally.
test_peakguard.py's `read_option()` and `_schema_bound()` and
test_inverter.py's `read_schema_max()` matched a STRIPPED line and took the
first hit, so the nested per-inverter `max_w:` began outranking the flat one -
silently, and correctly, because the two bounds are the same int(100,5000)
today. A reader that is right by coincidence is this project's named failure
mode. All three are anchored at exactly two spaces now; the file's two halves
disambiguate `peak_cap_w` instead of first-match, and an ambiguous key raises
rather than picking one. test_tariff.py's copy of the idiom went with them.
THE AUDIT IS A CHECK, NOT A PROMISE
test_inverter.py part 11 finds every config.yaml-reading function in the suite -
transitively, because two of the readers CFG-04 had to change never name the
file - EXECUTES each against a synthetic two-inverter config.yaml whose
per-inverter numbers differ from its flat ones, and fails if any answers with
the per-inverter value. The table is not maintained by hand: the signature
decides, so the eleventh reader is named on its first run. config.yaml declares
`inverters:` BEFORE `max_w:` on purpose and part 11 asserts that ordering, so
the trap stays baited.
SAFETY-28's `actuator_writers()` needed no change - it was already a structural,
case-folding census - but until now there was no configuration in which two
writers on one actuator could be WRITTEN DOWN. There is now, and it is refused.
VERIFIED BY BREAKING IT
Ten mutations run through tools/mutation_run.sh and observed red, listed in
test_inverter.py part 12 with the check that bites each. Two survived the first
run and the CHECKS were changed, not the table: the inheritance check had blank
flat keys, so "inherited" and "not inherited" were the same empty string; and
the `run_control()` check asserted a SOURCE LINE was present, which re-indenting
it does not remove. Both are recorded in MUTATION.md's "the check's data cannot
reach the failure it names" section, which now carries two more habits: assert
on behaviour rather than on source text, and give a check whose failure mode is
a hang a deadline.
19/19 green in the goodwe-addon-ci image. The one-inverter path is byte-
identical: the whole existing suite runs unmodified against it.
Version bumped to 0.6.0 - the Supervisor keys the built image by `version:`, so
a schema change without a bump is a schema nobody reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Review blocked this on ONE finding, and it was in the EVIDENCE, not the code.
The design and the shipped behaviour are unchanged.
THE INSTRUMENT DID NOT MEASURE WHAT FOUR DOCUMENTS SAID IT MEASURED
tools/supervisor_shape_probe.py section B validated the RECONSTRUCTED LIVE
options blob - which is missing many required keys that have nothing to do with
this card. Its refusal therefore named whichever of THOSE the validator reached
first: the committed transcript recorded `meter_freeze_s`, the reviewer's run
printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`.
The string those documents quoted - `Missing option 'inverters' in root` - was
never produced by the instrument.
And section B asserted a bare `not ok`. Any refusal at all satisfied it, so
deleting `inverters: []` from config.yaml left it printing PASS: the probe could
not detect the one regression it is the committed evidence for.
THE UNDERLYING CLAIM IS TRUE AND IS NOW MEASURED PROPERLY
Section B is an ISOLATED COUNTERFACTUAL now: the full shipped `options:` block
(expect accept), and the SAME block with `inverters` removed and nothing else
varying (expect refusal). One key differs, so the refusal has nowhere else to
come from - and the assertion is on the KEY NAME IN THE REFUSAL STRING, so the
line goes red if the refusal is ever about a different key. The probe also exits
non-zero when any case fails; without that a transcript is only evidence to
someone who reads it.
The old live-blob case survives as section B2, relabelled for what it is: an
incomplete reconstruction whose RAW half is refused for unrelated missing keys,
kept because its MERGED half genuinely demonstrates what App.options is.
VERIFIED BY BREAKING IT, WHICH IS THE WHOLE POINT
`inverters: []` deleted from config.yaml, probe re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
FAIL the shipped `options:` block validates AS IT STANDS - ...
Missing option 'inverters' in root in GoodWe RS485 Controller
...
== 2 CASE(S) FAILED ==
exit 1. Key restored, same image, same command: `== all cases passed ==`, exit
0. The transcript in DECISIONS is that restored run, re-run today, not the old
one.
THE FOUR MIS-CITATIONS
DECISIONS/CFG-04-inverter-roster.md section 4's "section B shows both halves"
sentence and its line-103 transcript; CHANGELOG.md; test_inverter.py's part-9
comment and its part-12 mutation row; and the PR description. All now attribute
the string to section B and say plainly what section B2 does not show. The
part-12 row additionally records that the probe was blind to that mutation until
review, because a mutation table that lists a defeat nothing catches is the
failure this repository keeps finding.
MUTATION.md gains it as the sixth instance of "the check's data cannot reach the
failure it names" - the first found OUTSIDE the suite, in a committed evidence
artifact, because nothing in that document had ever been pointed at tools/. Three
habits with it: assert on the CONTENT of a refusal rather than that one happened;
vary ONE thing, from an ACCEPTED baseline; and treat a committed transcript as a
check subject to the mutation battery.
TWO NON-BLOCKING FINDINGS, ALSO FIXED
validate.py rules 5 and 6 NAMED THE WRONG INVERTER. `unit_caps()` SKIPS an
entry with no usable `max_w`, so caps position i stops being roster position i
the moment one entry is malformed - and rule 5 fed `caps.index(weakest)` to
`_unit_label()`, which indexes the roster, while rule 6 paired `caps[0]` with a
hard-wired `_unit_label(opts, 0)`. Right number, another inverter's name: an
operator sent to the wrong device by the message that exists to stop that. New
`unit_caps_indexed()` carries the roster index with the cap. It is latent -
rule 9(d) refuses such a config in the same pass - but that was a property of
today's rule ordering, not of the code, and validate_startup_config() is also
sim/inject.py's entry point. Part 10 gains a check that asserts the fixture
really does desynchronise the two lists, then that both messages name the right
unit, then the counterfactual: the OLD expressions, on that same config, still
evaluate to the wrong names.
DOCS.md called a downgrade "safe". True of the SCHEMA - the older schema drops
the key with a warning - and incomplete about the ADD-ON: rule 9(e) requires the
flat `setpoint_entity` be cleared when a roster is declared, so a config that
downgrades cleanly is by construction one whose flat actuator is blank, and the
older version has nothing to write to. Confirmed by running release/1.0's own
validate_startup_config() on that blob: one refusal, naming setpoint_entity.
Recoverable and loud, but an outage until a field is refilled. The note says so
now, and tells the operator to write the entity down before downgrading.
19/19 green in the goodwe-addon-ci image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
The blocking finding is addressed at the instrument, not at the documents that
quoted it.
What was actually wrong
tools/supervisor_shape_probe.py §B validated the reconstructed live options
blob, which is missing many required keys unrelated to this card. Its refusal
named whichever of those the validator reached first — the committed
transcript recorded meter_freeze_s, your run printed p1_phase_export_entities, and a run today printed sign_disagree_w. The
quoted Missing option 'inverters' in root was never produced by the
instrument. And with a bare not ok assertion, deleting inverters: [] left
§B printing PASS.
What §B is now
An isolated counterfactual: the full shipped options: block (expect
accept), then the same block with inverters removed and nothing else
varying (expect refusal), asserted on the key name in the refusal string.
The old case survives as §B2, relabelled as an incomplete reconstruction
refused for unrelated keys. The probe now also exits non-zero on any failed
case.
The proof you asked for
Both runs against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0,
same command, same mounts. The only difference is the one line in config.yaml.
1. inverters: [] DELETED from config.yaml → the probe FAILS
App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
PASS Supervisor accepts the add-on manifest, version 0.6.0
schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
options ship inverters -> null
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
FAIL the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
PASS the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement
Missing option 'price_provider' in root in GoodWe RS485 Controller (local_goodwe_controller)
FAIL the same blob MERGED with these defaults - what App.options actually is
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
== 2 CASE(S) FAILED ==
- the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
- the same blob MERGED with these defaults - what App.options actually is
exit 1.
_no_inv and the shipped defaults are now the same dict, so the accept-half has
nothing left to be a baseline for and goes red — which is exactly the signal
that ought to appear when the default is gone. §B2's merged half goes red with
it, for the same reason.
2. Key restored → the probe PASSES
App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
PASS Supervisor accepts the add-on manifest, version 0.6.0
schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
options ship inverters -> []
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
PASS the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
PASS ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
PASS the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement
Missing option 'sign_disagree_w' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS the same blob MERGED with these defaults - what App.options actually is
inverters=[] setpoint_entity='number.kelder_goodwe_master_stage1_goodwe_setpoint_w' max_w=5000
== C. AC2: a two-inverter blob an operator would POST ==
PASS two inverters
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_soc", "batt_entity": "s
== D. what the schema itself refuses, per entry ==
PASS an entry with no name is refused
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no setpoint_entity is refused
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no max_w is refused
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with max_w over the range is refused
value must be at most 5000.0
PASS a JSON STRING where the list belongs is refused
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
== E. the ROLLBACK direction, and the operator's UI ==
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
== all cases passed ==
exit 0. This is the transcript now committed in DECISIONS/CFG-04-inverter-roster.md — a real run from today, not the old one.
Note §B2's raw refusal in that run: Missing option 'sign_disagree_w' in root — a third different key across three runs. That non-determinism is the
finding, printed.
The mis-citations, all four
where
was
now
DECISIONS/… §4, "§B shows both halves"
claimed §B showed the inverters refusal
says what §B shows (one key varied, refused by name) and what §B2 does not show
DECISIONS/… line 103 transcript
Missing option 'meter_freeze_s' in root quoted under the inverters claim
the real §B string; whole transcript re-run and replaced
CHANGELOG.md
"answers Missing option 'inverters' in root for an options blob that has no such key"
attributed to §B, and the row records that the probe was blind to that mutation until this review
MUTATION.md gains it as the sixth instance of "the check's data cannot
reach the failure it names" — the first one found outside the suite, in a
committed evidence artifact, because nothing in that document had ever been
pointed at tools/. Three habits with it: assert on the content of a
refusal; vary one thing from an accepted baseline; and treat a committed
transcript as a check subject to the mutation battery.
Your two non-blocking findings
Rules 5 and 6 named the wrong inverter. Confirmed and fixed. unit_caps()
skips an entry with no usable max_w, so a caps index stops being a roster
index; rule 5 fed caps.index(weakest) to _unit_label() (which indexes the
roster) and rule 6 paired caps[0] with a hard-wired _unit_label(opts, 0).
New unit_caps_indexed() carries the roster index with the cap. On a roster of [a: max_w="junk", b: 2000, c: 1000] the old rule 5 named inverter-b for
inverter-c's 1000 W and the old rule 6 named inverter-a for inverter-b's
2000 W. Part 10 now checks the fixture really desynchronises the lists, that
both messages name the right unit, and — the counterfactual — that the old
expressions on that same config still evaluate to the wrong names.
DOCS.md's "safe" downgrade. Corrected. The schema half is true; the add-on
half is not. Rule 9(e) requires the flat setpoint_entity be cleared when a
roster is declared, so a config that downgrades cleanly is by construction one
whose flat actuator is blank. I ran release/1.0's own validate_startup_config() on that blob:
release/1.0 validator -> 1 refusal(s)
- setpoint_entity is not set, so this add-on has no actuator: every write is a no-op, ...
Recoverable and loud, but an outage until a field is refilled. The note says so
and tells the operator to write the entity down before downgrading.
19/19 green in goodwe-addon-ci.
⚠️ Unchanged and still true: inverter B does not physically exist. Its
3000 W is the marked hypothesis SPIKE-04 will replace, and nothing in this round
measured it.
## Fix round pushed — `22c016f`
The blocking finding is addressed at the instrument, not at the documents that
quoted it.
### What was actually wrong
`tools/supervisor_shape_probe.py` §B validated the **reconstructed live options
blob**, which is missing many required keys unrelated to this card. Its refusal
named whichever of *those* the validator reached first — the committed
transcript recorded `meter_freeze_s`, your run printed
`p1_phase_export_entities`, and a run today printed `sign_disagree_w`. The
quoted `Missing option 'inverters' in root` was **never produced by the
instrument**. And with a bare `not ok` assertion, deleting `inverters: []` left
§B printing **PASS**.
### What §B is now
An **isolated counterfactual**: the full shipped `options:` block (expect
accept), then the same block with `inverters` removed **and nothing else
varying** (expect refusal), asserted on the **key name in the refusal string**.
The old case survives as **§B2**, relabelled as an incomplete reconstruction
refused for unrelated keys. The probe now also **exits non-zero** on any failed
case.
---
## The proof you asked for
Both runs against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`,
same command, same mounts. The only difference is the one line in `config.yaml`.
### 1. `inverters: []` DELETED from `config.yaml` → the probe FAILS
```
App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
PASS Supervisor accepts the add-on manifest, version 0.6.0
schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
options ship inverters -> null
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
FAIL the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
PASS the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement
Missing option 'price_provider' in root in GoodWe RS485 Controller (local_goodwe_controller)
FAIL the same blob MERGED with these defaults - what App.options actually is
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
```
```
== 2 CASE(S) FAILED ==
- the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
- the same blob MERGED with these defaults - what App.options actually is
```
**exit 1.**
`_no_inv` and the shipped defaults are now the same dict, so the accept-half has
nothing left to be a baseline for and goes red — which is exactly the signal
that ought to appear when the default is gone. §B2's merged half goes red with
it, for the same reason.
### 2. Key restored → the probe PASSES
```
App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
PASS Supervisor accepts the add-on manifest, version 0.6.0
schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
options ship inverters -> []
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
PASS the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
PASS ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
PASS the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement
Missing option 'sign_disagree_w' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS the same blob MERGED with these defaults - what App.options actually is
inverters=[] setpoint_entity='number.kelder_goodwe_master_stage1_goodwe_setpoint_w' max_w=5000
== C. AC2: a two-inverter blob an operator would POST ==
PASS two inverters
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_soc", "batt_entity": "s
== D. what the schema itself refuses, per entry ==
PASS an entry with no name is refused
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no setpoint_entity is refused
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no max_w is refused
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with max_w over the range is refused
value must be at most 5000.0
PASS a JSON STRING where the list belongs is refused
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
== E. the ROLLBACK direction, and the operator's UI ==
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
== all cases passed ==
```
**exit 0.** This is the transcript now committed in
`DECISIONS/CFG-04-inverter-roster.md` — a real run from today, not the old one.
Note §B2's raw refusal in that run: **`Missing option 'sign_disagree_w' in
root`** — a third different key across three runs. That non-determinism is the
finding, printed.
---
## The mis-citations, all four
| where | was | now |
|---|---|---|
| `DECISIONS/…` §4, "§B shows both halves" | claimed §B showed the `inverters` refusal | says what §B shows (one key varied, refused by name) and what §B2 does **not** show |
| `DECISIONS/…` line 103 transcript | `Missing option 'meter_freeze_s' in root` quoted under the `inverters` claim | the real §B string; whole transcript re-run and replaced |
| `CHANGELOG.md` | "answers `Missing option 'inverters' in root` for an options blob that has no such key" | "the **same block with only `inverters` removed**" |
| `test_inverter.py` part-9 comment + **part-12 mutation row** | attributed to the probe generally | attributed to **§B**, and the row records that the probe was blind to that mutation until this review |
`MUTATION.md` gains it as the **sixth** instance of *"the check's data cannot
reach the failure it names"* — the first one found **outside the suite**, in a
committed evidence artifact, because nothing in that document had ever been
pointed at `tools/`. Three habits with it: assert on the **content** of a
refusal; vary **one** thing from an **accepted** baseline; and treat a committed
transcript as a check subject to the mutation battery.
---
## Your two non-blocking findings
**Rules 5 and 6 named the wrong inverter.** Confirmed and fixed. `unit_caps()`
skips an entry with no usable `max_w`, so a caps index stops being a roster
index; rule 5 fed `caps.index(weakest)` to `_unit_label()` (which indexes the
roster) and rule 6 paired `caps[0]` with a hard-wired `_unit_label(opts, 0)`.
New `unit_caps_indexed()` carries the roster index with the cap. On a roster of
`[a: max_w="junk", b: 2000, c: 1000]` the old rule 5 named **inverter-b** for
inverter-c's 1000 W and the old rule 6 named **inverter-a** for inverter-b's
2000 W. Part 10 now checks the fixture really desynchronises the lists, that
both messages name the right unit, and — the counterfactual — that the **old
expressions on that same config still evaluate to the wrong names**.
**`DOCS.md`'s "safe" downgrade.** Corrected. The schema half is true; the add-on
half is not. Rule 9(e) requires the flat `setpoint_entity` be cleared when a
roster is declared, so a config that downgrades cleanly is by construction one
whose flat actuator is blank. I ran `release/1.0`'s own
`validate_startup_config()` on that blob:
```
release/1.0 validator -> 1 refusal(s)
- setpoint_entity is not set, so this add-on has no actuator: every write is a no-op, ...
```
Recoverable and loud, but an outage until a field is refilled. The note says so
and tells the operator to write the entity down before downgrading.
---
**19/19 green** in `goodwe-addon-ci`.
⚠️ Unchanged and still true: **inverter B does not physically exist.** Its
3000 W is the marked hypothesis SPIKE-04 will replace, and nothing in this round
measured it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin
merged commit 18f052a071 into release/1.02026-09-04 11:51:58 +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.
The defect
DEV-02 made every per-device value per-instance — rating, SoC, watchdogs,
heartbeat, store keys — so the code stopped assuming one inverter. The
options schema did not.
build_roster()returned a list of exactly one andsaid so in its own docstring, and there was no
inverters:key for an operatorto write. The roster could only ever be a roster of one.
The shape, and how it was established
config.yamlhad no list-of-objects anywhere, andstatic_profilecarries awhole tariff structure as a JSON string in a
str?precisely because a nestedschema was doubted. Whether the Supervisor's options validator accepts one on
this HA version was unverified, and guessing wrong is not a compile error — it
is an operator whose add-on will not start after an update, with the schema
pinned to the version they cannot install.
So it was run, not reasoned.
tools/supervisor_shape_probe.py(committed)executes Supervisor 2026.08.0's own
AppOptionsvalidator and its ownSCHEMA_APP_CONFIG, out ofghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0,against this repository's
config.yaml. 2026.08.0 is what the live installreports. Nothing is re-implemented; if the Supervisor changes its rules the
script changes its answer. It touches nothing — it reads the file and runs a
validator in a throwaway container, and never talks to the live Supervisor.
Full transcript and the argument:
DECISIONS/CFG-04-inverter-roster.md.SCHEMA_APP_CONFIGmax_w: 9999name/setpoint_entity/max_w{"type": "schema", "multiple": true}— a repeatable section an operator can edit⚠️ The measurement that decided the default
§B is an isolated counterfactual: exactly one key differs between its two
runs. The shipped
options:block validates as it stands; the same blockwith only
invertersremoved is answered withMissing option 'inverters' in root— a nested list whose element is a dictis never optional to
_check_missing_options(). Every existing install's storedoptions are that blob, because the key did not exist when they were written.
The probe asserts on the key name in the refusal, not on "a refusal
happened", so it goes red if the refusal is ever about some other key.
(§B2 is a separate, honestly labelled case: a reconstructed pre-0.6.0 blob,
merged with the defaults, validating. Its RAW half is refused for whichever
unrelated required key the incomplete reconstruction lacks first — not
evidence about
inverters. See the fix round at the top of this description.)App.optionsis the shippedoptions:blockdeep-merged with the operator's stored one, so
inverters: []inconfig.yamlis the difference between an upgrade nobody notices and an add-onthat cannot start. That is
docs/deploy-rollback.md§4's schema-conflict trap inthe forward direction, found before it was sprung.
The merge strategy was checked too, because it is the other way this goes wrong:
_OPTIONS_MERGERistype_strategies=[(dict, ["merge"])]with anoverridefallback, so lists override rather than concatenate — a non-empty default
could not have grown a roster with inverter A declared twice.
What was chosen, and why not the JSON-string precedent
str?+ JSON works and is what would have shipped had the list failed. It loseson three things:
actuator_writers()walks theoptions tree for
setpoint_entityat any depth — its own docstringanticipates this exact card. Inside a JSON string those entities are
invisible, so the §1.5 two-writers guarantee would have needed
re-implementing against a parsed copy: a second place for the census to be
wrong.
written here instead of by the Supervisor.
Nothing is inherited by an entry that omits a key, which is DEV-02 restated
as a config rule.
name,setpoint_entity,max_wrequired; a missingsoc_entitymeans this unit has no SoC sensor, never use the other one's.Defaults still live in exactly one place (CFG-03) — there is no per-entry
default anywhere, because there is no optional per-entry number.
Two permissive readers in shipped code, and three in the checks
validate.pyrules 5 and 6 compared their thresholds against the flatmax_w. At a roster of two 2000 W units withmax_w: 5000left over from asingle-inverter install,
sign_disagree_w: 3000passed rule 5 whileSAFETY-02's watchdog could fire on neither unit — rule 5's own failure mode,
reached through rule 5. They read
unit_caps()now; at one inverter that is[max_w]exactly.main.py'srun_control()re-ran the device-rating check behindif self.o.get("setpoint_entity")— the flat key, which a roster leavesblank — so on a two-inverter site no inverter's
max_wwas ever gradedagainst the device it is written to, silently.
test_peakguard.read_option()/_schema_bound()andtest_inverter.read_schema_max()matched a stripped line and took thefirst hit, so the nested per-inverter
max_w:began outranking the flat one —silently and correctly, because the two bounds are the same
int(100,5000)today. A reader that is right by coincidence is this project'snamed failure mode.
test_tariff.py's copy of the idiom went with them.test_inverter.pypart 11 finds everyconfig.yaml-reading function in thesuite — transitively, because two of the readers this card changed never name
the file — executes each against a synthetic two-inverter
config.yamlwhose per-inverter numbers differ from its flat ones, and fails if any answers
with the per-inverter value. The table is not maintained by hand: the signature
decides.
config.yamldeclaresinverters:beforemax_w:on purpose andpart 11 asserts that ordering, so the trap stays baited.
Verified by breaking it
Ten mutations run through
tools/mutation_run.shand observed red, listed intest_inverter.pypart 12 with the check that bites each — including revertingeach hardened reader, inheriting the flat
soc_entity/max_w, dropping rule9(e), rule 5 back to the flat
max_w,run_control()'s check back insideif entity:, removing theinverters: []default, and allowing duplicate names.Two survived the first run and the CHECKS were changed, not the table, both
recorded in
MUTATION.md:inherited" were the same empty string;
run_control()check asserted a source line was present — andre-indenting it does not remove the line. It drives the shipped
run_control()now, under a deadline, because that mutation's realsymptom is a hang rather than a red.
Acceptance criteria
tools/supervisor_shape_probe.py§A–§E,DECISIONS/CFG-04-inverter-roster.md§2–4; theinvertersmeasurement is §B, re-run 2026-09-04 againstghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0live_units()test_inverter.pypart 9[]and key-absent are byte-identical); 19/19 greenconfig.yamlactuator_writers()refuses two inverters on onesetpoint_entitytest_maintenance/test_price/test_watchdogCFG-03 pins still green19/19 green in the
goodwe-addon-ciimage. Version bumped to 0.6.0 — theSupervisor keys the built image by
version:.What I am unsure about, and what a reviewer should look hardest at
RECONSTRUCTION, not the real blob. The HA API rejected the token in
~/.goodwe-env(HTTP 401) and the only backup containinglocal_goodwe_controllerisprotected: true, so I could not read/data/options.json. The blob in the probe is assembled fromHANDOVER.md§1 (gain 0.6,
max_w5000, slew 1000, deadband 15, step 10, saturation 500/3,heartbeat 10 s, maintenance disabled, e-stop fitted) and
docs/deploy-rollback.md§0/§B.5 (meter_source: homewizard_local,p1_host,meter_poll_s). The property that actually matters is structuraland holds for any blob: no options written before this version contain
inverters, and the merge with the shipped[]is what makes them validate.But if someone can
ssh root@192.168.2.158 'cat /data/.../options.json', it isworth re-running the probe against the real thing.
validate.pyrule 6 gradesmaintenance_charge_wagainst the first entry ofunit_caps_indexed(),because
Controller.maint_unitisinverters[0]. That is a coupling betweentwo files. I chose it over
min(caps)deliberately —min()would refuse a5000 W pack being recalibrated beside a 2000 W sibling the cycle never touches
— but it is the line that has to move when a second
MaintenanceFSM arrives,and it is only a comment that says so.
amain()still probes only the flatsetpoint_entity, so at a roster configdev_min/dev_maxareNonethereand the per-unit range check happens in
run_control()instead. That matchesthe existing design (ESPHome is usually not up at boot, so the gate tolerates
unknown ranges and
run_control()re-checks), but it does mean a two-invertersite's first range verdict arrives a moment later than a one-inverter site's.
test_peakguard.pyfailed 7 checks once, on a heavily loaded host, and hasnot reproduced since (three clean runs, plus the full suite twice). The
failures clustered on the SAFETY-24 floor-limited scenario, which calls
PeakTracker.project(..., datetime.now(timezone.utc))— a wall-clock readwhose position within the billed quarter changes the projection. I believe it
is pre-existing wall-clock sensitivity rather than anything this branch does
(none of the changed readers alters the values it consumes — verified by
printing old-reader and new-reader answers side by side, identical for every
key), but I did not chase it and it is worth a second opinion.
test_validate.pywere edited, and I want that seen ratherthan skimmed: SAFETY-28 wrote them against a guessed CFG-04 shape (
idkeys, entries with only a
setpoint_entity). One is updated to the shape thiscard actually chose; the other asserted that a blank per-inverter entity left
the whole config accepted, and this card decided the opposite — a roster entry
nothing can command is refused. The census claim it was making is preserved
and now asserted directly on
actuator_writers(). The hazard fixture isdeliberately left in the old guessed shape, because its claim is that the
census finds writers structurally under a container it was never told about.
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Review blocked this on ONE finding, and it was in the EVIDENCE, not the code. The design and the shipped behaviour are unchanged. THE INSTRUMENT DID NOT MEASURE WHAT FOUR DOCUMENTS SAID IT MEASURED tools/supervisor_shape_probe.py section B validated the RECONSTRUCTED LIVE options blob - which is missing many required keys that have nothing to do with this card. Its refusal therefore named whichever of THOSE the validator reached first: the committed transcript recorded `meter_freeze_s`, the reviewer's run printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`. The string those documents quoted - `Missing option 'inverters' in root` - was never produced by the instrument. And section B asserted a bare `not ok`. Any refusal at all satisfied it, so deleting `inverters: []` from config.yaml left it printing PASS: the probe could not detect the one regression it is the committed evidence for. THE UNDERLYING CLAIM IS TRUE AND IS NOW MEASURED PROPERLY Section B is an ISOLATED COUNTERFACTUAL now: the full shipped `options:` block (expect accept), and the SAME block with `inverters` removed and nothing else varying (expect refusal). One key differs, so the refusal has nowhere else to come from - and the assertion is on the KEY NAME IN THE REFUSAL STRING, so the line goes red if the refusal is ever about a different key. The probe also exits non-zero when any case fails; without that a transcript is only evidence to someone who reads it. The old live-blob case survives as section B2, relabelled for what it is: an incomplete reconstruction whose RAW half is refused for unrelated missing keys, kept because its MERGED half genuinely demonstrates what App.options is. VERIFIED BY BREAKING IT, WHICH IS THE WHOLE POINT `inverters: []` deleted from config.yaml, probe re-run against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0: == B. THE LOAD-BEARING ONE: an options blob with no `inverters` key == FAIL the shipped `options:` block validates AS IT STANDS - ... Missing option 'inverters' in root in GoodWe RS485 Controller ... == 2 CASE(S) FAILED == exit 1. Key restored, same image, same command: `== all cases passed ==`, exit 0. The transcript in DECISIONS is that restored run, re-run today, not the old one. THE FOUR MIS-CITATIONS DECISIONS/CFG-04-inverter-roster.md section 4's "section B shows both halves" sentence and its line-103 transcript; CHANGELOG.md; test_inverter.py's part-9 comment and its part-12 mutation row; and the PR description. All now attribute the string to section B and say plainly what section B2 does not show. The part-12 row additionally records that the probe was blind to that mutation until review, because a mutation table that lists a defeat nothing catches is the failure this repository keeps finding. MUTATION.md gains it as the sixth instance of "the check's data cannot reach the failure it names" - the first found OUTSIDE the suite, in a committed evidence artifact, because nothing in that document had ever been pointed at tools/. Three habits with it: assert on the CONTENT of a refusal rather than that one happened; vary ONE thing, from an ACCEPTED baseline; and treat a committed transcript as a check subject to the mutation battery. TWO NON-BLOCKING FINDINGS, ALSO FIXED validate.py rules 5 and 6 NAMED THE WRONG INVERTER. `unit_caps()` SKIPS an entry with no usable `max_w`, so caps position i stops being roster position i the moment one entry is malformed - and rule 5 fed `caps.index(weakest)` to `_unit_label()`, which indexes the roster, while rule 6 paired `caps[0]` with a hard-wired `_unit_label(opts, 0)`. Right number, another inverter's name: an operator sent to the wrong device by the message that exists to stop that. New `unit_caps_indexed()` carries the roster index with the cap. It is latent - rule 9(d) refuses such a config in the same pass - but that was a property of today's rule ordering, not of the code, and validate_startup_config() is also sim/inject.py's entry point. Part 10 gains a check that asserts the fixture really does desynchronise the two lists, then that both messages name the right unit, then the counterfactual: the OLD expressions, on that same config, still evaluate to the wrong names. DOCS.md called a downgrade "safe". True of the SCHEMA - the older schema drops the key with a warning - and incomplete about the ADD-ON: rule 9(e) requires the flat `setpoint_entity` be cleared when a roster is declared, so a config that downgrades cleanly is by construction one whose flat actuator is blank, and the older version has nothing to write to. Confirmed by running release/1.0's own validate_startup_config() on that blob: one refusal, naming setpoint_entity. Recoverable and loud, but an outage until a field is refilled. The note says so now, and tells the operator to write the entity down before downgrading. 19/19 green in the goodwe-addon-ci image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjUFix round pushed —
22c016fThe blocking finding is addressed at the instrument, not at the documents that
quoted it.
What was actually wrong
tools/supervisor_shape_probe.py§B validated the reconstructed live optionsblob, which is missing many required keys unrelated to this card. Its refusal
named whichever of those the validator reached first — the committed
transcript recorded
meter_freeze_s, your run printedp1_phase_export_entities, and a run today printedsign_disagree_w. Thequoted
Missing option 'inverters' in rootwas never produced by theinstrument. And with a bare
not okassertion, deletinginverters: []left§B printing PASS.
What §B is now
An isolated counterfactual: the full shipped
options:block (expectaccept), then the same block with
invertersremoved and nothing elsevarying (expect refusal), asserted on the key name in the refusal string.
The old case survives as §B2, relabelled as an incomplete reconstruction
refused for unrelated keys. The probe now also exits non-zero on any failed
case.
The proof you asked for
Both runs against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0,same command, same mounts. The only difference is the one line in
config.yaml.1.
inverters: []DELETED fromconfig.yaml→ the probe FAILSexit 1.
_no_invand the shipped defaults are now the same dict, so the accept-half hasnothing left to be a baseline for and goes red — which is exactly the signal
that ought to appear when the default is gone. §B2's merged half goes red with
it, for the same reason.
2. Key restored → the probe PASSES
exit 0. This is the transcript now committed in
DECISIONS/CFG-04-inverter-roster.md— a real run from today, not the old one.Note §B2's raw refusal in that run:
Missing option 'sign_disagree_w' in root— a third different key across three runs. That non-determinism is thefinding, printed.
The mis-citations, all four
DECISIONS/…§4, "§B shows both halves"invertersrefusalDECISIONS/…line 103 transcriptMissing option 'meter_freeze_s' in rootquoted under theinvertersclaimCHANGELOG.mdMissing option 'inverters' in rootfor an options blob that has no such key"invertersremoved"test_inverter.pypart-9 comment + part-12 mutation rowMUTATION.mdgains it as the sixth instance of "the check's data cannotreach the failure it names" — the first one found outside the suite, in a
committed evidence artifact, because nothing in that document had ever been
pointed at
tools/. Three habits with it: assert on the content of arefusal; vary one thing from an accepted baseline; and treat a committed
transcript as a check subject to the mutation battery.
Your two non-blocking findings
Rules 5 and 6 named the wrong inverter. Confirmed and fixed.
unit_caps()skips an entry with no usable
max_w, so a caps index stops being a rosterindex; rule 5 fed
caps.index(weakest)to_unit_label()(which indexes theroster) and rule 6 paired
caps[0]with a hard-wired_unit_label(opts, 0).New
unit_caps_indexed()carries the roster index with the cap. On a roster of[a: max_w="junk", b: 2000, c: 1000]the old rule 5 named inverter-b forinverter-c's 1000 W and the old rule 6 named inverter-a for inverter-b's
2000 W. Part 10 now checks the fixture really desynchronises the lists, that
both messages name the right unit, and — the counterfactual — that the old
expressions on that same config still evaluate to the wrong names.
DOCS.md's "safe" downgrade. Corrected. The schema half is true; the add-onhalf is not. Rule 9(e) requires the flat
setpoint_entitybe cleared when aroster is declared, so a config that downgrades cleanly is by construction one
whose flat actuator is blank. I ran
release/1.0's ownvalidate_startup_config()on that blob:Recoverable and loud, but an outage until a field is refilled. The note says so
and tells the operator to write the entity down before downgrading.
19/19 green in
goodwe-addon-ci.⚠️ Unchanged and still true: inverter B does not physically exist. Its
3000 W is the marked hypothesis SPIKE-04 will replace, and nothing in this round
measured it.
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU