MAINT-05: one recalibration per pack, and only one pack moves power at a time #47

Merged
admin merged 3 commits from MAINT-05 into release/1.0 2026-09-04 21:39:26 +02:00
Owner

What changed

There was exactly one Maintenance state machine in this add-on and one
un-namespaced history, so at two packs only the first was ever recalibrated
and the second's coulomb counter drifted forever — and nothing prevented the
cycle draining pack A while the loop or arbitrage charged pack B, which is two
power-moving phases on one grid connection pulling against each other.

The PM proofread on the card is right and I did not widen or narrow it: the
comment at main.py:670-680 was written by the card that deferred this
work, and what it deferred — "a second FSM and a namespaced history of its
own"
— is what this card builds.

One Maintenance per roster entry. Each has its own schedule, phase and
history under unit/<name>/…. maintenance.UnitHistory is the one place an
unqualified key name ("phase") becomes the key that pack owns, so a raw write
cannot address a sibling's history.

store.migrate_maint_keys(), and it is not a second copy of the first
migration.
migrate_unit_keys()'s rule 1 (never overwrite a namespaced value)
and rule 2 (the flat keys stay in the file) are now _move_flat_keys(), and its
read-back postcondition is _verify_moves(); both migrations call the same two
functions, so there is one implementation of "it landed". It carries its own
marker
, because unlike DEV-02's it is not monotone across installs — it runs
only where the roster has more than one pack, and a single counter could not say
that without lying to the next version that reads it. Argument in the decision
record §2.

Rule 2 is kept live: UnitHistory mirrors the first pack's writes back onto
the flat keys at N≥2, so a rollback to a single-FSM add-on finds a current
history rather than one dated the day the second inverter was declared. That is
DEV-02's own amendment to rule 2, applied to history instead of to a latch. Only
the first pack — the flat spelling means inverters[0] on every install that
has one.

One maintenance slot per site (maintenance.arbitrate()). At most one pack
in a power-moving phase. POWER_MOVING is (drain, charge, hold): charge is
in it although the loop owns the setpoint there, because a pack pulled full
from the grid beside a pack exporting to empty is 4 kW circulating with the
meter reading zero and both packs believing they are behaving. The holder is
derived from the packs' own stored phases and never persisted — a second key
would disagree with the phase the first time the add-on was killed between the
two writes, and derived means a restart mid-drain re-derives it.

The claim is filed on the pack whose state machine produced it, and stays
SCOPE_UNIT. ARCH-01's rule: two FSMs mean two claims on two actuators, which
the allocator consumes as per-unit intervals, never a site claim.

validate.py rule 6 (the card calls it rule 8 — see scope notes below)
now grades maintenance_charge_w against the weakest cap on the roster.

The judgement calls the card left open

Recorded in goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md and
summarised here.

Arbitration — who goes first. At most one pack recalibrates at a time and
the other stays under the loop, which is the card's own honest default. When
several are due in the same hour the slot goes to the pack that has gone
longest without a completed cycle
(last_completed oldest; inf for
never-recalibrated, because "never" is the absence of a coulomb count, not a
small number). Roster order breaks a genuine numeric tie and nothing else — a
roster-order rule would recalibrate inverters[0] every contested month forever
while the second pack drifted, which is this card's own defect in a different
costume. Nothing preempts a cycle in flight: a recalibration stopped half way has
spent the energy and bought no calibration.

Staggering — the interlock defers; the schedule is NOT staggered by
construction.
Three reasons: a static offset cannot remove the need for the
interlock (a forced start, a commissioning cycle or an abort-and-retry
desynchronises it immediately), a deferral is observable where an offset is
silent, and the stagger falls out of the deferral anyway — pack A completes on
day 1 and is next due on day 29, pack B completes on day 2 and is next due on
day 30, and they stay separated.

Rule 6 — no, the first entry's cap is not right any more, and this refuses
configs that used to start.
The comment there said it graded inverters[0]'s
cap because there was one Maintenance and min() would be an over-refusal —
"a 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
touches"
— and closed with "when maint_unit stops being inverters[0], this
line moves with it."
It has stopped; the sibling the cycle never touches does
not exist. max_w 5000 beside 2000 with maintenance_charge_w 3000 was correct
before and is now a cheap-window charge asking for hi = -3000 against a
supervised lo = -2000 — lo > hi, arbiter clause 3, 0 W, on the pack nobody
was watching. Refused when the config is written, which is rule 6's job.
Identical at a roster of one.

Starvation and the false alarm. A deferral does not stamp
last_start_attempt — that key is the 90 min post-abort cooldown and the start
hour is 60 minutes long, so stamping it would lock the deferred pack out of the
very hour it is waiting for. It does not move the phase either, so
phase_started stays empty and neither the 12 h drain nor the 36 h charge
timeout (§12: "the battery is not following commands — a fault, not a slow
day"
) is reachable by waiting. The line it logs says WAITING, names the pack it
waits for, says it will run at the next start hour, and says "this is not a
stalled phase"
.

What I verified, and how

Everything below ran in the prebuilt goodwe-addon-ci image against the tree
rsynced to a plain path. No hardware was touched: no rig, no ESP32, no
inverter, no Pi, no /dev/ttyUSB0, no live Home Assistant, nothing flashed.
Inverter B does not exist; every two-pack fixture is simulated and its
3000 W / 2000 W ratings are marked hypotheses in the fixtures that carry them.

Full suite: 19/19 green, twice (the second run is the test_peakguard.py
CI-06 flake check; it passed both times and was not implicated in anything
here).

test_alloc test_arbiter test_arbitrage test_bytecode test_ci_gate test_clamp
test_control test_freeze test_inverter test_loop test_maintenance test_p1
test_peakguard test_price test_price_forecast test_static_tariff test_tariff
test_validate test_watchdog        -> all checks passed / OK

Mutation testing

Every mutation was applied to a pristine rsync of this branch and graded
through tools/mutation_run.sh, which purges __pycache__ and *.pyc
before running and fails if a cache appears during the run — python3 -B alone
is not sufficient (TEST-15). Tree graded:

c75a9bc9e456fce8  app/maintenance.py
c46348b6d7056280  app/store.py
32c90b62978f8c84  app/main.py
e8835eeb25af93d3  app/validate.py
# mutation observed the check that bites
M1 POWER_MOVING = (DRAIN, HOLD) — drop charge RED (2 checks) "a pack in charge holds the site's maintenance slot"
M2 tick()'s start gate if blocked_by: → if False: RED (16) "exactly one pack proceeds"
M3 arbitrate() clause 2 winner → roster order RED (3) "it is the longest-waiting pack, not the first in the roster"
M4 a deferral stamps last_start_attempt RED (3) "a deferral does not stamp last_start_attempt"
M5 UnitHistory.key() ignores the namespace RED (35) "the two packs hold different phases at the same moment"
M6 mirror_flat on every pack, not just the first RED (3) "only the first pack mirrors back to the flat keys"
M7 migration overwrites an existing namespaced value RED "RULE 1: an existing namespaced value is never overwritten"
M8 migration marks the store done even when state was lost RED "it does NOT mark the store migrated"
M9 migration runs at a roster of one RED (3) "migrate_maint_keys() refuses to run at all on a one-pack roster"
M10 claim filed on self.inverters[0] (the shipped expression) RED (4) "the drain lands on pack B, the pack whose state machine asked for it"
M11 every machine ticked with self.inverters[0].soc RED "pack B's machine is graded on pack B's own SoC"
M12 cycle() drops blocked_by= — interlock computed, not wired RED (2) "one real cycle leaves exactly one pack draining"
M13 tick()'s two-packs-busy recovery branch removed RED (3) "a store carrying two packs mid-cycle is recovered from rather than obeyed"
M14 rule 6 back to caps_ix[0] RED in test_validate.py and test_inverter.py "a charge rate the smallest pack cannot take is refused"
M15 force_start()'s interlock guard removed RED "a forced start is refused while another pack holds the slot"
M16 next_due → the first pack's instead of the earliest RED "the 'Maintenance due' row is the earliest date across the packs"
M17 banner names the pack at N=1 too RED "at a roster of one the banner … is the bare phase"
M18 maint_index always 0 RED "at two packs the banner and the phase row name the pack"

One equivalent mutant, reported rather than hidden. Replacing inv.soc with
self.maint_unit.soc survived, and it should: maint_unit now tracks the
pack holding the slot, arbitrate() allows at most one pack out of idle, and
soc is only read by a non-idle phase — so the two expressions denote the same
reading. M11 above is the non-equivalent form (inverters[0].soc, which is what
the shipped code meant) and it is red. The same is true of maint_unit in place
of inv in the claim; M10 uses inverters[0] for the same reason.

Fixtures: designed against the recurring defect

Every two-pack fixture gives the packs different phases and different
last_completed times
, because the bug being hunted is shared state and two
equal values cannot show it — MUTATION.md habit 4, and the amendment SAFETY-30
and CFG-04 each took.

  • part 6: pack A in charge at 40 days, pack B idle at 33 — so "A completed"
    and "B completed" are distinguishable statements, and A completing is
    observed not to stamp B.
  • part 7: the more overdue pack is the second in the roster, so a check that
    passed with roster order winning would be a check that cannot see the
    arbitration at all. The mirror image (waits swapped) is checked too, so the
    ordering is a comparison and not a constant, and a genuine tie is checked
    separately.
  • part 11: pack A at 90 % SoC and pack B at 10.5 % against an 11 % floor — the
    same tick, opposite answers, which two equal SoCs could not produce.
  • rule 6's fixture: caps of 5000 W and 2000 W with the rate at 3000 W between
    them, the one arrangement where the old answer and the new one disagree.

N=1 is asserted, not assumed

  • a whole cycle writes no store key that did not exist before this card — no
    unit/… maintenance key, no maint_schema_version, and the four flat keys
    carry the history;
  • migrate_maint_keys() returns without writing on a one-pack roster;
  • the four cycle events and the abort event are pinned as literal strings;
  • the status banner is Maintenance: drain with no pack name, and the phase row
    is the bare phase;
  • self.maint is maints[0], maint_unit is inverters[0] and the operator
    button targets maints[0];
  • arbitrate() over one machine returns [""], so no reason string it can build
    is reachable.

