12 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 9b1fa758ad OPT-17: the solar forecast can come from several entities, summed
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s
New list options pv_forecast_entities and pv_forecast_tomorrow_entities
(same shape as pv_entities); the single-entity keys are unchanged.

- Each member is read in its own unit (Wh/kWh/MWh) in one gather and the
  day is summed. Hourly splits are merged when every member has one, else
  the total goes on the bell.
- A member missing, unavailable, stale, non-energy or bad makes that
  day's forecast unusable (no partial sum). A caution row names it by
  position; the action log names the entity once per change.
- Single key and list both set for one day: refused at start.
- A one-member list behaves exactly like the single key (test_opt17.py);
  the single-key path is byte-identical to e36565f over 24 scenarios.
- Supervisor shape measured: tools/supervisor_shape_probe.py section J.

DECISIONS/OPT-17-several-forecast-entities.md has the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-11 02:55:58 +02:00
goodwe-agent 02c0a3dd7b MAINT-10: maintenance start hour in local time, default 19:00
The start hour was compared with the UTC hour, so the shipped 10 started at
12:00 in a Belgian summer. It is now read in Home Assistant's time zone
(fallback: the container's TZ, then UTC, one log line says which), and the
default moves to 19. DST: a missing hour starts at the first hour after the
jump; a repeated hour starts only in its first pass. Stored options are not
rewritten: a saved 10 now means 10:00 local (DOCS, CHANGELOG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:52:05 +02:00
glenn schrooyenandClaude Opus 5.5 7559d21eda TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive
for consumption and negative for production. `pv_invert_entities` lists the
ones to flip; the flip is applied per member inside pv.total(), before the sum,
so the MQTT `pv` row and the status page carry the corrected figure. Default []
is byte-identical to 0.12.0.

Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0):
a parallel list validates the 0.12.0 blob unchanged and renders as its own
translated repeatable field; dicts on pv_entities refuse 0.12.0's stored
strings; a prefix is invisible syntax; a global bool cannot describe a mixed
roster. Status page marks flipped entities and cautions per entity below
-50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged.

Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:25:24 +02:00
adminandClaude Opus 5 0ef9565071 TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.

⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.

⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.

THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.

STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.

⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.

Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.

31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:13:25 +02:00
glenn schrooyenandClaude Opus 5 477911afa1 UX-04: the 59 was computed and then graded against "> 15"
checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the
fourth is the same defect this card is about, one level up.

⚠️ THE FLOOR WAS PRINTED AS 59 AND ASSERTED AS `> 15`. So a manifest change
dropping the required count to 20 would have passed while the sentence beside it
still said 59 - a number displayed but not graded, which is exactly what let
UX-01's AC 3 survive review in the first place, and exactly the shape
`test_translations.py` was already fixed for.

Now DERIVED and graded in two separate assertions, because they are two
different claims:

  1. `_EXPECT_REQ = len(schema) - len(_OPTIONAL)`, where optional is Supervisor's
     own rule - a `?` suffix, plus `inverters` because `UiOptions` marks a list
     optional - asserted EQUAL to Supervisor's required count. The printed
     number is computed, so it cannot go stale.
  2. ⚠️ AND THE WITHDRAWAL'S PREMISE, SEPARATELY. The equality alone is not
     enough and I checked why: both sides read the same `?` convention, so
     marking a key optional moves BOTH and it stays green - measured, by making
     `slew_w` optional and watching it pass. That grades Supervisor agreeing
     with us, NOT the thing AC 3 was withdrawn for. The second assertion grades
     the premise: the floor is still far above the ~15 AC 3 asked for, and if it
     ever is not, the withdrawal needs re-arguing rather than re-asserting.

Driven, both directions, against the real 2026.08.0 image:

    clean                         PASS  "floor is DERIVED: 71 schema keys minus
                                         12 optional ones is 59, and
                                         Supervisor's own required flag agrees"
    premise threshold 15 -> 40    FAIL  "the floor of 59 is still far above
                                         the ~40 AC 3 asked for"

Also: a dangling reference to `DECISIONS/UX-04-hiding-and-the-deepmerge.md`,
which was never the filename shipped, and `DOCS.md` still saying section F
"prints" what it now grades.

Not changed, and review agreed both are disclosed rather than defects: the
pair-keyed ledger cannot see a SECOND site for a pair it already holds (the
site count can grow while the pair set cannot), and the probe's assertions run
by hand rather than in CI because they need the Supervisor image - the same
status as before this card, not a regression.

Suite: 27 files, all pass. Probe: all cases passed on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, --network none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:56:55 +02:00
Claude Opus 5 21f22b393a UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is
LIVE, so all three describe the running install. No behaviour, no schema and no
default moves here: config.yaml, translations/ and every module under app/ are
byte-identical to 6e7b233.

⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and
the arithmetic is the reason rather than the effort. options: and schema: both
declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key
only when it is ?-optional AND absent from options:, and nothing is absent. But
the deeper fact is that no manifest could have met it: Supervisor's own
UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter -
`schema.filter(e => e.name in opts || e.required)`, transcribed out of the
bundle in the release image - shows a required field unconditionally. So 59 is
the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything
reachable.

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into the ACTUAL source of the
defaults on a live install - the CFG-03 defect the same card's AC 4 exists to
police, made load-bearing by its AC 3.

⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW.
supervisor_shape_probe.py section F has printed "the Configuration tab renders
71 fields" since the day it was written and exited 0 through UX-01's whole
review while displaying the number that contradicted the criterion it was
evidence for. Three assertions replace the bare print, every number computed
from the manifest and from Supervisor's own `required` flag rather than typed,
including an ISOLATED COUNTERFACTUAL that empties options: and nothing else.
MEASURED RED: delete one key (price_feed_url) from options: and two of them
fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1.

--- AC 4: the AST check, built --------------------------------------------

AC 4's first clause is VACUOUS over an empty set (see above) and its second -
"the table changed goes red" - already works six times over. The third, the one
with the content, was never written: the only literal scan in the repository is
test_validate.py's regex over eight maintenance_* keys, which is a grep and
predates the card.

test_defaults_pin.py walks ast.Call nodes over app/*.py. It:

  - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than
    naming them. Every existing pin names its own table by hand, so a SEVENTH
    table added tomorrow is pinned by nobody and nothing says so;
  - grades all 30 mirrored keys against config.yaml, and requires that no
    module carries a literal fallback for any of them;
  - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by
    pair and not by count - a count goes green again the moment one site is
    deleted and another added. Line numbers are deliberately out of the key: a
    ledger that reddens on an unrelated reformat is one people re-bless without
    reading. It can only shrink;
  - grades every remaining literal AGAINST config.yaml, which is the half that
    catches CFG-04's actual failure - two copies of one number, one moves.

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its reason rather
than corrected; changing it alters the control loop's target and belongs to a
card that grades the loop. A second divergence goes red.

⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic
module containing a deliberate violation of each rule and REQUIRES it to be
caught, in-process, through the same function the graded parts call - so a
scanner that goes blind stops working there too - and the site total is graded
against a floor. And a literal was planted in the REAL tree:

    main.py:290  float(opts.get("maintenance_charge_w", 2000))
    -> FAIL no literal fallback survives for any of the 30 keys a mirror
       already answers for                                          exit 1

    main.py:290  opts.get("p1_host", "x"), opts.get("slew_w", 999)
    -> FAIL no NEW module/key pair has started carrying a literal fallback
            [('main.py', 'p1_host')]
       FAIL every remaining literal still AGREES with config.yaml
            main.py  slew_w: code 999 vs config.yaml 0               exit 1

Both plants removed.

--- AC 5: CFG-04 is closed, and not by this card's predecessor -------------

fef6ef2 touched config.yaml, translations/, test_translations.py and the probe -
no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation
survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same
triple mutation on a throwaway copy of 6e7b233: 26 files, RED at
test_selfconfig.py, exit 1 -

  1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches
  the rail collapsing to the 2000 default under an explicit 400 W slew

git log -L 310,325:test_selfconfig.py attributes that check to bcee235,
"UX-02: pin both halves of the max_w fix, because neither was graded". The AC is
met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done.

--- the 8 per-entry translations: they stay --------------------------------

Measured negative and not a defect in them: UiOptions gives inverters type
schema WITH multiple, the frontend sends that case to ha-selector-object, which
labels fields `fields[name].label || name`, and the converter sets neither label
nor translation_key. The Supervisor tab CANNOT be made to render them. They stay
because UX-02's ingress panel is the surface that can, because they cost
nothing, and because deleting them removes the only thing that would catch a
per-entry key added without a translation - which is how SAFETY-32's REQUIRED
bms_soc_floor would have joined the roster unlabelled. A written decision, not a
change; translations/ is untouched.

tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7.
That is TOOL-04, already filed, and it is not re-filed or fixed here.

--- evidence ---------------------------------------------------------------

  positive control  test_defaults_pin.py's mirror floor moved 6 -> 7 inside the
                    harness container: "### RED: test_defaults_pin.py", exit 1
  suite             27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the
                    checkout mounted read-only)
  shape probe       all cases passed, exit 0, on
                    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0

Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.

DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four
decisions, the frontend transcription, and the two counterfactual transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:29:48 +02:00
Claude Opus 5 fef6ef20a4 UX-01: translate the add-on config, and order it by task
checks / unit (pull_request) Successful in 5m1s
The add-on had NO translations/ directory, so Home Assistant fell back to
rendering the raw option KEYS. An operator opening Configuration read
`unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w` - seventy of
them, in file order, with no description and no way to tell a constant derived
from 1890 closed-loop test runs from their own inverter's rating.

⚠️ PRESENTATION ONLY. Not one default, not one schema range and not one line of
behaviour moved. The 175 non-comment lines of config.yaml are byte-identical to
release/1.0's, reordered; the only comment lines that changed are the group
banners and five sentences whose "above"/"below" the reorder made false.

* translations/en.yaml and translations/nl.yaml: a name and a description for
  all 70 options and all 7 per-entry keys of the `inverters:` repeatable
  section. The Dutch is written for a Flemish homeowner - afname/injectie,
  kwartierpiek, capaciteitstarief - not transliterated from the English.
* ⭐ Every derived constant says WHERE IT CAME FROM and whether to touch it.
  `unit_sign_disagree_s` cites its 1890 closed-loop runs (SAFETY-30);
  `circulation_w` its 924 runs and the measured 400 W knee (SAFETY-31);
  `meter_freeze_s` its 2399 s capture (SAFETY-18); `bms_soc_floor` register
  0x0560. The two numbers that are the operator's - max_w and peak_cap_w - say
  "THIS IS YOUR NUMBER". `cycle_cost_threshold_eur`, which is neither, says it
  is an open product question (OPT-08).
* config.yaml reordered into task order in BOTH halves, comments carried with
  their keys: hardware -> meter -> ratings and limits -> tariff -> maintenance
  -> safety -> advanced.
  ⚠️ test_inverter.py:1788's trap is PRESERVED: the nested per-entry `max_w:`
  still precedes the flat one under `schema:`, and `inverters: []` still ships
  in the `options:` half. Moving the nested block below the flat one is a 1-byte
  edit; it was run and goes red in test_inverter.py and test_translations.py.
* tools/supervisor_shape_probe.py section F MEASURES the result against
  ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 - Supervisor's own
  _read_app_translations(), its own UiOptions, and the frontend's label
  resolution transcribed from the shipped bundle - and prints every rendered row
  in both languages. `all cases passed`, exit 0.
* ⚠️ ONE MEASURED NEGATIVE RESULT, NOT GLOSSED: the per-entry keys inside the
  `inverters:` section still render RAW on frontend 20250925.1, because a
  list-of-objects goes to ha-selector-object and the converter sets no `label`
  and no `translation_key`. The `fields:` translations are shipped and validated
  - that is Supervisor's own shape for this - but this card does not claim they
  reach the screen. The `inverters` label and description do.
* test_translations.py: a translation naming a non-existent option, or an option
  with no translation, fails CI - both directions, both languages, top level and
  per entry. Mutation-tested BOTH WAYS: eleven in-process mutations each
  required to NAME the key they broke, plus a negative control; six more run on
  disk through the purge-and-re-read protocol. Two of the eleven are the ones
  that cost the WHOLE FILE rather than one label - an unrecognised top-level
  block, and a `network:` value that is not a string. Supervisor drops a file
  that fails SCHEMA_APP_TRANSLATIONS with only a log warning, so those silently
  return the entire tab to raw keys; neither is reachable through the
  `configuration:` coverage, which is why they were hunted for and closed.
* tools/translations_lint.py is the hand parser CI needs (no PyYAML in the
  add-on image) and the probe grades it, key by key and string by string,
  against Supervisor's own loader. It is strict and RAISES rather than skipping;
  nine malformed shapes prove it.

Full 20-file suite green (19 existing + test_translations.py).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 12:02:56 +02:00
adminandClaude Opus 5 19decf433f SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.

⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:

  roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
    clean                        -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
                                    SAFETY-02 sign-disagreement watchdog can never fire"
    min(caps[:1])          +4 B  -> 0 problems. The site STARTS.
    caps_ix[:1]            +4 B  -> 0 problems.
  the SAME roster with the small cap FIRST (2000, 5000)
    clean                        -> 1 problem, naming inverter-a
    min(caps[-1:])         +5 B  -> 0 problems.

⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.

THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)

Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.

  183 roster reductions across 9 modules, out of 23 files parsed:
  main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
  peakguard 4, inverter 3, store 3.  14 modules reduce over no roster at all.

⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.

The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.

THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE

All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:

  145 of 183   a behavioural fixture separates the site from a slice of itself
   38 of 183   ONLY the structural claim. Behaviourally UNGRADED.

All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.

THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning

test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.

⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's

Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:

  two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
  The site started, the status page said `armed`, and the per-inverter watchdog could
  not trip on EITHER unit. Identical sentence to the blocker, one branch over.
  two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
  never wrote, for a threshold both units can reach.

It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.

MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)

  B1  validate min(caps)     -> min(caps[:1])        +4 B  RED test_validate.py
  B2  validate min(caps)     -> min(caps[-1:])       +5 B  RED test_validate.py
  B3  validate caps_ix       -> caps_ix[:1]          +4 B  RED test_validate.py
  B4  validate rule 6 min    -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B5  validate rule 5 msg    -> min(caps_ix[:1])     +4 B  RED test_validate.py
  B6  validate rule 5b msg   -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B7  CONTROL: weakest -> max(0.0, weakest)         +10 B  GREEN, as intended
  R3a peakguard max(floors)  -> min(floors)           0 B  RED test_peakguard.py
  R3b peakguard max(floors)  -> max(floors[-2:])     +5 B  RED test_peakguard.py
  R3c main call site packs   -> self.packs[:2]       +4 B  RED test_peakguard.py
  R4a main pack_floor_soc    -> vals[:2]             +4 B  RED test_peakguard.py
  R4b main pack_floor_soc    -> vals[-2:]            +5 B  RED test_peakguard.py
  R4c main pack_floor_soc    -> vals[:3]             +4 B  RED test_peakguard.py
  R4d main cycle() any(...)  -> self.inverters[:1]   +4 B  RED test_watchdog.py

B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.

R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
  packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
  binding_soc_floor(...) = 30.0   the 0-byte mutant would answer 15.0   (+15.0 pp)
  -> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.

⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.

⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.

REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had
rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry
BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the
date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is
not a measurement.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0
before and after. CI-06: test_peakguard.py run four times in isolation, green each time
(288 ok lines); test_validate.py three times, green each time.

MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been
found twice AND make it discover its own scope, and say what the instrument cannot see
with sizes in the same breath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 05:36:16 +02:00
adminandClaude Opus 5 f9e7fbf86d SAFETY-32: bms_soc_floor per pack, rule 8 per unit
`bms_soc_floor` describes A BATTERY, not a site. At two packs of different
models the cut-outs differ, and one number for both graded the shallower pack
against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under
`peak_soc_floor: 20` STARTED, and the guard would command a forced discharge
that pack B's BMS, not the operator's floor, is the thing that stops. That is
rule 8's own failure mode, reached through rule 8.

Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate
anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as
already shipped and are untouched.

* config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry.
  Nothing is inherited; the flat key stays the single-inverter shape's own
  cut-out. A per-entry DEFAULT was declined - a default is one number for every
  pack, which is this card's defect - and the decision is recorded.
* inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None,
  not a borrowed number and not 0.0 (the most permissive value the key has).
* peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`,
  `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
  one place the question is answered.
* validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8
  grades EVERY unit and NAMES the one it refuses. Graded with the offender
  SECOND and with the same two numbers in the other order.
* main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack
  reduction of the same option; the floor-limited notification names the pack
  that stopped the site. `pack_floor_soc` - the emptiest pack, and its
  None-on-missing behaviour - is untouched.
* tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke
  §C and left §D printing PASS on a refusal about a different key. §D is now an
  isolated counterfactual over an accepted merged baseline, one key varied,
  asserting the key name. `all cases passed` on 2026.08.0.

Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR
reduces to `peak_soc_floor` for every pack. The reduction is written for
`unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid
pairs answer the option, the pair rule 8 refuses answers the cut-out.
`peak_soc_floor` is still one number for the site, so the "deeper pack
needlessly withheld" half is filed as an open question, not closed.

Full 19-file suite green. Twelve source mutations through
tools/mutation_run.sh: eleven red, one argued survivor. The battery also found
`_flat_bms_floor()` defaulting in two places, each covering for the other.

No measurement of inverter B appears anywhere: it does not exist yet. Every
second-pack figure is simulated and labelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
glenn schrooyenandClaude Opus 5 a9993aeda2 SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the copies are pinned
checks / unit (pull_request) Successful in 4m51s
The desk half of SPIKE-04. No control behaviour changes; this is documentation
and the check that stops it drifting again.

Three places stated inverter B's rating - config.yaml's commented roster
example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator
would POST" - and all three said 3000 W, a number nobody had measured or read
off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe
GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power
4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does
not gate this site. All three copies now read 4600.

⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity
is recorded rather than guessed. The datasheet prints it under BATTERY INPUT
DATA, so it is probably DC, while max_w is graded against 0x0518 - the
inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither
reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds
it correctly under either, once dev_max is read off the unit, so `max_w: 4600`
is what an operator should configure today. goodwe/NOTES.md §1.1b carries the
full record with the date and the method on every figure.

A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.
test_validate.py now pins all three documented rosters: findable, in agreement,
and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which
stopped matching cannot leave "they all agree" green on the two sites still
read. It also grades the PROVENANCE beside the number (the datasheet, the
0x0518 it is graded against, and the 4324 W the DC reading would make it)
WITHIN 2500 characters of the roster rather than file-wide.

⚠️ The first version of that provenance check searched whole files and was
ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in
DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green
no matter what happened next to B's rating. That is a check whose data cannot
reach the failure it names, in the file that exists to complain about them. It
is fixed, and the window is what fixes it - deleting the caveat beside the
number now goes red even where the same token survives elsewhere in the file.
The windowed version immediately found that the DOCS.md caveat did not name
0x0518 at all; DOCS.md now says it.

Four mutations, bytecode purged and counted at 0 before and after each, all
RED (two of them 0-byte deltas that change a printed answer):
  M1  probe's operator blob 4600 -> 3000        0 B   RED (value + agreement)
  M2  DOCS.md's DC/AC caveat deleted         -157 B   RED (provenance)
  M3  config.yaml alone 4600 -> 4324            0 B   RED (agreement)
  M4  probe entity renamed, regex finds nothing       RED (the vacuity guard)
Restored, green again. The probe itself was re-run against Supervisor
2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0.

⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE.
test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py
build a second inverter at 3000 W against A's 5000 W. The only property those
checks consume is that B's number is unmistakably not A's, and the real pair is
a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit
bug and ordinary control arithmetic produce indistinguishable numbers.
Collapsing them onto the real values would buy realism those checks do not use
and cost them their separating power, which is exactly what test_peakguard.py's
own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a
hypothesis about B's rating; they call it illustrative, and not a placeholder
either - the placeholder era ended on 2026-09-04.

Also recorded in the changelog for the cards that own them: 94 % battery-to-AC
=> ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is
the number SAFETY-31 argues its threshold from without having one; < 13 W night
self-consumption per unit, which nothing accounts for; and the A/B asymmetry in
opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's
12 h drain timeout was derived on A and should be re-checked against B.

⚠️ Honest survivors:
- goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by
  nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every
  FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN.
  This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for
  this repository only, as .gitea/workflows/checks.yml already states - so the
  cross-repo check is not buildable here.
- No check can see whether 4600 is the RIGHT number. Everything above pins a
  datasheet reading against itself. Only the bench half of SPIKE-04 can say
  whether the unit agrees, and the DC-vs-AC ambiguity is the live example.
- The constant is still named B_RATING_HYPOTHESIS in four test files, which
  reads as a hypothesis about B's rating when it is now an illustrative
  fixture. It is left alone deliberately: renaming ~20 sites across
  test_peakguard.py and test_inverter.py would collide hard with the open
  SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment
  directly above each definition says what the value is and is not.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and
verified 0 before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 22:19:31 +02:00
glenn schrooyenandClaude Opus 5 22c016fadd CFG-04 fix round: the probe now measures the key it is cited for
checks / unit (pull_request) Successful in 4m33s
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
2026-09-04 10:33:51 +02:00
adminandClaude Opus 5 6b85acb0f1 CFG-04: an operator can declare a second inverter, and the shape was measured
checks / unit (pull_request) Successful in 4m30s
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
2026-09-04 04:29:45 +02:00