# CFG-04 — how a second inverter is declared, and why that shape was measured **Status: DECIDED 2026-09-04 — a list of objects, `inverters:`, with `[]` as the shipped default.** The alternative on the table was the `static_profile` precedent: a structured value carried as a JSON **string** in a `str?` option. Both work. The list wins on three counts that were measured rather than argued, and the empty default is not cosmetic — without it the upgrade breaks. Filed with CFG-04. Card 6a99fdf9d381935eddce4467. --- ## 1. The question, and why it could not be answered from the documentation `config.yaml` used `list(off|ha_dsmr|…)` for enums and `str?` for free text, and **there was no list-of-objects anywhere in the file**. `static_profile` (`options:` `""`, `schema:` `str?`) carries a whole tariff structure as one JSON line, and its own comment says why: *"a JSON string rather than a nested schema, deliberately … it survives config.yaml's flat options block"*. That comment is evidence that a nested schema was **doubted**, not that it was tried. So the question was not "is this expressible in YAML" but **"does the Supervisor this site actually runs accept it"** — and the Supervisor pins the options schema to the *installed* version (`docs/pickup.md`), so guessing wrong is not a compile error, it is an operator whose add-on will not start after an update (`docs/deploy-rollback.md` §4, in the forward direction). ## 2. How it was answered `tools/supervisor_shape_probe.py`, committed beside this record, runs **Supervisor's own `AppOptions` validator and its own `SCHEMA_APP_CONFIG`** out of the release image — `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — against **this repository's `config.yaml`**. 2026.08.0 is the version the live install reports (`supervisor_version` in its own backup metadata, 2026-09-03, alongside Home Assistant 2026.8.3). Nothing was re-implemented: if the Supervisor changes its rules, the script changes its answer. Nothing was touched, either — it reads the file and runs a validator in a throwaway container. It does **not** talk to the live Supervisor and does not POST options, because the schema conflict is the one thing you do not want to discover on a running site. ### What it printed, 2026-09-04 ⚠️ **This transcript was RE-RUN after review, against the same image tag, and it is not the one this record first carried.** The first §B validated the reconstructed live blob — a blob missing many required keys that have nothing to do with this card — and quoted its refusal as evidence about `inverters`. It never was: that refusal names whichever unrelated key the validator reaches first, and it has printed `meter_freeze_s`, `p1_phase_export_entities` and `sign_disagree_w` on three different runs. §B is now the isolated counterfactual — the shipped `options:` block, and the same block with `inverters` and nothing else removed — and it asserts on the KEY NAME in the refusal. The mis-cited half is kept, relabelled, as §B2. See §4. ``` 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 == ``` *(`arch: armv7` deprecation is pre-existing and unrelated.)* ## 3. What that establishes, item by item | Claim | Evidence | |---|---| | A list of objects is a legal `schema:` element | §A — the whole manifest passes `SCHEMA_APP_CONFIG`, whose `SCHEMA_ELEMENT` explicitly permits `[{str: Self}]` | | Per-entry types are enforced | §D — `max_w: 9999` refused with *"value must be at most 5000.0"* | | A missing required key is refused **by name** | §D — *"Missing option 'name' in inverters"* | | A two-inverter blob validates | §C | | The operator can edit it in the UI | §E — `UiOptions` renders `{"type": "schema", "multiple": true}` | | The `inverters: []` default is what makes a pre-0.6.0 blob validate | §B — the shipped defaults **minus only that key** are refused with *"Missing option 'inverters' in root"*; unmodified they pass | | A downgrade drops the key rather than refusing | §E — a schema without `inverters` **drops the key with a warning**; it is not the §B trap in reverse | ## 4. ⚠️ The one that decided the default: `inverters: []` §B is the load-bearing measurement, and it is an **isolated counterfactual**: exactly one key differs between its two runs. `supervisor/apps/options.py`'s `_check_missing_options()` treats a nested list as **required** unless its element is an optional scalar — a list whose element is a dict is never optional. So the shipped `options:` block validates as it stands, and the same block with **only** `inverters` removed is answered with: ``` Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller) ``` The live install's stored options have no such key, because it did not exist when they were written. If `config.yaml` declared `inverters:` in `schema:` and **not** in `options:`, then on update `App.test_update_schema()` would log *"new schema is not compatible"* and the operator would be left with an add-on that will not start — and the schema they would need to fix it with is pinned to the version they cannot install. What makes it safe is that `App.options` is `deepmerge(config.yaml's options:, the operator's persisted options)`, so a shipped `[]` fills the gap before validation ever runs. §B shows the halves that carry the claim — the defaults **minus this one key refused, by this key's name**, and the unmodified defaults **accepted** — and §B2 shows the merge doing its job on a reconstructed pre-0.6.0 blob. ⚠️ **What §B2 does NOT show, and what this record used to claim it did.** §B2's `LIVE` dict is an incomplete reconstruction: it carries the keys `HANDOVER.md` and `docs/deploy-rollback.md` record and no others, so it is missing many required keys unrelated to this card. Its RAW half is therefore refused for whichever of *those* the validator reaches first — three separate runs have printed `meter_freeze_s`, `p1_phase_export_entities` and `sign_disagree_w` — and the earlier version of this section quoted that refusal as if it named `inverters`. It never did. The string above was produced by §B, where the `inverters` key is the only thing that varies. The probe's assertion on that line is on the **key name in the refusal**, not on "a refusal happened". With the weaker assertion §B would have gone on printing PASS after someone deleted `inverters: []` from `config.yaml` — blind to the one regression it is the committed evidence for. It is now checked both ways: with the default deleted the probe prints `2 CASE(S) FAILED` and exits 1; restored, it prints `all cases passed` and exits 0. The merge strategy was checked too, because it is the other way this could have gone wrong: `_OPTIONS_MERGER` is `type_strategies=[(dict, ["merge"])]` with an `override` fallback, so **lists override rather than concatenate**. A shipped one-element default plus an operator's two-element list would otherwise have produced three entries — with inverter A declared twice, aimed at one actuator, which is exactly the hazard SAFETY-28 refuses. `[]` makes that unreachable and the override rule means even a non-empty default could not have grown a roster. ## 5. Why the list, and not the `str?` + JSON precedent The JSON string works — §C of the earlier draft of this probe validated the same roster as a `str?` — and if the list had failed it is what would have shipped. It loses on three things: 1. **SAFETY-28's writer census stops working.** `actuator_writers()` walks the options **tree** looking for `setpoint_entity` at any depth, and its own docstring anticipates this card: *"Add CFG-04's per-inverter config — a list of inverters, each with its own `setpoint_entity` — and there are two real writers"*. Inside a JSON **string** those entities are invisible to it, so the §1.5 two-writers guarantee would have had to be re-implemented against a parsed copy — a second place for the census to be wrong. 2. **No per-field validation.** With `str?` the Supervisor sees one opaque string: a typo'd rating, a missing name and a malformed entity id all arrive at the add-on identically, and every error message has to be written here. §D shows the Supervisor doing that work, in its own words, before the add-on starts. 3. **No UI.** §E renders a repeatable section; a `str?` renders a text box into which an operator hand-types JSON. `static_profile` accepts that cost because §14 gives it exactly one key and its structure is a tariff document; a roster is a list of devices an operator adds one at a time. ## 6. What is deliberately NOT in the shape - **No per-entry defaults.** `name`, `setpoint_entity` and `max_w` are **required**; the sensors are optional and their absence means *this unit has no such sensor*, never *use the other one's*. CFG-03 keeps defaults in exactly one place, and a per-entry default would be a second one — worse, it would be a per-entry default whose value is another inverter's measurement, which is the sibling-grading defect DEV-02 spent a card removing. - **No `gain`, `slew_w`, `integrator_max_w` per entry.** §6.2 and ALLOC-01 are ONE integrator on P1 above the split. `app/inverter.py`'s module docstring carries that argument; the config must not make a second one expressible. - **No `inverters` entry for the flat keys.** They ARE the single-inverter shape. Declaring both is refused (rule 9e) rather than merged, because a config that silently becomes a different config is worse than one that refuses to load. ## 7. Revisit triggers - **A Supervisor upgrade that changes `AppOptions`.** Re-run the probe with the new version tag. The whole point of committing it is that this costs a minute. - **SPIKE-04 measuring inverter B.** It supplies a real `max_w` for the second entry. It does not change the shape. - **A second `Maintenance` FSM.** `validate.py` rule 6 grades `maintenance_charge_w` against the first entry of `unit_caps_indexed()` because `Controller.maint_unit` is `inverters[0]`. When that stops being true, that line moves with it.