Scope notes

  • The card says "rule 8"; the code it points at by line number (validate.py:805-814) and by content is rule 6. Rule 8 is the EMS-floor-vs-BMS-floor rule and has nothing to do with maint_unit. I revisited rule 6, which is the one whose comment names maint_unit and promises to move with it. Flagging rather than silently reinterpreting.
  • Found and NOT fixed — a maintenance drain can still be refused by the allocator at N≥2. The maintenance SET pins its pack; the loop's target is a site claim. When the site target is below the pin — a 2000 W drain while the house imports less, which is most of a night — the only split that sums to the site target asks the sibling to charge while the maintained pack discharges, and allocator.plan() refuses it by name as circulating power. The site parks at 0 W, and the pack's own 12 h timeout would eventually report "drain stuck" about a healthy battery. This is pre-existing on release/1.0 at 2d025ee — measured, with the same drain filed on inverters[0] producing the identical refusal with the unit names swapped. Fixing it means changing what the loop's target means while a unit is pinned (regulate the meter using only the unpinned packs), which is loop and allocator territory, not maintenance's. It is pinned as a running check (test_maintenance.py part 12) rather than left as a comment, so it cannot be forgotten or tuned away, and it wants a card of its own.
  • The operator's one maintenance button targets the pack holding the slot when one holds it (which is what start and abort already meant at N=1, where pressing start mid-drain re-entered drain), and otherwise the longest-waiting pack — the same pack arbitrate() would have picked. A per-unit button is a UI card: web.py posts a bare maint_start with no unit, and inventing a unit parameter here would ship half a surface.
  • force_start()'s interlock guard is not reachable from main.py's button (it retargets rather than refusing), and it is there anyway because price_forecast.begin_phase() calls force_start() on whatever machine it is handed — that is the caller that will need it when the price-gated entry point is wired in. It is graded directly (M15).
  • No measured numbers for inverter B. It does not physically exist; every two-pack fixture is simulated and labelled, and the 3000 W / 2000 W figures are marked hypotheses at each site that carries them.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## What changed There was exactly one `Maintenance` state machine in this add-on and one un-namespaced history, so at two packs **only the first was ever recalibrated** and the second's coulomb counter drifted forever — and nothing prevented the cycle draining pack A while the loop or arbitrage charged pack B, which is two power-moving phases on one grid connection pulling against each other. The PM proofread on the card is right and I did not widen or narrow it: the comment at `main.py:670-680` was written by the card that **deferred** this work, and what it deferred — *"a second FSM and a namespaced history of its own"* — is what this card builds. **One `Maintenance` per roster entry.** Each has its own schedule, phase and history under `unit/<name>/…`. `maintenance.UnitHistory` is the one place an unqualified key name (`"phase"`) becomes the key that pack owns, so a raw write cannot address a sibling's history. **`store.migrate_maint_keys()`, and it is not a second copy of the first migration.** `migrate_unit_keys()`'s rule 1 (never overwrite a namespaced value) and rule 2 (the flat keys stay in the file) are now `_move_flat_keys()`, and its read-back postcondition is `_verify_moves()`; both migrations call the same two functions, so there is one implementation of "it landed". It carries **its own marker**, because unlike DEV-02's it is not monotone across installs — it runs only where the roster has more than one pack, and a single counter could not say that without lying to the next version that reads it. Argument in the decision record §2. Rule 2 is kept **live**: `UnitHistory` mirrors the first pack's writes back onto the flat keys at N≥2, so a rollback to a single-FSM add-on finds a *current* history rather than one dated the day the second inverter was declared. That is DEV-02's own amendment to rule 2, applied to history instead of to a latch. Only the first pack — the flat spelling means `inverters[0]` on every install that has one. **One maintenance slot per site (`maintenance.arbitrate()`).** At most one pack in a power-moving phase. `POWER_MOVING` is `(drain, charge, hold)`: `charge` is in it although the *loop* owns the setpoint there, because a pack pulled full from the grid beside a pack exporting to empty is 4 kW circulating with the meter reading zero and both packs believing they are behaving. The holder is **derived** from the packs' own stored phases and never persisted — a second key would disagree with the phase the first time the add-on was killed between the two writes, and derived means a restart mid-drain re-derives it. **The claim is filed on the pack whose state machine produced it**, and stays `SCOPE_UNIT`. ARCH-01's rule: two FSMs mean two claims on two actuators, which the allocator consumes as per-unit intervals, never a site claim. **`validate.py` rule 6** (the card calls it rule 8 — see *scope notes* below) now grades `maintenance_charge_w` against the **weakest** cap on the roster. ## The judgement calls the card left open Recorded in `goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md` and summarised here. **Arbitration — who goes first.** At most one pack recalibrates at a time and the other stays under the loop, which is the card's own honest default. When several are due in the same hour the slot goes to **the pack that has gone longest without a completed cycle** (`last_completed` oldest; `inf` for never-recalibrated, because "never" is the absence of a coulomb count, not a small number). Roster order breaks a genuine numeric tie and nothing else — a roster-order rule would recalibrate `inverters[0]` every contested month forever while the second pack drifted, which is this card's own defect in a different costume. Nothing preempts a cycle in flight: a recalibration stopped half way has spent the energy and bought no calibration. **Staggering — the interlock defers; the schedule is NOT staggered by construction.** Three reasons: a static offset cannot remove the need for the interlock (a forced start, a commissioning cycle or an abort-and-retry desynchronises it immediately), a deferral is observable where an offset is silent, and the stagger falls out of the deferral anyway — pack A completes on day 1 and is next due on day 29, pack B completes on day 2 and is next due on day 30, and they stay separated. **Rule 6 — no, the first entry's cap is not right any more, and this refuses configs that used to start.** The comment there said it graded `inverters[0]`'s cap because there was one `Maintenance` and `min()` would be an over-refusal — *"a 5000 W pack being recalibrated beside a 2000 W sibling the cycle never touches"* — and closed with *"when `maint_unit` stops being `inverters[0]`, this line moves with it."* It has stopped; the sibling the cycle never touches does not exist. `max_w` 5000 beside 2000 with `maintenance_charge_w` 3000 was correct before and is now a cheap-window charge asking for `hi = -3000` against a supervised `lo = -2000` — `lo > hi`, arbiter clause 3, 0 W, on the pack nobody was watching. Refused when the config is **written**, which is rule 6's job. Identical at a roster of one. **Starvation and the false alarm.** A deferral does **not** stamp `last_start_attempt` — that key is the 90 min post-abort cooldown and the start hour is 60 minutes long, so stamping it would lock the deferred pack out of the very hour it is waiting for. It does not move the phase either, so `phase_started` stays empty and neither the 12 h `drain` nor the 36 h `charge` timeout (§12: *"the battery is not following commands — a fault, not a slow day"*) is reachable by waiting. The line it logs says WAITING, names the pack it waits for, says it will run at the next start hour, and says *"this is not a stalled phase"*. ## What I verified, and how Everything below ran in the prebuilt `goodwe-addon-ci` image against the tree rsynced to a plain path. **No hardware was touched**: no rig, no ESP32, no inverter, no Pi, no `/dev/ttyUSB0`, no live Home Assistant, nothing flashed. Inverter B does not exist; every two-pack fixture is **simulated** and its 3000 W / 2000 W ratings are marked hypotheses in the fixtures that carry them. **Full suite: 19/19 green**, twice (the second run is the `test_peakguard.py` CI-06 flake check; it passed both times and was not implicated in anything here). ``` test_alloc test_arbiter test_arbitrage test_bytecode test_ci_gate test_clamp test_control test_freeze test_inverter test_loop test_maintenance test_p1 test_peakguard test_price test_price_forecast test_static_tariff test_tariff test_validate test_watchdog -> all checks passed / OK ``` ### Mutation testing Every mutation was applied to a **pristine rsync of this branch** and graded through `tools/mutation_run.sh`, which **purges `__pycache__` and `*.pyc`** before running and fails if a cache appears during the run — `python3 -B` alone is not sufficient (TEST-15). Tree graded: ``` c75a9bc9e456fce8 app/maintenance.py c46348b6d7056280 app/store.py 32c90b62978f8c84 app/main.py e8835eeb25af93d3 app/validate.py ``` | # | mutation | observed | the check that bites | |---|---|---|---| | M1 | `POWER_MOVING = (DRAIN, HOLD)` — drop `charge` | **RED** (2 checks) | *"a pack in **charge** holds the site's maintenance slot"* | | M2 | `tick()`'s start gate `if blocked_by:` → `if False:` | **RED** (16) | *"exactly one pack proceeds"* | | M3 | `arbitrate()` clause 2 winner → roster order | **RED** (3) | *"it is the longest-waiting pack, not the first in the roster"* | | M4 | a deferral stamps `last_start_attempt` | **RED** (3) | *"a deferral does not stamp `last_start_attempt`"* | | M5 | `UnitHistory.key()` ignores the namespace | **RED** (35) | *"the two packs hold different phases at the same moment"* | | M6 | `mirror_flat` on every pack, not just the first | **RED** (3) | *"only the first pack mirrors back to the flat keys"* | | M7 | migration overwrites an existing namespaced value | **RED** | *"RULE 1: an existing namespaced value is never overwritten"* | | M8 | migration marks the store done even when state was lost | **RED** | *"it does NOT mark the store migrated"* | | M9 | migration runs at a roster of one | **RED** (3) | *"`migrate_maint_keys()` refuses to run at all on a one-pack roster"* | | M10 | claim filed on `self.inverters[0]` (the shipped expression) | **RED** (4) | *"the drain lands on pack B, the pack whose state machine asked for it"* | | M11 | every machine ticked with `self.inverters[0].soc` | **RED** | *"pack B's machine is graded on pack B's own SoC"* | | M12 | `cycle()` drops `blocked_by=` — interlock computed, not wired | **RED** (2) | *"one real cycle leaves exactly one pack draining"* | | M13 | `tick()`'s two-packs-busy recovery branch removed | **RED** (3) | *"a store carrying two packs mid-cycle is recovered from rather than obeyed"* | | M14 | rule 6 back to `caps_ix[0]` | **RED** in `test_validate.py` **and** `test_inverter.py` | *"a charge rate the smallest pack cannot take is refused"* | | M15 | `force_start()`'s interlock guard removed | **RED** | *"a forced start is refused while another pack holds the slot"* | | M16 | `next_due` → the first pack's instead of the earliest | **RED** | *"the 'Maintenance due' row is the earliest date across the packs"* | | M17 | banner names the pack at N=1 too | **RED** | *"at a roster of one the banner … is the bare phase"* | | M18 | `maint_index` always 0 | **RED** | *"at two packs the banner and the phase row name the pack"* | **One equivalent mutant, reported rather than hidden.** Replacing `inv.soc` with `self.maint_unit.soc` **survived**, and it should: `maint_unit` now tracks the pack holding the slot, `arbitrate()` allows at most one pack out of `idle`, and `soc` is only read by a non-idle phase — so the two expressions denote the same reading. M11 above is the non-equivalent form (`inverters[0].soc`, which is what the shipped code meant) and it is red. The same is true of `maint_unit` in place of `inv` in the claim; M10 uses `inverters[0]` for the same reason. ### Fixtures: designed against the recurring defect Every two-pack fixture gives the packs **different phases and different `last_completed` times**, because the bug being hunted is shared state and two equal values cannot show it — `MUTATION.md` habit 4, and the amendment SAFETY-30 and CFG-04 each took. * part 6: pack A in `charge` at 40 days, pack B `idle` at 33 — so *"A completed"* and *"B completed"* are distinguishable statements, and A completing is observed **not** to stamp B. * part 7: the **more overdue pack is the second in the roster**, so a check that passed with roster order winning would be a check that cannot see the arbitration at all. The mirror image (waits swapped) is checked too, so the ordering is a comparison and not a constant, and a genuine tie is checked separately. * part 11: pack A at 90 % SoC and pack B at 10.5 % against an 11 % floor — the same tick, opposite answers, which two equal SoCs could not produce. * rule 6's fixture: caps of 5000 W and 2000 W with the rate at 3000 W **between** them, the one arrangement where the old answer and the new one disagree. ### N=1 is asserted, not assumed * a whole cycle writes **no store key that did not exist before this card** — no `unit/…` maintenance key, no `maint_schema_version`, and the four flat keys carry the history; * `migrate_maint_keys()` returns without writing on a one-pack roster; * the four cycle events and the abort event are pinned as **literal strings**; * the status banner is `Maintenance: drain` with no pack name, and the phase row is the bare phase; * `self.maint` is `maints[0]`, `maint_unit` is `inverters[0]` and the operator button targets `maints[0]`; * `arbitrate()` over one machine returns `[""]`, so no reason string it can build is reachable. ## Scope notes * **The card says "rule 8"; the code it points at by line number (`validate.py:805-814`) and by content is rule 6.** Rule 8 is the EMS-floor-vs-BMS-floor rule and has nothing to do with `maint_unit`. I revisited **rule 6**, which is the one whose comment names `maint_unit` and promises to move with it. Flagging rather than silently reinterpreting. * **Found and NOT fixed — a maintenance drain can still be refused by the allocator at N≥2.** The maintenance SET **pins** its pack; the loop's target is a **site** claim. When the site target is below the pin — a 2000 W drain while the house imports less, which is most of a night — the only split that sums to the site target asks the sibling to **charge** while the maintained pack **discharges**, and `allocator.plan()` refuses it by name as circulating power. The site parks at 0 W, and the pack's own 12 h timeout would eventually report *"drain stuck"* about a **healthy** battery. This is **pre-existing on `release/1.0` at `2d025ee`** — measured, with the same drain filed on `inverters[0]` producing the identical refusal with the unit names swapped. Fixing it means changing what the loop's target *means* while a unit is pinned (regulate the meter using only the unpinned packs), which is loop and allocator territory, not maintenance's. It is pinned as a **running check** (`test_maintenance.py` part 12) rather than left as a comment, so it cannot be forgotten or tuned away, and it wants a card of its own. * **The operator's one maintenance button** targets the pack holding the slot when one holds it (which is what *start* and *abort* already meant at N=1, where pressing *start* mid-drain re-entered `drain`), and otherwise the longest-waiting pack — the same pack `arbitrate()` would have picked. A per-unit button is a UI card: `web.py` posts a bare `maint_start` with no unit, and inventing a unit parameter here would ship half a surface. * **`force_start()`'s interlock guard is not reachable from `main.py`'s button** (it retargets rather than refusing), and it is there anyway because `price_forecast.begin_phase()` calls `force_start()` on whatever machine it is handed — that is the caller that will need it when the price-gated entry point is wired in. It is graded directly (M15). * **No measured numbers for inverter B.** It does not physically exist; every two-pack fixture is simulated and labelled, and the 3000 W / 2000 W figures are marked hypotheses at each site that carries them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-04 13:39:50 +02:00
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.

* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
  history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
  install's flat history into the first pack's namespace under
  `migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
  and `_verify_moves()`, not restated - on its own marker, because unlike
  DEV-02's it runs only where the roster has more than one pack. The flat keys
  are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
  in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
  the packs' own stored phases, never persisted, so a restart mid-drain
  re-derives it. Several packs due in the same hour: the slot goes to the one
  that has gone longest without a completed cycle; roster order breaks a
  numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
  stamp `last_start_attempt` and does not move the phase, so neither the 12 h
  drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
  SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
  the roster now, as its own comment said it must once `maint_unit` stopped
  being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
  migration does not run, the four cycle events are the shipped strings, the
  banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
  claim, so below the pin the allocator refuses the split and the site parks at
  0 W. Pre-existing at 2d025ee. Pinned as a running check.

Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-04 16:18:44 +02:00
REJECTED because a survivor undoes AC 5, and it survived for the recurring
reason: two fixture values that must be distinguishable were EQUAL.

THE SURVIVOR. app/main.py:282 mutated from `self.inverters[0].name` to `UNIT_A`
left the full 19-file suite green. `inverter.UNIT_A == "inverter-a"`, and every
two-pack fixture in test_maintenance.py named its first roster entry
"inverter-a" - so the two expressions were the same string in every check that
existed. main.py's own comment made the distinction load-bearing and nothing in
the repository could see whether that comment was true.

THE FAILURE IS REAL. `build_roster()` names a declared roster's entries from the
config and numbers a nameless one POSITIONALLY (`inverter-1`), so "inverter-a"
is not even the fallback. On ["garage","shed"] with a flat last_completed of
2026-08-07 the mutant migrates into unit/inverter-a/, pack 0 reads back None,
and both packs come up believing they have never been recalibrated.

AND IT FAILED SILENTLY, which was the worse half: `_verify_moves()` read back
the keys it had just WRITTEN, under the namespace it had just been HANDED, was
satisfied, and MAINT_SCHEMA_KEY was stamped - so the next start skipped the
migration too. A postcondition that reads back the namespace it was handed
cannot detect being handed the wrong one.

WHAT CHANGED

1. THE POSTCONDITION IS GRADED AGAINST THE ROSTER. `_verify_moves()` takes the
   names the MACHINES read and reports a namespace that is not among them as
   `misdirected` - a new, differently-named failure from `lost`:
     * `lost` is "the store took the write and dropped it" - a storage fault,
       with a harmless-default case (a dropped `last_completed: None` loses
       nothing, because absent and never-recalibrated are the same statement);
     * `misdirected` is "the store did exactly as it was told, by a caller that
       was wrong". There is no harmless-default version of it - a write nobody
       reads is not information whatever its value - so it fails `ok` alone.
   `ok` gates the marker, so a misdirected migration is NOT stamped and the next
   start re-runs it. Asymmetric on purpose: a re-run is recoverable (rule 1
   makes it a no-op, rule 2 left the flat keys to recover from), a silent skip
   is not. Part 9b MEASURES the recovery rather than asserting it.

2. `migrate_maint_keys()` TAKES THE ROSTER IN PLACE OF AN `n_units` COUNT. The
   count was `len(self.inverters)` at the only call site, so the roster carries
   it, and a count beside the names is a second opinion about how many packs
   there are.

3. THE FIXTURES CAN NOW REACH THE FAILURE THEY NAME. Parts 9 and 11 run on
   ["garage","shed"], each behind a PRECONDITION check that the first name is
   not UNIT_A - so an edit that makes them equal again goes red instead of
   making the claims vacuous. Part 11's fixture date is relative to `now`, not
   a literal, so "history preserved" and "history lost" cannot converge as the
   file ages past `interval_days`.

MUTATION LEDGER - all red, purged tree, none at +0 B, full detail on the PR:
   M1  main.py  inverters[0].name -> UNIT_A            -16 B  4 checks red
   M2  store.py drop `roster=names` from _verify_moves -14 B  5 checks red
   M3  store.py drop the misdirected.append            -37 B  5 checks red
   M4  store.py ok: drop `and not self.misdirected`    -25 B  3 checks red
   M5  fixture  garage -> inverter-a (the trap itself)  +4 B  5 checks red
   M6  store.py parts[1] -> parts[-1]                   +1 B  1 check red

FOUND AND NOT FIXED - `migrate_unit_keys()` HAS THE SAME HOLE AND IT IS A LATCH.
It runs BEFORE `build_roster()` (each Inverter restores its latch in its
constructor), so it is handed the constant UNIT_A with no roster to grade
against. On a declared roster a pre-DEV-02 `sign_tripped: true` lands under
unit/inverter-a/sign_tripped, which no unit reads - and
`_grade_store_migration()`, whose whole job is to re-arm exactly this, is
DEFEATED BY THE KEY THE DEFECT WROTE: its "is anything namespaced latched" test
is satisfied by the misdirected value itself. Measured, with the flat-config
counterfactual, and pinned as a running check (test_maintenance.py part 13)
whose assertions state the WRONG behaviour on purpose. It wants its own card:
the remedy is in inverter.py and SAFETY-02's semantics, and a silent expansion
into a safety latch is how a rejected PR becomes a broken one.

Suite: 19/19 PASS, purged tree, bind-mounted :ro in goodwe-addon-ci.
No hardware was touched; every fixture here is SIMULATED and pack B's 3000 W
remains a marked hypothesis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

MAINT-05 fix round — the survivor is closed, and the silent-success hole with it

Head is now 65cddf3. Everything below is pasted from runs on this box; nothing is described that was not run.

The harness, first, because the rest is worthless without it

Every tree below is an rsync of the working tree to a plain path with __pycache__ and .git excluded, then bind-mounted :ro into goodwe-addon-ci — the same structural immunity .gitea/workflows/checks.yml has, where the interpreter physically cannot write a cache into the tree it is grading. So each run starts at 0 bytecode entries and cannot acquire one.

That the counts are real, demonstrated on a deliberately dirty tree (one run with a writable mount and no -B):

== a deliberately DIRTY tree: run once WITHOUT -B and without :ro, so a cache exists
bytecode entries BEFORE purge = 23
/home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__
/home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__/main.cpython-314.pyc
/home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__/tariff.cpython-314.pyc
/home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__/maintenance.cpython-314.pyc
bytecode entries AFTER  purge = 0

⚠️ Worth recording: the host-side purge of that tree silently removed nothing — the .pyc files are root-owned by the container, and find -exec rm failed. tools/mutation_run.sh exits 1 on exactly that state ("could not purge, and a cache remains"), which is the right answer. The purge above had to run inside a container. Anyone mutating through a writable docker mount should know that.

Every mutation below has a non-zero byte delta — none is the +0 B case — and the mutated line is shown on disk before the run.


1. The survivor, reproduced on the rejected head 1aef19e

app/main.py:282-283, self.inverters[0].name → UNIT_A. Byte delta 228319 → 228303 = -16 B. On disk before the run:

282:        self.maint_migration = migrate_maint_keys(
283-            store, UNIT_A, len(self.inverters))

Full 19-file suite on that mutant:

== bytecode entries after purge = 0
== run: 19 file(s) (python3 -B)
   PASS  test_alloc.py  (25s)      PASS  test_maintenance.py  (1s)
   PASS  test_arbiter.py  (0s)     PASS  test_p1.py  (8s)
   PASS  test_arbitrage.py  (5s)   PASS  test_peakguard.py  (52s)
   PASS  test_bytecode.py  (1s)    PASS  test_price.py  (1s)
   PASS  test_ci_gate.py  (1s)     PASS  test_price_forecast.py  (0s)
   PASS  test_clamp.py  (1s)       PASS  test_static_tariff.py  (4s)
   PASS  test_control.py  (0s)     PASS  test_tariff.py  (6s)
   PASS  test_freeze.py  (34s)     PASS  test_validate.py  (5s)
   PASS  test_inverter.py  (7s)    PASS  test_watchdog.py  (31s)
   PASS  test_loop.py  (93s)

19/19 PASS. Confirmed exactly as reported.

2. …and the reviewer's ["garage","shed"] repro on that same pair of trees

A real Controller against a stub HA — simulated, no rig, no device; pack B's 3000 W is the marked hypothesis — with a declared roster and a flat last_completed of 2026-08-07:

############ TREE: base (release head 1aef19e) ############
        self.maint_migration = migrate_maint_keys(
            store, self.inverters[0].name, len(self.inverters))
--- run ---
roster declared in options : ['garage', 'shed']
flat store before start    : last_completed=2026-08-07T12:00:00+00:00
roster built               : ['garage', 'shed']
migration report           : migrated 4 flat key(s) to inverter namespaces: unit/garage/last_completed, unit/garage/last_start_attempt, unit/garage/phase, unit/garage/phase_started
migration .ok / .verified  : True / True
keys written by migration  : ['unit/garage/last_completed', 'unit/garage/last_start_attempt', 'unit/garage/phase', 'unit/garage/phase_started']
maint_schema_version       : 1
pack 0 (garage) reads back  : 2026-08-07 12:00:00+00:00
pack 0 due now?            : False   (interval 28 d)

RESULT: history preserved - pack 0 reads back the flat 2026-08-07T12:00:00+00:00.
        (maint_schema_version STAMPED)
exit=0

############ TREE: mut (1aef19e + UNIT_A) ############
        self.maint_migration = migrate_maint_keys(
            store, UNIT_A, len(self.inverters))
--- run ---
roster declared in options : ['garage', 'shed']
flat store before start    : last_completed=2026-08-07T12:00:00+00:00
roster built               : ['garage', 'shed']
migration report           : migrated 4 flat key(s) to inverter namespaces: unit/inverter-a/last_completed, unit/inverter-a/last_start_attempt, unit/inverter-a/phase, unit/inverter-a/phase_started
migration .ok / .verified  : True / True
keys written by migration  : ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started']
maint_schema_version       : 1
pack 0 (garage) reads back  : None
pack 0 due now?            : True   (interval 28 d)

RESULT: HISTORY LOST - pack 0 believes it has NEVER been recalibrated.
        (maint_schema_version STAMPED)
exit=1

Note the two lines that matter as much as the loss: .ok / .verified is True / True and the marker is STAMPED. The migration was satisfied by reading back the keys it had itself written, under the name it had itself been handed, and then told the next start not to bother.


3. What changed

(a) _verify_moves() is graded against the namespace the MACHINES read. It takes the roster and reports a unit that is not among those names as misdirected. That is a deliberately different failure from lost:

  • lost is the store took the write and dropped it — a storage fault, and it has a harmless-default case (a dropped last_completed: None loses nothing; "absent" and "never recalibrated" are the same statement, which is why lost may shout);
  • misdirected is the store did exactly as it was told, by a caller that was wrong. There is no harmless-default version of it: a write nobody reads is not information whatever its value. So anything in it fails ok on its own.

(b) ok gates the marker, so a misdirected migration is NOT stamped. This is the remedy the ticket asked me to decide and record, and the argument is asymmetric: a re-run is recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the flat keys in the file to recover from — while a silent skip is not recoverable at all, because the store now claims the migration happened. Part 9b measures the recovery rather than asserting it: the same store, re-run with the roster's own first name, comes back with its month of history.

(c) migrate_maint_keys() takes the roster in place of n_units. The count was len(self.inverters) at the only call site, so the roster carries it, and a count passed beside the names is a second opinion about how many packs there are.

(d) The fixtures can now reach the failure they name. Parts 9 and 11 run on ["garage","shed"], each behind a precondition check (MUTATION.md habit 1) asserting the first name is not UNIT_A — so an edit that makes them equal again goes red instead of making the claims vacuous. Part 11's fixture date is now relative to now rather than the literal 2026-08-07: an absolute date drifts toward interval_days as the file ages, and the day it crosses, "history preserved" and "history lost" stop being different answers. That is the same degenerate-fixture defect one level up, and it was two days from biting.


4. The mutation is now RED, and it names the right thing

Same mutation, applied to 65cddf3. Byte delta 229275 → 229259 = -16 B. On disk before the run:

        self.maint_migration = migrate_maint_keys(
            store, UNIT_A, [inv.name for inv in self.inverters])
   PASS  test_alloc.py  (25s)      PASS  test_loop.py  (93s)
   PASS  test_arbiter.py  (0s)     FAIL  test_maintenance.py  (1s)
   PASS  test_arbitrage.py  (6s)   PASS  test_p1.py  (8s)
   PASS  test_bytecode.py  (0s)    PASS  test_peakguard.py  (54s)
   PASS  test_ci_gate.py  (1s)     PASS  test_price.py  (2s)
   PASS  test_clamp.py  (1s)       PASS  test_price_forecast.py  (0s)
   PASS  test_control.py  (0s)     PASS  test_static_tariff.py  (4s)
   PASS  test_freeze.py  (34s)     PASS  test_tariff.py  (6s)
   PASS  test_inverter.py  (7s)    PASS  test_validate.py  (5s)
                                   PASS  test_watchdog.py  (31s)

Verbatim, the checks that went red (part 11):

  ok   PRECONDITION for the migration checks below: this roster's first entry is 'garage', which is NOT `UNIT_A` ('inverter-a'), and `UNIT_A` is not on the roster at all. Every claim under it about 'the first roster entry's namespace' is therefore a claim a wrong constant would fail
    roster ['garage', 'shed'] -> namespaces ['garage', 'shed'], mirrors [True, False]
    flat last_completed 2026-09-01 (3 d ago, interval 28 d) -> migration: migrated 4 flat key(s) to inverter namespaces: unit/inverter-a/last_completed, ... - WRONG NAMESPACE: unit/inverter-a/last_completed was migrated into namespace 'inverter-a', which is NOT on the roster ['garage', 'shed'] - nothing reads that key; ...
    namespaced keys now: ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started']
  FAIL a two-inverter Controller builds one machine per roster entry, each in its own namespace, and migrates the flat history into the FIRST ROSTER ENTRY'S - `garage`, the name the config gave it, not a constant
  FAIL ⚠️ AND NOT ONE MAINTENANCE KEY IS WRITTEN UNDER `unit/inverter-a/`. That is the namespace a call site handing the migration `UNIT_A` would use, and on this roster nothing reads it - so a store carrying one is an install whose whole maintenance history has been parked out of sight
  FAIL ⚠️ AND THE PACK IS NOT DUE, WHICH IS WHAT 'NO LOST HISTORY' MEANS TO AN OPERATOR (AC5). It recalibrated 3 days ago and the interval is 28 days. A migration into a namespace this pack does not read leaves it reading NOTHING - absent means never recalibrated, means due now - so this one line separates a preserved month from a lost one
  FAIL ...and the migration REPORTS clean and stamps the marker on this roster, so 'not due' above is a migration that landed rather than one that was refused
  ok   ...and ONLY the first pack mirrors back to the flat keys, ...
  ok   ...and the mirror is LIVE: ...
  ok   ...while the second pack's write reaches only its own namespace

4 FAILED

Four named checks, and the third of them is the operator-facing symptom in the operator's own terms: the pack believes it has never been recalibrated.

5. The reviewer's repro, re-run against both fix-round trees

############ TREE: head (65cddf3) ############
            store, self.inverters[0].name, [inv.name for inv in self.inverters])
--- run ---
roster built               : ['garage', 'shed']
migration .ok / .verified  : True / True
keys written by migration  : ['unit/garage/last_completed', 'unit/garage/last_start_attempt', 'unit/garage/phase', 'unit/garage/phase_started']
maint_schema_version       : 1
pack 0 (garage) reads back  : 2026-08-07 12:00:00+00:00
pack 0 due now?            : False   (interval 28 d)

RESULT: history preserved - pack 0 reads back the flat 2026-08-07T12:00:00+00:00.
        (maint_schema_version STAMPED)
exit=0

############ TREE: mut2 (65cddf3 + UNIT_A) ############
            store, UNIT_A, [inv.name for inv in self.inverters])
--- run ---
roster built               : ['garage', 'shed']
migration report           : migrated 4 flat key(s) to inverter namespaces: unit/inverter-a/last_completed, ... - WRONG NAMESPACE: unit/inverter-a/last_completed was migrated into namespace 'inverter-a', which is NOT on the roster ['garage', 'shed'] - nothing reads that key; ...
migration .ok / .verified  : False / False
keys written by migration  : ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started']
maint_schema_version       : <<absent>>
pack 0 (garage) reads back  : None
pack 0 due now?            : True   (interval 28 d)

RESULT: HISTORY LOST - pack 0 believes it has NEVER been recalibrated.
        (maint_schema_version NOT stamped)
exit=1

The wrong call site still puts the data in the wrong place — it is a wrong call site — but it is now loud (ok/verified both False, the refusal naming the namespace and the roster) and the marker is not stamped, so the next start re-runs and recovers. That is the difference between a bug you find and a bug you never find.

6. The unmutated tree, fully green

65cddf3, purged, :ro:

== bytecode entries after purge = 0
== run: 19 file(s) (python3 -B)
   PASS  test_alloc.py  (25s)      PASS  test_maintenance.py  (0s)
   PASS  test_arbiter.py  (0s)     PASS  test_p1.py  (8s)
   PASS  test_arbitrage.py  (6s)   PASS  test_peakguard.py  (55s)
   PASS  test_bytecode.py  (1s)    PASS  test_price.py  (1s)
   PASS  test_ci_gate.py  (1s)     PASS  test_price_forecast.py  (1s)
   PASS  test_clamp.py  (1s)       PASS  test_static_tariff.py  (3s)
   PASS  test_control.py  (0s)     PASS  test_tariff.py  (8s)
   PASS  test_freeze.py  (34s)     PASS  test_validate.py  (6s)
   PASS  test_inverter.py  (6s)    PASS  test_watchdog.py  (30s)
   PASS  test_loop.py  (94s)

19/19 PASS.


7. Mutation ledger — six, all red, none at +0 B

Each on its own freshly-rsynced tree, 0 bytecode entries before the run, :ro.

file mutation Δ bytes result
M1 app/main.py self.inverters[0].name → UNIT_A −16 B RED, 4 checks (part 11), incl. "the pack is not due"
M2 app/store.py _verify_moves(store, rep, roster=names) → _verify_moves(store, rep) −14 B RED, 5 checks (part 9b) — the postcondition itself
M3 app/store.py drop rep.misdirected.append(note) −37 B RED, 5 checks (part 9b)
M4 app/store.py ok: drop and not self.misdirected −25 B RED, 3 checks — incl. "the marker is NOT stamped"
M5 test_maintenance.py the trap itself: fixture garage → inverter-a +4 B RED, 5 checks — the precondition fires first
M6 app/store.py parts[1] → parts[-1] (grade the key name, not the unit) +1 B RED, part 9

M5 is the one I would ask a reviewer to look at. Renaming the fixture's first pack back to inverter-a takes the precondition check red by name — "the first pack on this section's roster is NOT UNIT_A" — rather than silently making four claims vacuous the way it did before. That is habit 1 doing the job it exists for, measured rather than asserted.

M6 goes red on the right check first and then raises a KeyError on the following marker assertion, so its downstream checks do not run. It is still a red naming the right thing; recording it honestly rather than tidying it.


8. ⚠️ FOUND AND NOT FIXED — migrate_unit_keys() has the identical hole, and it is a LATCH

Found while fixing this card's instance. Same defect one module over; it costs a disarmed safety guard rather than a lost history, and I have deliberately not fixed it here.

Controller.__init__ calls migrate_unit_keys(store, UNIT_A) — a hardcoded constant where the maintenance migration now passes the roster's own first name. It has to run before build_roster(), because each Inverter restores its sign latch in its constructor, so there is no roster to grade against and _verify_moves() is passed roster=None. On a declared roster a pre-DEV-02 sign_tripped: true therefore lands under unit/inverter-a/sign_tripped, which no unit reads.

And _grade_store_migration() — whose entire job is to re-arm exactly this — is defeated by the key the defect wrote. It re-arms when the flat key says tripped and nothing namespaced is latched. The misdirected write is namespaced and is latched, so namespaced_latched is True, it returns, and no inverter on the roster comes up tripped. Measured:

13. RECORDED, NOT FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a LATCH
    pre-DEV-02 flat latch + declared roster ['garage', 'shed'] -> migrated 2 flat key(s) to inverter namespaces: unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped
    latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped']
    roster came up tripped: [('garage', False), ('shed', False)]
  ok   the flat pre-DEV-02 latch is migrated into `unit/inverter-a/` - a name that is NOT on this roster, so no unit reads it. ...
  ok   ⚠️ AND NO INVERTER ON THE ROSTER COMES UP LATCHED, on a store whose file still reads `sign_tripped: true`. That is DEV-02's own sentence for the worst case - a disarmed safety guard on an install that believes it is protected
  ok   ...and `_grade_store_migration()` is DEFEATED BY THE KEY THE DEFECT WROTE, ... its 'is anything namespaced latched' test is satisfied by the misdirected value itself, so it returns instead of re-arming
    the same store on the FLAT config -> roster [('inverter-a', True)]
  ok   ...while the SAME store on the FLAT config - the shape where `UNIT_A` really is the roster's only name - migrates and re-arms correctly. ...

The last line is the accepted-baseline counterfactual (habit 7): on the flat shape the same store migrates and comes up latched, so this is a defect of the declared roster, not of the migration as such.

Why not fixed here. The remedy is to name the migration's target from the config before any Inverter exists, which means factoring build_roster()'s naming rule out of build_roster() so there is still only one spelling of it — inverter.py and SAFETY-02's latch semantics, with its own battery over test_inverter.py and test_watchdog.py. MAINT-05 moves the maintenance history and nothing about the sign latch, and silently expanding a rejected PR into a safety latch is how it becomes a broken one. It wants a card.

It is pinned as a running check (test_maintenance.py part 13) rather than left as a comment, following part 12's precedent. ⚠️ Its assertions state the WRONG behaviour on purpose — read them as the specification of a defect; the card that fixes it should expect them to go red and should replace them. M5 above also takes part 13 red, which is the evidence that those four checks are live and not tautologies.


9. Audit of the other fixtures (ticket item 3)

inverter-a appears 103 times across eight suites. Almost all of them are the flat single-inverter shape, where UNIT_A genuinely is the roster's only name and hardcoding it is correct — test_watchdog.py's TRIPPED_KEY, test_inverter.py's flat-roster and migrate_unit_keys() checks, test_peakguard.py's c_src.units == (UNIT_A,). Those are not the trap.

The instances where a roster name should have been read were:

  • test_maintenance.py part 9 — every direct migrate_maint_keys() call and every unit_key("inverter-a", …) expectation. Fixed, now ROSTER = ["garage","shed"] behind a precondition.
  • test_maintenance.py part 11's _c2 block — the only Controller-level check of the migration, on TWO_OPTS, whose first entry is inverter-a. This is the one the mutation walked through. Fixed, now NAMED_OPTS with ["garage","shed"] behind its own precondition, plus a direct pin that not one key is written under unit/inverter-a/.
  • test_inverter.py's MAINT-05 diff — checked; it is about _unit_label() index travel and names inverter-b/inverter-c against literals. No equality trap.

TWO_OPTS itself is left named inverter-a/inverter-b on purpose: the part-11 checks that use it (which pack a claim is filed on, which pack's SoC ticks which machine, the banner naming the pack) compare pack A against pack B, never against a constant, so their two sides already come from different places.


10. Housekeeping

  • MUTATION.md's "third way a check lies" list now carries this instance, including the observation that makes it the fifth consecutive one: the check and the thing it checked were built from the same constant. The question to ask of a difference-claim is not "is this assertion true" but "where did each side of this comparison come from" — and if the answer is the same place twice, the check is decoration.
  • DECISIONS/MAINT-05-per-pack-maintenance.md gains the roster-postcondition argument in §2 and the not-fixed latch finding as §7; CHANGELOG.md carries both.
  • No hardware was touched. No rig, no ESP32, no inverter, no /dev/ttyUSB0, no witness Pi, no riglock.py, nothing flashed, no live HA. Every fixture here is simulated, and pack B's 3000 W remains a marked hypothesis everywhere it appears.
  • test_peakguard.py (CI-06, known flaky) passed on every run above and was never implicated.

Not merging — this is for review.

## MAINT-05 fix round — the survivor is closed, and the silent-success hole with it Head is now `65cddf3`. Everything below is pasted from runs on this box; nothing is described that was not run. ### The harness, first, because the rest is worthless without it Every tree below is an `rsync` of the working tree to a plain path with `__pycache__` and `.git` excluded, then bind-mounted **`:ro`** into `goodwe-addon-ci` — the same structural immunity `.gitea/workflows/checks.yml` has, where the interpreter physically cannot write a cache into the tree it is grading. So each run starts at **0** bytecode entries and cannot acquire one. That the counts are real, demonstrated on a deliberately dirty tree (one run with a **writable** mount and no `-B`): ``` == a deliberately DIRTY tree: run once WITHOUT -B and without :ro, so a cache exists bytecode entries BEFORE purge = 23 /home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__ /home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__/main.cpython-314.pyc /home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__/tariff.cpython-314.pyc /home/homelab/maint05-work/cachedemo/goodwe-addon/goodwe_controller/app/__pycache__/maintenance.cpython-314.pyc bytecode entries AFTER purge = 0 ``` ⚠️ Worth recording: the **host-side** purge of that tree silently removed nothing — the `.pyc` files are root-owned by the container, and `find -exec rm` failed. `tools/mutation_run.sh` exits 1 on exactly that state ("could not purge, and a cache remains"), which is the right answer. The purge above had to run *inside* a container. Anyone mutating through a writable docker mount should know that. Every mutation below has a **non-zero byte delta** — none is the `+0 B` case — and the mutated line is shown on disk before the run. --- ### 1. The survivor, reproduced on the rejected head `1aef19e` `app/main.py:282-283`, `self.inverters[0].name` → `UNIT_A`. **Byte delta `228319 → 228303 = -16 B`.** On disk before the run: ``` 282: self.maint_migration = migrate_maint_keys( 283- store, UNIT_A, len(self.inverters)) ``` Full 19-file suite on that mutant: ``` == bytecode entries after purge = 0 == run: 19 file(s) (python3 -B) PASS test_alloc.py (25s) PASS test_maintenance.py (1s) PASS test_arbiter.py (0s) PASS test_p1.py (8s) PASS test_arbitrage.py (5s) PASS test_peakguard.py (52s) PASS test_bytecode.py (1s) PASS test_price.py (1s) PASS test_ci_gate.py (1s) PASS test_price_forecast.py (0s) PASS test_clamp.py (1s) PASS test_static_tariff.py (4s) PASS test_control.py (0s) PASS test_tariff.py (6s) PASS test_freeze.py (34s) PASS test_validate.py (5s) PASS test_inverter.py (7s) PASS test_watchdog.py (31s) PASS test_loop.py (93s) ``` **19/19 PASS.** Confirmed exactly as reported. ### 2. …and the reviewer's `["garage","shed"]` repro on that same pair of trees A real `Controller` against a stub HA — **simulated, no rig, no device; pack B's 3000 W is the marked hypothesis** — with a declared roster and a flat `last_completed` of `2026-08-07`: ``` ############ TREE: base (release head 1aef19e) ############ self.maint_migration = migrate_maint_keys( store, self.inverters[0].name, len(self.inverters)) --- run --- roster declared in options : ['garage', 'shed'] flat store before start : last_completed=2026-08-07T12:00:00+00:00 roster built : ['garage', 'shed'] migration report : migrated 4 flat key(s) to inverter namespaces: unit/garage/last_completed, unit/garage/last_start_attempt, unit/garage/phase, unit/garage/phase_started migration .ok / .verified : True / True keys written by migration : ['unit/garage/last_completed', 'unit/garage/last_start_attempt', 'unit/garage/phase', 'unit/garage/phase_started'] maint_schema_version : 1 pack 0 (garage) reads back : 2026-08-07 12:00:00+00:00 pack 0 due now? : False (interval 28 d) RESULT: history preserved - pack 0 reads back the flat 2026-08-07T12:00:00+00:00. (maint_schema_version STAMPED) exit=0 ############ TREE: mut (1aef19e + UNIT_A) ############ self.maint_migration = migrate_maint_keys( store, UNIT_A, len(self.inverters)) --- run --- roster declared in options : ['garage', 'shed'] flat store before start : last_completed=2026-08-07T12:00:00+00:00 roster built : ['garage', 'shed'] migration report : migrated 4 flat key(s) to inverter namespaces: unit/inverter-a/last_completed, unit/inverter-a/last_start_attempt, unit/inverter-a/phase, unit/inverter-a/phase_started migration .ok / .verified : True / True keys written by migration : ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started'] maint_schema_version : 1 pack 0 (garage) reads back : None pack 0 due now? : True (interval 28 d) RESULT: HISTORY LOST - pack 0 believes it has NEVER been recalibrated. (maint_schema_version STAMPED) exit=1 ``` Note the two lines that matter as much as the loss: **`.ok / .verified` is `True / True`** and **the marker is STAMPED**. The migration was satisfied by reading back the keys it had itself written, under the name it had itself been handed, and then told the next start not to bother. --- ### 3. What changed **(a) `_verify_moves()` is graded against the namespace the MACHINES read.** It takes the roster and reports a unit that is not among those names as `misdirected`. That is a deliberately *different* failure from `lost`: * `lost` is *the store took the write and dropped it* — a storage fault, and it has a harmless-default case (a dropped `last_completed: None` loses nothing; "absent" and "never recalibrated" are the same statement, which is why `lost` may shout); * `misdirected` is *the store did exactly as it was told, by a caller that was wrong*. There is **no** harmless-default version of it: a write nobody reads is not information whatever its value. So anything in it fails `ok` on its own. **(b) `ok` gates the marker, so a misdirected migration is NOT stamped.** This is the remedy the ticket asked me to decide and record, and the argument is asymmetric: a re-run is recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the flat keys in the file to recover *from* — while a silent skip is not recoverable at all, because the store now claims the migration happened. Part 9b **measures** the recovery rather than asserting it: the same store, re-run with the roster's own first name, comes back with its month of history. **(c) `migrate_maint_keys()` takes the roster in place of `n_units`.** The count was `len(self.inverters)` at the only call site, so the roster carries it, and a count passed beside the names is a second opinion about how many packs there are. **(d) The fixtures can now reach the failure they name.** Parts 9 and 11 run on `["garage","shed"]`, each behind a **precondition check** (MUTATION.md habit 1) asserting the first name is not `UNIT_A` — so an edit that makes them equal again goes red instead of making the claims vacuous. Part 11's fixture date is now **relative to `now`** rather than the literal `2026-08-07`: an absolute date drifts toward `interval_days` as the file ages, and the day it crosses, "history preserved" and "history lost" stop being different answers. That is the same degenerate-fixture defect one level up, and it was two days from biting. --- ### 4. The mutation is now RED, and it names the right thing Same mutation, applied to `65cddf3`. **Byte delta `229275 → 229259 = -16 B`.** On disk before the run: ``` self.maint_migration = migrate_maint_keys( store, UNIT_A, [inv.name for inv in self.inverters]) ``` ``` PASS test_alloc.py (25s) PASS test_loop.py (93s) PASS test_arbiter.py (0s) FAIL test_maintenance.py (1s) PASS test_arbitrage.py (6s) PASS test_p1.py (8s) PASS test_bytecode.py (0s) PASS test_peakguard.py (54s) PASS test_ci_gate.py (1s) PASS test_price.py (2s) PASS test_clamp.py (1s) PASS test_price_forecast.py (0s) PASS test_control.py (0s) PASS test_static_tariff.py (4s) PASS test_freeze.py (34s) PASS test_tariff.py (6s) PASS test_inverter.py (7s) PASS test_validate.py (5s) PASS test_watchdog.py (31s) ``` Verbatim, the checks that went red (part 11): ``` ok PRECONDITION for the migration checks below: this roster's first entry is 'garage', which is NOT `UNIT_A` ('inverter-a'), and `UNIT_A` is not on the roster at all. Every claim under it about 'the first roster entry's namespace' is therefore a claim a wrong constant would fail roster ['garage', 'shed'] -> namespaces ['garage', 'shed'], mirrors [True, False] flat last_completed 2026-09-01 (3 d ago, interval 28 d) -> migration: migrated 4 flat key(s) to inverter namespaces: unit/inverter-a/last_completed, ... - WRONG NAMESPACE: unit/inverter-a/last_completed was migrated into namespace 'inverter-a', which is NOT on the roster ['garage', 'shed'] - nothing reads that key; ... namespaced keys now: ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started'] FAIL a two-inverter Controller builds one machine per roster entry, each in its own namespace, and migrates the flat history into the FIRST ROSTER ENTRY'S - `garage`, the name the config gave it, not a constant FAIL ⚠️ AND NOT ONE MAINTENANCE KEY IS WRITTEN UNDER `unit/inverter-a/`. That is the namespace a call site handing the migration `UNIT_A` would use, and on this roster nothing reads it - so a store carrying one is an install whose whole maintenance history has been parked out of sight FAIL ⚠️ AND THE PACK IS NOT DUE, WHICH IS WHAT 'NO LOST HISTORY' MEANS TO AN OPERATOR (AC5). It recalibrated 3 days ago and the interval is 28 days. A migration into a namespace this pack does not read leaves it reading NOTHING - absent means never recalibrated, means due now - so this one line separates a preserved month from a lost one FAIL ...and the migration REPORTS clean and stamps the marker on this roster, so 'not due' above is a migration that landed rather than one that was refused ok ...and ONLY the first pack mirrors back to the flat keys, ... ok ...and the mirror is LIVE: ... ok ...while the second pack's write reaches only its own namespace 4 FAILED ``` Four named checks, and the third of them is the operator-facing symptom in the operator's own terms: **the pack believes it has never been recalibrated.** ### 5. The reviewer's repro, re-run against both fix-round trees ``` ############ TREE: head (65cddf3) ############ store, self.inverters[0].name, [inv.name for inv in self.inverters]) --- run --- roster built : ['garage', 'shed'] migration .ok / .verified : True / True keys written by migration : ['unit/garage/last_completed', 'unit/garage/last_start_attempt', 'unit/garage/phase', 'unit/garage/phase_started'] maint_schema_version : 1 pack 0 (garage) reads back : 2026-08-07 12:00:00+00:00 pack 0 due now? : False (interval 28 d) RESULT: history preserved - pack 0 reads back the flat 2026-08-07T12:00:00+00:00. (maint_schema_version STAMPED) exit=0 ############ TREE: mut2 (65cddf3 + UNIT_A) ############ store, UNIT_A, [inv.name for inv in self.inverters]) --- run --- roster built : ['garage', 'shed'] migration report : migrated 4 flat key(s) to inverter namespaces: unit/inverter-a/last_completed, ... - WRONG NAMESPACE: unit/inverter-a/last_completed was migrated into namespace 'inverter-a', which is NOT on the roster ['garage', 'shed'] - nothing reads that key; ... migration .ok / .verified : False / False keys written by migration : ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started'] maint_schema_version : <<absent>> pack 0 (garage) reads back : None pack 0 due now? : True (interval 28 d) RESULT: HISTORY LOST - pack 0 believes it has NEVER been recalibrated. (maint_schema_version NOT stamped) exit=1 ``` The wrong call site still puts the data in the wrong place — it is a wrong call site — but it is now **loud** (`ok`/`verified` both `False`, the refusal naming the namespace *and* the roster) and the marker is **not stamped**, so the next start re-runs and recovers. That is the difference between a bug you find and a bug you never find. ### 6. The unmutated tree, fully green `65cddf3`, purged, `:ro`: ``` == bytecode entries after purge = 0 == run: 19 file(s) (python3 -B) PASS test_alloc.py (25s) PASS test_maintenance.py (0s) PASS test_arbiter.py (0s) PASS test_p1.py (8s) PASS test_arbitrage.py (6s) PASS test_peakguard.py (55s) PASS test_bytecode.py (1s) PASS test_price.py (1s) PASS test_ci_gate.py (1s) PASS test_price_forecast.py (1s) PASS test_clamp.py (1s) PASS test_static_tariff.py (3s) PASS test_control.py (0s) PASS test_tariff.py (8s) PASS test_freeze.py (34s) PASS test_validate.py (6s) PASS test_inverter.py (6s) PASS test_watchdog.py (30s) PASS test_loop.py (94s) ``` **19/19 PASS.** --- ### 7. Mutation ledger — six, all red, none at `+0 B` Each on its own freshly-rsynced tree, 0 bytecode entries before the run, `:ro`. | | file | mutation | Δ bytes | result | |---|---|---|---|---| | **M1** | `app/main.py` | `self.inverters[0].name` → `UNIT_A` | **−16 B** | RED, 4 checks (part 11), incl. "the pack is not due" | | **M2** | `app/store.py` | `_verify_moves(store, rep, roster=names)` → `_verify_moves(store, rep)` | **−14 B** | RED, 5 checks (part 9b) — the postcondition itself | | **M3** | `app/store.py` | drop `rep.misdirected.append(note)` | **−37 B** | RED, 5 checks (part 9b) | | **M4** | `app/store.py` | `ok`: drop `and not self.misdirected` | **−25 B** | RED, 3 checks — incl. "the marker is NOT stamped" | | **M5** | `test_maintenance.py` | the trap itself: fixture `garage` → `inverter-a` | **+4 B** | RED, 5 checks — the **precondition** fires first | | **M6** | `app/store.py` | `parts[1]` → `parts[-1]` (grade the key name, not the unit) | **+1 B** | RED, part 9 | **M5 is the one I would ask a reviewer to look at.** Renaming the fixture's first pack back to `inverter-a` takes the precondition check red *by name* — "the first pack on this section's roster is NOT `UNIT_A`" — rather than silently making four claims vacuous the way it did before. That is habit 1 doing the job it exists for, measured rather than asserted. M6 goes red on the right check first and then raises a `KeyError` on the following marker assertion, so its downstream checks do not run. It is still a red naming the right thing; recording it honestly rather than tidying it. --- ### 8. ⚠️ FOUND AND NOT FIXED — `migrate_unit_keys()` has the identical hole, and it is a LATCH Found while fixing this card's instance. Same defect one module over; it costs a **disarmed safety guard** rather than a lost history, and I have deliberately **not** fixed it here. `Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` — a hardcoded constant where the maintenance migration now passes the roster's own first name. It **has** to run before `build_roster()`, because each `Inverter` restores its sign latch in its constructor, so there is no roster to grade against and `_verify_moves()` is passed `roster=None`. On a **declared** roster a pre-DEV-02 `sign_tripped: true` therefore lands under `unit/inverter-a/sign_tripped`, which no unit reads. **And `_grade_store_migration()` — whose entire job is to re-arm exactly this — is defeated by the key the defect wrote.** It re-arms when the flat key says tripped and *nothing namespaced is latched*. The misdirected write **is** namespaced and **is** latched, so `namespaced_latched` is `True`, it returns, and no inverter on the roster comes up tripped. Measured: ``` 13. RECORDED, NOT FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a LATCH pre-DEV-02 flat latch + declared roster ['garage', 'shed'] -> migrated 2 flat key(s) to inverter namespaces: unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped'] roster came up tripped: [('garage', False), ('shed', False)] ok the flat pre-DEV-02 latch is migrated into `unit/inverter-a/` - a name that is NOT on this roster, so no unit reads it. ... ok ⚠️ AND NO INVERTER ON THE ROSTER COMES UP LATCHED, on a store whose file still reads `sign_tripped: true`. That is DEV-02's own sentence for the worst case - a disarmed safety guard on an install that believes it is protected ok ...and `_grade_store_migration()` is DEFEATED BY THE KEY THE DEFECT WROTE, ... its 'is anything namespaced latched' test is satisfied by the misdirected value itself, so it returns instead of re-arming the same store on the FLAT config -> roster [('inverter-a', True)] ok ...while the SAME store on the FLAT config - the shape where `UNIT_A` really is the roster's only name - migrates and re-arms correctly. ... ``` The last line is the accepted-baseline counterfactual (habit 7): on the flat shape the same store migrates and comes up latched, so this is a defect of the **declared roster**, not of the migration as such. **Why not fixed here.** The remedy is to name the migration's target from the config before any `Inverter` exists, which means factoring `build_roster()`'s naming rule out of `build_roster()` so there is still only one spelling of it — `inverter.py` and SAFETY-02's latch semantics, with its own battery over `test_inverter.py` and `test_watchdog.py`. MAINT-05 moves the maintenance history and nothing about the sign latch, and silently expanding a rejected PR into a safety latch is how it becomes a broken one. **It wants a card.** It is pinned as a running check (`test_maintenance.py` part 13) rather than left as a comment, following part 12's precedent. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read them as the specification of a defect; the card that fixes it should expect them to go red and should replace them. `M5` above also takes part 13 red, which is the evidence that those four checks are live and not tautologies. --- ### 9. Audit of the other fixtures (ticket item 3) `inverter-a` appears 103 times across eight suites. Almost all of them are the **flat single-inverter shape**, where `UNIT_A` genuinely *is* the roster's only name and hardcoding it is correct — `test_watchdog.py`'s `TRIPPED_KEY`, `test_inverter.py`'s flat-roster and `migrate_unit_keys()` checks, `test_peakguard.py`'s `c_src.units == (UNIT_A,)`. Those are not the trap. The instances where a **roster name should have been read** were: * `test_maintenance.py` part 9 — every direct `migrate_maint_keys()` call and every `unit_key("inverter-a", …)` expectation. **Fixed**, now `ROSTER = ["garage","shed"]` behind a precondition. * `test_maintenance.py` part 11's `_c2` block — the only Controller-level check of the migration, on `TWO_OPTS`, whose first entry is `inverter-a`. **This is the one the mutation walked through.** Fixed, now `NAMED_OPTS` with `["garage","shed"]` behind its own precondition, plus a direct pin that not one key is written under `unit/inverter-a/`. * `test_inverter.py`'s MAINT-05 diff — checked; it is about `_unit_label()` index travel and names `inverter-b`/`inverter-c` against literals. No equality trap. `TWO_OPTS` itself is left named `inverter-a`/`inverter-b` on purpose: the part-11 checks that use it (which pack a claim is filed on, which pack's SoC ticks which machine, the banner naming the pack) compare pack A against pack B, never against a constant, so their two sides already come from different places. --- ### 10. Housekeeping * `MUTATION.md`'s "third way a check lies" list now carries this instance, including the observation that makes it the *fifth consecutive* one: **the check and the thing it checked were built from the same constant.** The question to ask of a difference-claim is not "is this assertion true" but "where did each side of this comparison come from" — and if the answer is the same place twice, the check is decoration. * `DECISIONS/MAINT-05-per-pack-maintenance.md` gains the roster-postcondition argument in §2 and the not-fixed latch finding as §7; `CHANGELOG.md` carries both. * **No hardware was touched.** No rig, no ESP32, no inverter, no `/dev/ttyUSB0`, no witness Pi, no `riglock.py`, nothing flashed, no live HA. Every fixture here is **simulated**, and pack B's 3000 W remains a **marked hypothesis** everywhere it appears. * `test_peakguard.py` (CI-06, known flaky) passed on every run above and was never implicated. Not merging — this is for review.
admin added 1 commit 2026-09-04 19:40:58 +02:00
REJECTED because the fix round's own load-bearing sentence was FALSE, and the
whole suite was blind to that. `_verify_moves()` produced the red the reviewer
reproduced - that half stood - but DECISIONS §2 claimed "`ok` gates the marker,
so a misdirected migration is not stamped and the next start runs it again ...
a re-run is recoverable". THE REFUSAL LASTED EXACTLY ONE START.

MEASURED, Controller level, ONE persisted store, roster ["garage","shed"], flat
last_completed 2026-08-07, call site still handing `inverter-a` on both starts:

    construction 1: ok=False misdirected=4 moved=4 kept=0   marker=<absent>
    construction 2: ok=True  misdirected=0 moved=0 kept=4   marker=1
    history parked under unit/inverter-a/...; marker stamped after ONE restart.

WHY. `_verify_moves()` graded only `rep.moved`. On start 2 the same wrong call
site finds the four keys already present, so rule 1 routes every one of them to
`rep.kept`, `rep.moved` is EMPTY, the roster loop iterates over nothing,
`misdirected` is empty and `ok` is True. Start 3 then takes the
`from_version >= MAINT_SCHEMA_VERSION` early return FOREVER - including after
the call site is fixed. Loud-and-recoverable became silent-and-permanent after
one restart: the exact state store.py's header says must never happen, reached
by the fix written to prevent it.

WHY THE CHECK MISSED IT - the recurring defect, this time INSIDE the remedy.
Part 9b's recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` - the
CORRECTED name. It measures recovery along the one path where the operator has
ALREADY fixed the bug. Nothing in the 19 files called `migrate_maint_keys()`
twice with the SAME WRONG `unit`. The check was true and passed for the wrong
reason.

WHAT CHANGED

1. app/store.py `_verify_moves()` grades `set(rep.moved) | set(rep.kept)`.
   Sound by construction: every entry in both lists is a `unit_key(unit,
   legacy)` built from the SAME `unit` argument, so grading `kept` grades the
   same namespace decision `moved` does. A correct call site's `kept` entries
   sit under a roster name and pass; a wrong one cannot launder its namespace by
   having already written it. `migrate_unit_keys()` passes `roster=None` and
   returns before the loop, so it is untouched.

2. test_maintenance.py part 9c - THE CHECK THAT WAS MISSING. Two starts, SAME
   wrong unit, ONE store: the marker is still withheld on the second, the
   refusal names the same roster, the flat history is still there to recover
   from, and a third start with the CORRECTED name still gets a run (which a
   stamped marker would have made impossible). Its PRECONDITION asserts the
   SHAPE of the second report - `moved` empty, `kept` full - so it cannot
   quietly revert to grading 9b's path. Counterfactual beside it: the same two
   calls with the right name are accepted, then skipped as `already at schema`.

3. test_maintenance.py part 9d - THE OTHER GUARD ON THE SAME LINE, and a
   survivor nothing in the repository could see. `if rep.ok:` is
   `not lost and not misdirected`; every check that reached the marker did so on
   a store whose `kept` was empty, so

       if rep.ok:        ->      if rep.ok or rep.kept:        (+12 B)

   stamped the marker on a migration that LOST A MONTH OF HISTORY - defeating
   the older, settled `lost` guard as well as the new one - and ALL 19 SUITES
   WERE GREEN on it (measured at 65cddf3, purged, :ro). 9d's fixture is the one
   shape that separates the guards: two keys already namespaced (`kept`
   non-empty, the mutant's `or` fires) and two whose write the store drops
   (`lost` non-empty), `misdirected` empty throughout.

4. FOUND HERE AND CLOSED: THE SAME MUTATION ONE FUNCTION UP. `migrate_unit_keys()`
   - SAFETY-02's LATCH migration - gates its own marker the same way, and every
   check that reached it ran on a store with an empty `kept`. `if rep.ok or
   rep.kept:` at store.py:307 SURVIVED ALL 19 FILES. It stamps `schema_version`
   on a migration that DROPPED THE LATCH: the guard is off, the file still reads
   `sign_tripped: true`, and the store now says the migration finished, so the
   next start skips the one thing that would re-arm it. test_inverter.py part 10
   now drops the `sign_tripped` write BESIDE an already-namespaced
   `sign_reason`, with the accepted-baseline counterfactual. No source change -
   the shipped gate was right; nothing could see it. Mutation table 34 -> 35.

5. DECISIONS/MAINT-05 §2 CORRECTED. The paragraph that stated the false
   guarantee now states what was measured, why grading `moved` alone could not
   hold, and why the union is sound. §7 records the marker-gate hole found in
   `migrate_unit_keys()` and that it was closed. MUTATION.md gains habit 9 -
   "exercise a refusal a SECOND time with the SAME wrong input, not the
   corrected one" - and two survey rows.

MUTATION LEDGER - 11 mutations, ALL RED on the final tree, every run on a fresh
copy with the bytecode cache purged (entry count printed before and after) and
the tree mounted :ro so none could be written. Two were survivors BEFORE this
commit and both are recorded as such. Full transcript on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

MAINT-05 round 3 — the refusal now survives the next start, and both marker guards are visible

The blocker is confirmed and closed, a second survivor inside the new gating logic is closed,
and a third survivor of the same shape one function up — in SAFETY-02's latch migration —
was found by this round's battery and closed as well.

DECISIONS/MAINT-05-per-pack-maintenance.md §2 has been corrected: the sentence "ok gates the
marker, so a misdirected migration is not stamped and the next start runs it again … a re-run is
recoverable"
was false as written, and the document now says so and says what was measured.

The one-line fix

--- a/goodwe_controller/app/store.py       _verify_moves()
-    for target in sorted(rep.moved):
+    for target in sorted(set(rep.moved) | set(rep.kept)):

Sound by construction: every entry in both rep.moved and rep.kept is a
unit_key(unit, legacy) built from the same unit argument, so grading kept grades the same
namespace decision moved does. migrate_unit_keys() passes roster=None and returns before the
loop, so it is untouched.


(a) THE TWO-STARTS CONTROLLER SCENARIO

Real Controller, constructed repeatedly against one persisted store, roster
["garage", "shed"], flat last_completed 2026-08-07, with the call site still wrong on every
start
(app/main.py mutated to hand UNIT_A). ⚠️ Simulated — stub HA, no rig, no device.

BEFORE — head 65cddf3:

roster ['garage', 'shed']; flat last_completed 2026-08-07T12:00:00+00:00
call site hands: store, UNIT_A, [inv.name for inv in self.inverters]
  construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
  construction 2: ok=True  misdirected=0 moved=0 kept=4 marker=1
  history parked under: ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started']
  garage reads last_completed: '<absent>'
  flat last_completed still: '2026-08-07T12:00:00+00:00'
  MARKER AFTER TWO STARTS: 1
  construction 3: ok=True misdirected=0 moved=0 kept=0 marker=1
  start 3 skipped? ran=False skipped='maintenance history already at schema 1; written by a multi-inverter version and not touched'

AFTER — this commit:

roster ['garage', 'shed']; flat last_completed 2026-08-07T12:00:00+00:00
call site hands: store, UNIT_A, [inv.name for inv in self.inverters]
  construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
  construction 2: ok=False misdirected=4 moved=0 kept=4 marker=<absent>
  history parked under: ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started']
  garage reads last_completed: '<absent>'
  flat last_completed still: '2026-08-07T12:00:00+00:00'
  MARKER AFTER TWO STARTS: '<absent>'
  construction 3: ok=False misdirected=4 moved=0 kept=4 marker=<absent>
  start 3 skipped? ran=True skipped=''

The marker is withheld on start 2 and start 3, the flat history is intact (rule 2), and the
migration keeps running — so a fixed call site still gets a run instead of an early return forever.

The check that was missing — test_maintenance.py 9c

9b's recovery check re-ran the migration with ROSTER[0], the corrected name: it measured
recovery along the one path where the operator has already fixed the bug. Nothing in the 19 files
called migrate_maint_keys() twice with the same wrong unit. 9c does:

9c. THE REFUSAL HAS TO SURVIVE THE NEXT START: the same wrong call site, twice, over ONE store
    start 1: ok=False misdirected=4 moved=4 kept=0 marker='<<absent>>'
    start 2: ok=False misdirected=4 moved=0 kept=4 marker='<<absent>>'
  ok   PRECONDITION, SO THIS SECTION CANNOT PASS FOR THE WRONG REASON: start 2 goes down the KEPT path ... `moved` is EMPTY and `kept` holds every one of them
  ok   ⚠️ THE MARKER IS STILL WITHHELD ON THE SECOND START ...
  ok   ...and it is refused for the SAME reason as the first time, graded against the same roster ...
  ok   ...and the history is still THERE to recover ...
    start 3, call site FIXED: migrated 4 flat key(s) to inverter namespaces: unit/garage/last_completed, unit/garage/last_start_attempt, unit/garage/phase, unit/garage/phase_started
  ok   ⚠️ AND BECAUSE THE MARKER WAS NEVER STAMPED, THE FIXED CALL SITE STILL GETS A RUN ...
  ok   ...and the counterfactual: the SAME two calls with the roster's own first name are accepted ...

The precondition pins the shape of the second report (moved empty, kept full), so the
section cannot quietly revert to grading 9b's path.


(b) M7 GOES RED

app/store.py migrate_maint_keys(): if rep.ok: → if rep.ok or rep.kept: (+12 B).

Differential on the 9d fixture (two keys already namespaced, two whose write the store drops):

shipped :  ran=True ok=False lost=2 kept=2 misdirected=0   MARKER: '<absent>'
M7      :  ran=True ok=False lost=2 kept=2 misdirected=0   MARKER: 1

M7 at head 65cddf3 — SURVIVOR, all 19 files:

== bytecode cache entries under /home/homelab/ci-m7: BEFORE=0  AFTER=0
== app/store.py 5ce5d033fd889163  29613 bytes
19 test file(s) to run
PASS  test_alloc.py … PASS  test_watchdog.py
SUITE: 19/19 PASS, 0 FAIL

M7 on this commit — RED, on three named checks:

=== M7-maint-gate-or-kept  (app/store.py)
    -    if rep.ok:
    +    if rep.ok or rep.kept:
    bytecode cache entries: BEFORE=0 AFTER=0
    app/store.py sha256 0aef8c7efcda75c4  32594 bytes
    RED (killed)      test_maintenance.py
        FAIL ⚠️ THE MARKER IS STILL WITHHELD ON THE SECOND START ...
        FAIL ⚠️ AND BECAUSE THE MARKER WAS NEVER STAMPED, THE FIXED CALL SITE STILL GETS A RUN ...
        FAIL ⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF HISTORY ...

test_maintenance.py 9d is the fixture that separates the two guards: kept non-empty (so the
mutant's or fires) and lost non-empty (so ok is False), with misdirected empty
throughout — it cannot pass on the other guard by accident. A counterfactual beside it runs the same
fixture on a store that does not drop writes and is stamped, so the refusal is about the state
that was lost, not about kept being non-empty.


(c) THE ORIGINAL UNIT_A MUTATION IS STILL RED

=== MG-callsite-UNIT_A  (app/main.py)
    -            store, self.inverters[0].name, [inv.name for inv in self.inverters])
    +            store, UNIT_A, [inv.name for inv in self.inverters])
    bytecode cache entries: BEFORE=0 AFTER=0
    app/main.py sha256 76baa99db0d00947  229259 bytes      (-16 B)
    RED (killed)      test_maintenance.py
        FAIL a two-inverter Controller builds one machine per roster entry ... migrates the flat history into the FIRST ROSTER ENTRY'S - `garage`
        FAIL ⚠️ AND NOT ONE MAINTENANCE KEY IS WRITTEN UNDER `unit/inverter-a/` ...
        FAIL ⚠️ AND THE PACK IS NOT DUE, WHICH IS WHAT 'NO LOST HISTORY' MEANS TO AN OPERATOR (AC5) ...
        FAIL ...and the migration REPORTS clean and stamps the marker on this roster ...

(d) CLEAN TREE — 19/19

Same harness the CI job uses: the tree rsynced to a plain path, bytecode purged with the entry
count printed before and after, mounted :ro, python3 -B.

== bytecode purge (PLAIN path /home/homelab/ci-maint05)
   cache entries BEFORE purge: 0
   cache entries AFTER  purge: 0
== tree graded (sha256 head, bytes)
   6a5406e9b1b02247     32582  app/store.py
== run (docker, mount :ro)
19 test file(s) to run
PASS  test_alloc.py  (26s)
PASS  test_arbiter.py  (0s)
PASS  test_arbitrage.py  (6s)
PASS  test_bytecode.py  (0s)
PASS  test_ci_gate.py  (1s)
PASS  test_clamp.py  (2s)
PASS  test_control.py  (0s)
PASS  test_freeze.py  (34s)
PASS  test_inverter.py  (6s)
PASS  test_loop.py  (94s)
PASS  test_maintenance.py  (1s)
PASS  test_p1.py  (7s)
PASS  test_peakguard.py  (83s)
PASS  test_price.py  (1s)
PASS  test_price_forecast.py  (1s)
PASS  test_static_tariff.py  (3s)
PASS  test_tariff.py  (11s)
PASS  test_validate.py  (5s)
PASS  test_watchdog.py  (30s)
SUITE: 19/19 PASS, 0 FAIL

⚠️ BEFORE=0 is a real zero, not a broken counter. The 0 matters — MUTATION.md's own note is
that a recorded zero is exactly what a stale-bytecode defect produces — so the counter was exercised
against a planted cache on a copy of the same tree:

planted cache -> BEFORE=3 AFTER=0

One honest note on this run. An earlier attempt at (d) was discarded, not reported: while
demonstrating the counter above I mistakenly created an empty app/__pycache__/store.cpython-312.pyc
inside the tree that run had mounted, for about 40 s. It reached 19/19 PASS and I am not
citing it. The transcript above is a full re-run started afterwards, from a freshly rsynced tree with
the cache verified at zero.


THE THIRD SURVIVOR, FOUND BY THIS ROUND'S BATTERY — the SAME mutation on SAFETY-02's LATCH migration

migrate_unit_keys() gates schema_version on rep.ok exactly as migrate_maint_keys() gates its
own marker, and every check that reached that gate ran on a store where nothing was namespaced
yet — so rep.kept was empty in all of them.

=== MK-unitkeys-gate  (app/store.py:307)
    -    if rep.ok:
    +    if rep.ok or rep.kept:                                (+12 B)
== bytecode cache entries under /home/homelab/ci-mk: BEFORE=0  AFTER=0
== app/store.py 012bd61ef929f4d7  32594 bytes
19 test file(s) to run
PASS  test_alloc.py … PASS  test_watchdog.py
SUITE: 19/19 PASS, 0 FAIL          <-- SURVIVOR

What it does: stamps schema_version on a migration that dropped the latch. The guard is off,
the state file still reads sign_tripped: true, and because the store now says the migration
finished, the next start skips the one thing that would have re-armed it — DEV-02's own worst
case, filed under done.

Closed by test_inverter.py part 10, with the one shape none of the existing fixtures had — the
sign_tripped: true write dropped beside an already-namespaced sign_reason (lost non-empty
and kept non-empty), plus the accepted-baseline counterfactual (habit 7):

    a dropped latch write beside an already-namespaced reason: migrated 1 flat key(s) to inverter namespaces: unit/inverter-a/sign_tripped (left alone, already namespaced: unit/inverter-a/sign_reason) - PROBLEMS: ... - STATE LOST: ...
    lost=1 kept=1 ok=False; marker '<<absent>>'
  ok   PRECONDITION: this fixture reaches the marker gate through the `lost` guard with a NON-EMPTY `kept` ...
  ok   ⚠️ AND THE STORE IS NOT MARKED MIGRATED ...
  ok   ...and the counterfactual, one property different: the SAME store shape on a store that does not drop writes IS marked migrated ...
=== MJ-unitkeys-gate  (after the pin)
    RED (killed)      test_inverter.py
        FAIL ⚠️ AND THE STORE IS NOT MARKED MIGRATED. The key that was ALREADY there does not buy a marker for the latch that was not ...

No source change — the shipped gate was right; nothing could see it. test_inverter.py's
mutation table goes 34 → 35.


MUTATION LEDGER — 11 mutations, all RED on this commit

Method, every row: a fresh copy of the tree to a plain path, bytecode cache entry count
printed before and after
the purge, the tree mounted :ro into goodwe-addon-ci so no .pyc
can be written during the run, python3 -B, and the mutated file's sha256 + byte count printed
from the tree that was graded. Clean app/store.py = 32582 B (6a5406e9b1b02247), clean
app/main.py = 229275 B.

id file / line mutation Δ bytes result
M7 store.py:529 if rep.ok: → if rep.ok or rep.kept: +12 RED — 3 checks (was a survivor 19/19 at 65cddf3)
MA store.py:434 grade sorted(rep.moved) — revert the fix −21 RED — 3 checks (9c)
MB store.py:434 grade sorted(rep.kept) only −22 RED — 6 checks (9b + 9c)
MC store.py:529 gate → if not rep.misdirected: +13 RED — 2 checks (9d + part 9)
MD store.py:529 gate → if not rep.lost: +6 RED — 6 checks (9b + 9c)
ME store.py:230 ok → not self.lost −25 RED — 6 checks
MF store.py:230 ok → not self.misdirected −18 RED — 4 checks (9d + part 9)
MG main.py self.inverters[0].name → UNIT_A −16 RED — 4 checks (part 11)
MH store.py:330 rep.kept.append(target) → pass −19 RED — 6 checks
MI store.py:434 union | → intersection & +0 RED — 6 checks
MJ store.py:307 migrate_unit_keys() gate → or rep.kept: +12 RED — 1 check (was a survivor 19/19 before the pin)

⚠️ MI is a ZERO-BYTE delta — proof the edit RAN

dis.get_instructions() on the running _verify_moves, in the container, against each tree
(both purged, both :ro), plus a different return from the same call:

--- CLEAN (union) ---
  source on disk : for target in sorted(set(rep.moved) | set(rep.kept)):
  file bytes     : 32582
  RUNNING code, binary ops inside _verify_moves:
     266  BINARY_OP    argrepr='[]'
     552  BINARY_OP    argrepr='|'
     650  BINARY_OP    argrepr='[]'
  start 1 handed 'inverter-a': misdirected=4 ok=False marker='<absent>'

--- MI (intersection, +0 B) ---
  source on disk : for target in sorted(set(rep.moved) & set(rep.kept)):
  file bytes     : 32582
  RUNNING code, binary ops inside _verify_moves:
     266  BINARY_OP    argrepr='[]'
     552  BINARY_OP    argrepr='&'
     650  BINARY_OP    argrepr='[]'
  start 1 handed 'inverter-a': misdirected=0 ok=True marker=1

sha256 differed (6a54… vs 94f4…) at identical byte counts, the disassembly shows the
interpreter executing & rather than |, and the call returns a different answer. TEST-15 cannot
be hiding in this row.


WHAT ELSE CHANGED

  • DECISIONS/MAINT-05-per-pack-maintenance.md §2 — the false paragraph is corrected in place, with
    the measured table, why grading moved alone could not hold, and why the union is sound. §7 gains
    the marker-gate hole found in migrate_unit_keys() and the note that it was closed here.
  • MUTATION.md — the MAINT-05 entry gains the sub-finding "and the fix itself carried the
    defect"
    ; a new habit 9 — exercise a refusal a SECOND time with the SAME wrong input, not the
    corrected one
    — and two survey rows.
  • CHANGELOG.md — two bullets for the second fix round, one for the found-and-closed latch-gate
    survivor.

Not done, deliberately

  • Nothing touched the rig, the ESP32, the inverter, the witness Pi or the live add-on. Every number
    above is from goodwe-addon-ci on a copied tree.
  • Inverter B does not physically exist. Every two-pack fixture is simulated and labelled as
    such; B_RATING_HYPOTHESIS = 3000 W remains a marked hypothesis.
  • Not merged, and release/1.0 was not pushed to.
## MAINT-05 round 3 — the refusal now survives the next start, and both marker guards are visible The blocker is confirmed and closed, a second survivor inside the new gating logic is closed, and a **third** survivor of the same shape one function up — in SAFETY-02's **latch** migration — was found by this round's battery and closed as well. `DECISIONS/MAINT-05-per-pack-maintenance.md` §2 has been corrected: the sentence *"`ok` gates the marker, so a misdirected migration is not stamped and the next start runs it again … a re-run is recoverable"* was **false as written**, and the document now says so and says what was measured. ### The one-line fix ```diff --- a/goodwe_controller/app/store.py _verify_moves() - for target in sorted(rep.moved): + for target in sorted(set(rep.moved) | set(rep.kept)): ``` Sound by construction: every entry in **both** `rep.moved` and `rep.kept` is a `unit_key(unit, legacy)` built from the **same** `unit` argument, so grading `kept` grades the same namespace decision `moved` does. `migrate_unit_keys()` passes `roster=None` and returns before the loop, so it is untouched. --- ## (a) THE TWO-STARTS CONTROLLER SCENARIO Real `Controller`, constructed repeatedly against **one persisted store**, roster `["garage", "shed"]`, flat `last_completed` 2026-08-07, with the **call site still wrong on every start** (`app/main.py` mutated to hand `UNIT_A`). ⚠️ Simulated — stub HA, no rig, no device. **BEFORE — head `65cddf3`:** ``` roster ['garage', 'shed']; flat last_completed 2026-08-07T12:00:00+00:00 call site hands: store, UNIT_A, [inv.name for inv in self.inverters] construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent> construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 history parked under: ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started'] garage reads last_completed: '<absent>' flat last_completed still: '2026-08-07T12:00:00+00:00' MARKER AFTER TWO STARTS: 1 construction 3: ok=True misdirected=0 moved=0 kept=0 marker=1 start 3 skipped? ran=False skipped='maintenance history already at schema 1; written by a multi-inverter version and not touched' ``` **AFTER — this commit:** ``` roster ['garage', 'shed']; flat last_completed 2026-08-07T12:00:00+00:00 call site hands: store, UNIT_A, [inv.name for inv in self.inverters] construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent> construction 2: ok=False misdirected=4 moved=0 kept=4 marker=<absent> history parked under: ['unit/inverter-a/last_completed', 'unit/inverter-a/last_start_attempt', 'unit/inverter-a/phase', 'unit/inverter-a/phase_started'] garage reads last_completed: '<absent>' flat last_completed still: '2026-08-07T12:00:00+00:00' MARKER AFTER TWO STARTS: '<absent>' construction 3: ok=False misdirected=4 moved=0 kept=4 marker=<absent> start 3 skipped? ran=True skipped='' ``` The marker is withheld on start 2 **and** start 3, the flat history is intact (rule 2), and the migration keeps running — so a fixed call site still gets a run instead of an early return forever. ### The check that was missing — `test_maintenance.py` **9c** 9b's recovery check re-ran the migration with `ROSTER[0]`, the **corrected** name: it measured recovery along the one path where the operator has already fixed the bug. Nothing in the 19 files called `migrate_maint_keys()` twice with the **same wrong** `unit`. 9c does: ``` 9c. THE REFUSAL HAS TO SURVIVE THE NEXT START: the same wrong call site, twice, over ONE store start 1: ok=False misdirected=4 moved=4 kept=0 marker='<<absent>>' start 2: ok=False misdirected=4 moved=0 kept=4 marker='<<absent>>' ok PRECONDITION, SO THIS SECTION CANNOT PASS FOR THE WRONG REASON: start 2 goes down the KEPT path ... `moved` is EMPTY and `kept` holds every one of them ok ⚠️ THE MARKER IS STILL WITHHELD ON THE SECOND START ... ok ...and it is refused for the SAME reason as the first time, graded against the same roster ... ok ...and the history is still THERE to recover ... start 3, call site FIXED: migrated 4 flat key(s) to inverter namespaces: unit/garage/last_completed, unit/garage/last_start_attempt, unit/garage/phase, unit/garage/phase_started ok ⚠️ AND BECAUSE THE MARKER WAS NEVER STAMPED, THE FIXED CALL SITE STILL GETS A RUN ... ok ...and the counterfactual: the SAME two calls with the roster's own first name are accepted ... ``` The **precondition** pins the *shape* of the second report (`moved` empty, `kept` full), so the section cannot quietly revert to grading 9b's path. --- ## (b) M7 GOES RED `app/store.py` `migrate_maint_keys()`: `if rep.ok:` → `if rep.ok or rep.kept:` (**+12 B**). **Differential on the 9d fixture** (two keys already namespaced, two whose write the store drops): ``` shipped : ran=True ok=False lost=2 kept=2 misdirected=0 MARKER: '<absent>' M7 : ran=True ok=False lost=2 kept=2 misdirected=0 MARKER: 1 ``` **M7 at head `65cddf3` — SURVIVOR, all 19 files:** ``` == bytecode cache entries under /home/homelab/ci-m7: BEFORE=0 AFTER=0 == app/store.py 5ce5d033fd889163 29613 bytes 19 test file(s) to run PASS test_alloc.py … PASS test_watchdog.py SUITE: 19/19 PASS, 0 FAIL ``` **M7 on this commit — RED, on three named checks:** ``` === M7-maint-gate-or-kept (app/store.py) - if rep.ok: + if rep.ok or rep.kept: bytecode cache entries: BEFORE=0 AFTER=0 app/store.py sha256 0aef8c7efcda75c4 32594 bytes RED (killed) test_maintenance.py FAIL ⚠️ THE MARKER IS STILL WITHHELD ON THE SECOND START ... FAIL ⚠️ AND BECAUSE THE MARKER WAS NEVER STAMPED, THE FIXED CALL SITE STILL GETS A RUN ... FAIL ⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF HISTORY ... ``` `test_maintenance.py` **9d** is the fixture that separates the two guards: `kept` non-empty (so the mutant's `or` fires) **and** `lost` non-empty (so `ok` is False), with `misdirected` empty throughout — it cannot pass on the other guard by accident. A counterfactual beside it runs the same fixture on a store that does **not** drop writes and is stamped, so the refusal is about the state that was lost, not about `kept` being non-empty. --- ## (c) THE ORIGINAL `UNIT_A` MUTATION IS STILL RED ``` === MG-callsite-UNIT_A (app/main.py) - store, self.inverters[0].name, [inv.name for inv in self.inverters]) + store, UNIT_A, [inv.name for inv in self.inverters]) bytecode cache entries: BEFORE=0 AFTER=0 app/main.py sha256 76baa99db0d00947 229259 bytes (-16 B) RED (killed) test_maintenance.py FAIL a two-inverter Controller builds one machine per roster entry ... migrates the flat history into the FIRST ROSTER ENTRY'S - `garage` FAIL ⚠️ AND NOT ONE MAINTENANCE KEY IS WRITTEN UNDER `unit/inverter-a/` ... FAIL ⚠️ AND THE PACK IS NOT DUE, WHICH IS WHAT 'NO LOST HISTORY' MEANS TO AN OPERATOR (AC5) ... FAIL ...and the migration REPORTS clean and stamps the marker on this roster ... ``` --- ## (d) CLEAN TREE — 19/19 Same harness the CI job uses: the tree rsynced to a **plain path**, bytecode purged with the entry count printed **before and after**, mounted **`:ro`**, `python3 -B`. ``` == bytecode purge (PLAIN path /home/homelab/ci-maint05) cache entries BEFORE purge: 0 cache entries AFTER purge: 0 == tree graded (sha256 head, bytes) 6a5406e9b1b02247 32582 app/store.py == run (docker, mount :ro) 19 test file(s) to run PASS test_alloc.py (26s) PASS test_arbiter.py (0s) PASS test_arbitrage.py (6s) PASS test_bytecode.py (0s) PASS test_ci_gate.py (1s) PASS test_clamp.py (2s) PASS test_control.py (0s) PASS test_freeze.py (34s) PASS test_inverter.py (6s) PASS test_loop.py (94s) PASS test_maintenance.py (1s) PASS test_p1.py (7s) PASS test_peakguard.py (83s) PASS test_price.py (1s) PASS test_price_forecast.py (1s) PASS test_static_tariff.py (3s) PASS test_tariff.py (11s) PASS test_validate.py (5s) PASS test_watchdog.py (30s) SUITE: 19/19 PASS, 0 FAIL ``` ⚠️ **`BEFORE=0` is a real zero, not a broken counter.** The `0` matters — MUTATION.md's own note is that a recorded zero is exactly what a stale-bytecode defect produces — so the counter was exercised against a planted cache on a copy of the same tree: ``` planted cache -> BEFORE=3 AFTER=0 ``` **One honest note on this run.** An earlier attempt at (d) was *discarded, not reported*: while demonstrating the counter above I mistakenly created an empty `app/__pycache__/store.cpython-312.pyc` **inside the tree that run had mounted**, for about 40 s. It reached `19/19 PASS` and I am not citing it. The transcript above is a full re-run started afterwards, from a freshly rsynced tree with the cache verified at zero. --- ## THE THIRD SURVIVOR, FOUND BY THIS ROUND'S BATTERY — the SAME mutation on SAFETY-02's LATCH migration `migrate_unit_keys()` gates `schema_version` on `rep.ok` exactly as `migrate_maint_keys()` gates its own marker, and **every** check that reached that gate ran on a store where nothing was namespaced yet — so `rep.kept` was empty in all of them. ``` === MK-unitkeys-gate (app/store.py:307) - if rep.ok: + if rep.ok or rep.kept: (+12 B) == bytecode cache entries under /home/homelab/ci-mk: BEFORE=0 AFTER=0 == app/store.py 012bd61ef929f4d7 32594 bytes 19 test file(s) to run PASS test_alloc.py … PASS test_watchdog.py SUITE: 19/19 PASS, 0 FAIL <-- SURVIVOR ``` What it does: stamps `schema_version` on a migration that **dropped the latch**. The guard is off, the state file still reads `sign_tripped: true`, and because the store now says the migration finished, the next start **skips the one thing that would have re-armed it** — DEV-02's own worst case, filed under *done*. Closed by `test_inverter.py` part 10, with the one shape none of the existing fixtures had — the `sign_tripped: true` write dropped **beside an already-namespaced `sign_reason`** (`lost` non-empty *and* `kept` non-empty), plus the accepted-baseline counterfactual (habit 7): ``` a dropped latch write beside an already-namespaced reason: migrated 1 flat key(s) to inverter namespaces: unit/inverter-a/sign_tripped (left alone, already namespaced: unit/inverter-a/sign_reason) - PROBLEMS: ... - STATE LOST: ... lost=1 kept=1 ok=False; marker '<<absent>>' ok PRECONDITION: this fixture reaches the marker gate through the `lost` guard with a NON-EMPTY `kept` ... ok ⚠️ AND THE STORE IS NOT MARKED MIGRATED ... ok ...and the counterfactual, one property different: the SAME store shape on a store that does not drop writes IS marked migrated ... ``` ``` === MJ-unitkeys-gate (after the pin) RED (killed) test_inverter.py FAIL ⚠️ AND THE STORE IS NOT MARKED MIGRATED. The key that was ALREADY there does not buy a marker for the latch that was not ... ``` **No source change** — the shipped gate was right; nothing could see it. `test_inverter.py`'s mutation table goes 34 → 35. --- ## MUTATION LEDGER — 11 mutations, all RED on this commit Method, every row: a **fresh copy** of the tree to a plain path, bytecode cache **entry count printed before and after** the purge, the tree mounted **`:ro`** into `goodwe-addon-ci` so no `.pyc` can be written during the run, `python3 -B`, and the mutated file's `sha256` + byte count printed from the tree that was graded. Clean `app/store.py` = **32582 B** (`6a5406e9b1b02247`), clean `app/main.py` = **229275 B**. | id | file / line | mutation | Δ bytes | result | |---|---|---|---|---| | M7 | store.py:529 | `if rep.ok:` → `if rep.ok or rep.kept:` | +12 | **RED** — 3 checks (was a **survivor 19/19** at `65cddf3`) | | MA | store.py:434 | grade `sorted(rep.moved)` — revert the fix | −21 | **RED** — 3 checks (9c) | | MB | store.py:434 | grade `sorted(rep.kept)` only | −22 | **RED** — 6 checks (9b + 9c) | | MC | store.py:529 | gate → `if not rep.misdirected:` | +13 | **RED** — 2 checks (9d + part 9) | | MD | store.py:529 | gate → `if not rep.lost:` | +6 | **RED** — 6 checks (9b + 9c) | | ME | store.py:230 | `ok` → `not self.lost` | −25 | **RED** — 6 checks | | MF | store.py:230 | `ok` → `not self.misdirected` | −18 | **RED** — 4 checks (9d + part 9) | | MG | main.py | `self.inverters[0].name` → `UNIT_A` | −16 | **RED** — 4 checks (part 11) | | MH | store.py:330 | `rep.kept.append(target)` → `pass` | −19 | **RED** — 6 checks | | MI | store.py:434 | union `\|` → intersection `&` | **+0** | **RED** — 6 checks | | MJ | store.py:307 | `migrate_unit_keys()` gate → `or rep.kept:` | +12 | **RED** — 1 check (was a **survivor 19/19** before the pin) | ### ⚠️ MI is a ZERO-BYTE delta — proof the edit RAN `dis.get_instructions()` on the **running** `_verify_moves`, in the container, against each tree (both purged, both `:ro`), plus a different return from the same call: ``` --- CLEAN (union) --- source on disk : for target in sorted(set(rep.moved) | set(rep.kept)): file bytes : 32582 RUNNING code, binary ops inside _verify_moves: 266 BINARY_OP argrepr='[]' 552 BINARY_OP argrepr='|' 650 BINARY_OP argrepr='[]' start 1 handed 'inverter-a': misdirected=4 ok=False marker='<absent>' --- MI (intersection, +0 B) --- source on disk : for target in sorted(set(rep.moved) & set(rep.kept)): file bytes : 32582 RUNNING code, binary ops inside _verify_moves: 266 BINARY_OP argrepr='[]' 552 BINARY_OP argrepr='&' 650 BINARY_OP argrepr='[]' start 1 handed 'inverter-a': misdirected=0 ok=True marker=1 ``` `sha256` differed (`6a54…` vs `94f4…`) at identical byte counts, the disassembly shows the interpreter executing `&` rather than `|`, and the call returns a different answer. TEST-15 cannot be hiding in this row. --- ## WHAT ELSE CHANGED * `DECISIONS/MAINT-05-per-pack-maintenance.md` §2 — the false paragraph is corrected in place, with the measured table, why grading `moved` alone could not hold, and why the union is sound. §7 gains the marker-gate hole found in `migrate_unit_keys()` and the note that it was **closed** here. * `MUTATION.md` — the MAINT-05 entry gains the sub-finding *"and the fix itself carried the defect"*; a new **habit 9** — *exercise a refusal a SECOND time with the SAME wrong input, not the corrected one* — and two survey rows. * `CHANGELOG.md` — two bullets for the second fix round, one for the found-and-closed latch-gate survivor. ## Not done, deliberately * Nothing touched the rig, the ESP32, the inverter, the witness Pi or the live add-on. Every number above is from `goodwe-addon-ci` on a copied tree. * **Inverter B does not physically exist.** Every two-pack fixture is simulated and labelled as such; `B_RATING_HYPOTHESIS = 3000 W` remains a marked hypothesis. * Not merged, and `release/1.0` was not pushed to.
Author
Owner

CI on 905b6fd: red on the first attempt, green on the re-run, and the red is CI-06 — reproduced on code that predates this commit

checks / unit (pull_request) — run 614, attempt 1: FAILURE (test_peakguard.py, 5 checks).
Attempt 2 (re-run, no push): SUCCESS in 5m14s, 19/19 PASS, on
GRADED: refs/pull/47/head merged into release/1.0 → result 905b6fd2b3b26bbfb993634ecf7f87daced5e457.

PASS  test_alloc.py (25s)   PASS  test_arbiter.py (0s)    PASS  test_arbitrage.py (6s)
PASS  test_bytecode.py (1s) PASS  test_ci_gate.py (1s)    PASS  test_clamp.py (1s)
PASS  test_control.py (0s)  PASS  test_freeze.py (34s)    PASS  test_inverter.py (6s)
PASS  test_loop.py (94s)    PASS  test_maintenance.py(0s) PASS  test_p1.py (8s)
PASS  test_peakguard.py(81s)PASS  test_price.py (1s)      PASS  test_price_forecast.py (0s)
PASS  test_static_tariff.py (4s)  PASS  test_tariff.py (11s)
PASS  test_validate.py (5s) PASS  test_watchdog.py (30s)

I did not attribute the red to flakiness by assertion. Three steps, in order:

1. The clock. Attempt 1 started test_peakguard.py at 17:44:05Z and it failed at 17:45:22Z
— it ran straight through the 17:45:00Z quarter boundary. wired_over_limit() derives its load
from remaining in the current quarter (excess = 900 × (projected − ceiling) ÷ remaining) and its
own comment says a boundary crossed between the last telegram and cycle()'s clock is what its
bounded retry exists for.

2. Isolation — 3 × PASS on this commit's tree, purged, :ro, python3 -B:

== bytecode cache entries: BEFORE=0 AFTER=0
--- isolation run 1 at 17:47:28Z ---   PASS test_peakguard.py
--- isolation run 2 at 17:48:26Z ---   PASS test_peakguard.py
--- isolation run 3 at 17:49:17Z ---   PASS test_peakguard.py

3. The decisive one — reproduced on 65cddf3, i.e. WITHOUT this commit. A tree checked out at
the previous head (git archive 65cddf3, app/store.py = 29601 B — the pre-fix file), started
so its wall-clock section straddles the next boundary:

BASE-65cddf3: next quarter boundary 18:00:00Z; starting at 17:58:45Z
BASE-65cddf3: bytecode cache entries BEFORE=0 AFTER=0; store.py 29601 bytes
BASE-65cddf3: launching at 17:58:45Z
BASE-65cddf3: FAIL  (finished 18:00:06Z)
  FAIL one cycle of the intent form is exactly the delivered form plus the command standing on the wire ...
  FAIL the REAL Controller reaches the floor-limited branch, so main.py hands decide() a state of charge at all
  FAIL ...and the number written to the inverter is not a forced discharge - the pack is not spent for money below the floor
  FAIL ...and the operator is TOLD THE PEAK WILL BE EXCEEDED, exactly once, with its own notification id - AC 2 ...
  FAIL ...once per episode, not once per cycle ...
  FAIL the status page carries exactly one peak-guard row and it is NOT ok ...
  FAIL ...and it reaches the action log too
7 FAILED

test_peakguard.py fails at a quarter boundary on the branch head this PR was rejected at, with
none of this round's changes present. The failure is CI-06, it is a wall-clock condition, and it is
not reachable from anything in this commit: the only source file touched is app/store.py's
maintenance-migration postcondition, and no fixture in test_peakguard.py hands
migrate_maint_keys() an off-roster unit or a store that drops writes.

⚠️ Recorded, not fixed. Making wired_over_limit() boundary-proof means giving the peak guard an
injectable clock, which is SAFETY-07/SAFETY-24 territory and its own mutation battery — not a
maintenance-migration card. It is filed here as a reproduction rather than a shrug, so the next
person to see this red has the recipe: start test_peakguard.py about 75 s before a quarter
boundary.

### CI on `905b6fd`: red on the first attempt, **green on the re-run**, and the red is CI-06 — reproduced on code that predates this commit `checks / unit (pull_request)` — **run 614, attempt 1: FAILURE** (`test_peakguard.py`, 5 checks). **Attempt 2 (re-run, no push): SUCCESS in 5m14s, 19/19 PASS**, on `GRADED: refs/pull/47/head merged into release/1.0` → `result 905b6fd2b3b26bbfb993634ecf7f87daced5e457`. ``` PASS test_alloc.py (25s) PASS test_arbiter.py (0s) PASS test_arbitrage.py (6s) PASS test_bytecode.py (1s) PASS test_ci_gate.py (1s) PASS test_clamp.py (1s) PASS test_control.py (0s) PASS test_freeze.py (34s) PASS test_inverter.py (6s) PASS test_loop.py (94s) PASS test_maintenance.py(0s) PASS test_p1.py (8s) PASS test_peakguard.py(81s)PASS test_price.py (1s) PASS test_price_forecast.py (0s) PASS test_static_tariff.py (4s) PASS test_tariff.py (11s) PASS test_validate.py (5s) PASS test_watchdog.py (30s) ``` **I did not attribute the red to flakiness by assertion.** Three steps, in order: **1. The clock.** Attempt 1 started `test_peakguard.py` at **17:44:05Z** and it failed at **17:45:22Z** — it ran straight through the **17:45:00Z quarter boundary**. `wired_over_limit()` derives its load from `remaining` in the current quarter (`excess = 900 × (projected − ceiling) ÷ remaining`) and its own comment says a boundary crossed between the last telegram and `cycle()`'s clock is what its bounded retry exists for. **2. Isolation — 3 × PASS on this commit's tree**, purged, `:ro`, `python3 -B`: ``` == bytecode cache entries: BEFORE=0 AFTER=0 --- isolation run 1 at 17:47:28Z --- PASS test_peakguard.py --- isolation run 2 at 17:48:26Z --- PASS test_peakguard.py --- isolation run 3 at 17:49:17Z --- PASS test_peakguard.py ``` **3. The decisive one — reproduced on `65cddf3`, i.e. WITHOUT this commit.** A tree checked out at the **previous** head (`git archive 65cddf3`, `app/store.py` = 29601 B — the pre-fix file), started so its wall-clock section straddles the next boundary: ``` BASE-65cddf3: next quarter boundary 18:00:00Z; starting at 17:58:45Z BASE-65cddf3: bytecode cache entries BEFORE=0 AFTER=0; store.py 29601 bytes BASE-65cddf3: launching at 17:58:45Z BASE-65cddf3: FAIL (finished 18:00:06Z) FAIL one cycle of the intent form is exactly the delivered form plus the command standing on the wire ... FAIL the REAL Controller reaches the floor-limited branch, so main.py hands decide() a state of charge at all FAIL ...and the number written to the inverter is not a forced discharge - the pack is not spent for money below the floor FAIL ...and the operator is TOLD THE PEAK WILL BE EXCEEDED, exactly once, with its own notification id - AC 2 ... FAIL ...once per episode, not once per cycle ... FAIL the status page carries exactly one peak-guard row and it is NOT ok ... FAIL ...and it reaches the action log too 7 FAILED ``` `test_peakguard.py` fails at a quarter boundary **on the branch head this PR was rejected at**, with none of this round's changes present. The failure is CI-06, it is a wall-clock condition, and it is not reachable from anything in this commit: the only source file touched is `app/store.py`'s maintenance-migration postcondition, and no fixture in `test_peakguard.py` hands `migrate_maint_keys()` an off-roster unit or a store that drops writes. ⚠️ **Recorded, not fixed.** Making `wired_over_limit()` boundary-proof means giving the peak guard an injectable clock, which is SAFETY-07/SAFETY-24 territory and its own mutation battery — not a maintenance-migration card. It is filed here as a reproduction rather than a shrug, so the next person to see this red has the recipe: start `test_peakguard.py` about **75 s before** a quarter boundary.
admin merged commit 3934ad7ea5 into release/1.0 2026-09-04 21:39:26 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#47