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).
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:
"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.pyandtest_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.socsurvived, 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.
## 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
§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
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
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:
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])
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.
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.
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
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 bothrep.moved and rep.kept is a unit_key(unit, legacy) built from the sameunit 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.py9c
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 wrongunit. 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.pymigrate_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):
== 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.py9d is the fixture that separates the two guards: kept non-empty (so the
mutant's or fires) andlost 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.
⚠️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 andkept 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.
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.
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.02026-09-04 21:39:26 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What changed
There was exactly one
Maintenancestate machine in this add-on and oneun-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-680was written by the card that deferred thiswork, and what it deferred — "a second FSM and a namespaced history of its
own" — is what this card builds.
One
Maintenanceper roster entry. Each has its own schedule, phase andhistory under
unit/<name>/….maintenance.UnitHistoryis the one place anunqualified key name (
"phase") becomes the key that pack owns, so a raw writecannot address a sibling's history.
store.migrate_maint_keys(), and it is not a second copy of the firstmigration.
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 itsread-back postcondition is
_verify_moves(); both migrations call the same twofunctions, 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:
UnitHistorymirrors the first pack's writes back ontothe 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 thathas one.
One maintenance slot per site (
maintenance.arbitrate()). At most one packin a power-moving phase.
POWER_MOVINGis(drain, charge, hold):chargeisin 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, whichthe allocator consumes as per-unit intervals, never a site claim.
validate.pyrule 6 (the card calls it rule 8 — see scope notes below)now grades
maintenance_charge_wagainst the weakest cap on the roster.The judgement calls the card left open
Recorded in
goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.mdandsummarised 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_completedoldest;inffornever-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 foreverwhile 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]'scap because there was one
Maintenanceandmin()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_unitstops beinginverters[0], thisline moves with it." It has stopped; the sibling the cycle never touches does
not exist.
max_w5000 beside 2000 withmaintenance_charge_w3000 was correctbefore and is now a cheap-window charge asking for
hi = -3000against asupervised
lo = -2000—lo > hi, arbiter clause 3, 0 W, on the pack nobodywas 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 starthour 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_startedstays empty and neither the 12 hdrainnor the 36 hchargetimeout (§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-ciimage against the treersynced 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.pyCI-06 flake check; it passed both times and was not implicated in anything
here).
Mutation testing
Every mutation was applied to a pristine rsync of this branch and graded
through
tools/mutation_run.sh, which purges__pycache__and*.pycbefore running and fails if a cache appears during the run —
python3 -Baloneis not sufficient (TEST-15). Tree graded:
POWER_MOVING = (DRAIN, HOLD)— dropchargetick()'s start gateif blocked_by:→if False:arbitrate()clause 2 winner → roster orderlast_start_attemptlast_start_attempt"UnitHistory.key()ignores the namespacemirror_flaton every pack, not just the firstmigrate_maint_keys()refuses to run at all on a one-pack roster"self.inverters[0](the shipped expression)self.inverters[0].soccycle()dropsblocked_by=— interlock computed, not wiredtick()'s two-packs-busy recovery branch removedcaps_ix[0]test_validate.pyandtest_inverter.pyforce_start()'s interlock guard removednext_due→ the first pack's instead of the earliestmaint_indexalways 0One equivalent mutant, reported rather than hidden. Replacing
inv.socwithself.maint_unit.socsurvived, and it should:maint_unitnow tracks thepack holding the slot,
arbitrate()allows at most one pack out ofidle, andsocis only read by a non-idle phase — so the two expressions denote the samereading. M11 above is the non-equivalent form (
inverters[0].soc, which is whatthe shipped code meant) and it is red. The same is true of
maint_unitin placeof
invin the claim; M10 usesinverters[0]for the same reason.Fixtures: designed against the recurring defect
Every two-pack fixture gives the packs different phases and different
last_completedtimes, because the bug being hunted is shared state and twoequal values cannot show it —
MUTATION.mdhabit 4, and the amendment SAFETY-30and CFG-04 each took.
chargeat 40 days, pack Bidleat 33 — so "A completed"and "B completed" are distinguishable statements, and A completing is
observed not to stamp B.
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.
same tick, opposite answers, which two equal SoCs could not produce.
them, the one arrangement where the old answer and the new one disagree.
N=1 is asserted, not assumed
unit/…maintenance key, nomaint_schema_version, and the four flat keyscarry the history;
migrate_maint_keys()returns without writing on a one-pack roster;Maintenance: drainwith no pack name, and the phase rowis the bare phase;
self.maintismaints[0],maint_unitisinverters[0]and the operatorbutton targets
maints[0];arbitrate()over one machine returns[""], so no reason string it can buildis reachable.
Scope notes
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 withmaint_unit. I revisited rule 6, which is the one whose comment namesmaint_unitand promises to move with it. Flagging rather than silently reinterpreting.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 onrelease/1.0at2d025ee— measured, with the same drain filed oninverters[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.pypart 12) rather than left as a comment, so it cannot be forgotten or tuned away, and it wants a card of its own.drain), and otherwise the longest-waiting pack — the same packarbitrate()would have picked. A per-unit button is a UI card:web.pyposts a baremaint_startwith no unit, and inventing a unit parameter here would ship half a surface.force_start()'s interlock guard is not reachable frommain.py's button (it retargets rather than refusing), and it is there anyway becauseprice_forecast.begin_phase()callsforce_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).🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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_01BwRLw7eKYj2byVdwB6kbjUMAINT-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
rsyncof the working tree to a plain path with__pycache__and.gitexcluded, then bind-mounted:rointogoodwe-addon-ci— the same structural immunity.gitea/workflows/checks.ymlhas, 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):⚠️ Worth recording: the host-side purge of that tree silently removed nothing — the
.pycfiles are root-owned by the container, andfind -exec rmfailed.tools/mutation_run.shexits 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 Bcase — and the mutated line is shown on disk before the run.1. The survivor, reproduced on the rejected head
1aef19eapp/main.py:282-283,self.inverters[0].name→UNIT_A. Byte delta228319 → 228303 = -16 B. On disk before the run:Full 19-file suite on that mutant:
19/19 PASS. Confirmed exactly as reported.
2. …and the reviewer's
["garage","shed"]repro on that same pair of treesA real
Controlleragainst a stub HA — simulated, no rig, no device; pack B's 3000 W is the marked hypothesis — with a declared roster and a flatlast_completedof2026-08-07:Note the two lines that matter as much as the loss:
.ok / .verifiedisTrue / Trueand 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 asmisdirected. That is a deliberately different failure fromlost:lostis the store took the write and dropped it — a storage fault, and it has a harmless-default case (a droppedlast_completed: Noneloses nothing; "absent" and "never recalibrated" are the same statement, which is whylostmay shout);misdirectedis 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 failsokon its own.(b)
okgates 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 ofn_units. The count waslen(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 notUNIT_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 tonowrather than the literal2026-08-07: an absolute date drifts towardinterval_daysas 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 delta229275 → 229259 = -16 B. On disk before the run:Verbatim, the checks that went red (part 11):
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
The wrong call site still puts the data in the wrong place — it is a wrong call site — but it is now loud (
ok/verifiedbothFalse, 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:19/19 PASS.
7. Mutation ledger — six, all red, none at
+0 BEach on its own freshly-rsynced tree, 0 bytecode entries before the run,
:ro.app/main.pyself.inverters[0].name→UNIT_Aapp/store.py_verify_moves(store, rep, roster=names)→_verify_moves(store, rep)app/store.pyrep.misdirected.append(note)app/store.pyok: dropand not self.misdirectedtest_maintenance.pygarage→inverter-aapp/store.pyparts[1]→parts[-1](grade the key name, not the unit)M5 is the one I would ask a reviewer to look at. Renaming the fixture's first pack back to
inverter-atakes the precondition check red by name — "the first pack on this section's roster is NOTUNIT_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
KeyErroron 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 LATCHFound 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__callsmigrate_unit_keys(store, UNIT_A)— a hardcoded constant where the maintenance migration now passes the roster's own first name. It has to run beforebuild_roster(), because eachInverterrestores its sign latch in its constructor, so there is no roster to grade against and_verify_moves()is passedroster=None. On a declared roster a pre-DEV-02sign_tripped: truetherefore lands underunit/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, sonamespaced_latchedisTrue, it returns, and no inverter on the roster comes up tripped. Measured: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
Inverterexists, which means factoringbuild_roster()'s naming rule out ofbuild_roster()so there is still only one spelling of it —inverter.pyand SAFETY-02's latch semantics, with its own battery overtest_inverter.pyandtest_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.pypart 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.M5above 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-aappears 103 times across eight suites. Almost all of them are the flat single-inverter shape, whereUNIT_Agenuinely is the roster's only name and hardcoding it is correct —test_watchdog.py'sTRIPPED_KEY,test_inverter.py's flat-roster andmigrate_unit_keys()checks,test_peakguard.py'sc_src.units == (UNIT_A,). Those are not the trap.The instances where a roster name should have been read were:
test_maintenance.pypart 9 — every directmigrate_maint_keys()call and everyunit_key("inverter-a", …)expectation. Fixed, nowROSTER = ["garage","shed"]behind a precondition.test_maintenance.pypart 11's_c2block — the only Controller-level check of the migration, onTWO_OPTS, whose first entry isinverter-a. This is the one the mutation walked through. Fixed, nowNAMED_OPTSwith["garage","shed"]behind its own precondition, plus a direct pin that not one key is written underunit/inverter-a/.test_inverter.py's MAINT-05 diff — checked; it is about_unit_label()index travel and namesinverter-b/inverter-cagainst literals. No equality trap.TWO_OPTSitself is left namedinverter-a/inverter-bon 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.mdgains the roster-postcondition argument in §2 and the not-fixed latch finding as §7;CHANGELOG.mdcarries both./dev/ttyUSB0, no witness Pi, noriglock.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.
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 at65cddf3, 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_01BwRLw7eKYj2byVdwB6kbjUMAINT-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 "okgates themarker, 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
Sound by construction: every entry in both
rep.movedandrep.keptis aunit_key(unit, legacy)built from the sameunitargument, so gradingkeptgrades the samenamespace decision
moveddoes.migrate_unit_keys()passesroster=Noneand returns before theloop, so it is untouched.
(a) THE TWO-STARTS CONTROLLER SCENARIO
Real
Controller, constructed repeatedly against one persisted store, roster["garage", "shed"], flatlast_completed2026-08-07, with the call site still wrong on everystart (
app/main.pymutated to handUNIT_A). ⚠️ Simulated — stub HA, no rig, no device.BEFORE — head
65cddf3:AFTER — this commit:
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.py9c9b's recovery check re-ran the migration with
ROSTER[0], the corrected name: it measuredrecovery 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 wrongunit. 9c does:The precondition pins the shape of the second report (
movedempty,keptfull), so thesection cannot quietly revert to grading 9b's path.
(b) M7 GOES RED
app/store.pymigrate_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):
M7 at head
65cddf3— SURVIVOR, all 19 files:M7 on this commit — RED, on three named checks:
test_maintenance.py9d is the fixture that separates the two guards:keptnon-empty (so themutant's
orfires) andlostnon-empty (sookis False), withmisdirectedemptythroughout — 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
keptbeing non-empty.(c) THE ORIGINAL
UNIT_AMUTATION IS STILL RED(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.⚠️
BEFORE=0is a real zero, not a broken counter. The0matters — MUTATION.md's own note isthat 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:
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.pycinside the tree that run had mounted, for about 40 s. It reached
19/19 PASSand I am notciting 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()gatesschema_versiononrep.okexactly asmigrate_maint_keys()gates itsown marker, and every check that reached that gate ran on a store where nothing was namespaced
yet — so
rep.keptwas empty in all of them.What it does: stamps
schema_versionon 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 migrationfinished, 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.pypart 10, with the one shape none of the existing fixtures had — thesign_tripped: truewrite dropped beside an already-namespacedsign_reason(lostnon-emptyand
keptnon-empty), plus the accepted-baseline counterfactual (habit 7):No source change — the shipped gate was right; nothing could see it.
test_inverter.py'smutation 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
:rointogoodwe-addon-ciso no.pyccan be written during the run,
python3 -B, and the mutated file'ssha256+ byte count printedfrom the tree that was graded. Clean
app/store.py= 32582 B (6a5406e9b1b02247), cleanapp/main.py= 229275 B.if rep.ok:→if rep.ok or rep.kept:65cddf3)sorted(rep.moved)— revert the fixsorted(rep.kept)onlyif not rep.misdirected:if not rep.lost:ok→not self.lostok→not self.misdirectedself.inverters[0].name→UNIT_Arep.kept.append(target)→pass|→ intersection&migrate_unit_keys()gate →or rep.kept:⚠️ 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:sha256differed (6a54…vs94f4…) at identical byte counts, the disassembly shows theinterpreter executing
&rather than|, and the call returns a different answer. TEST-15 cannotbe hiding in this row.
WHAT ELSE CHANGED
DECISIONS/MAINT-05-per-pack-maintenance.md§2 — the false paragraph is corrected in place, withthe measured table, why grading
movedalone could not hold, and why the union is sound. §7 gainsthe 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 thedefect"; 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-gatesurvivor.
Not done, deliberately
above is from
goodwe-addon-cion a copied tree.such;
B_RATING_HYPOTHESIS = 3000 Wremains a marked hypothesis.release/1.0was not pushed to.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 commitchecks / 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.I did not attribute the red to flakiness by assertion. Three steps, in order:
1. The clock. Attempt 1 started
test_peakguard.pyat 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 loadfrom
remainingin the current quarter (excess = 900 × (projected − ceiling) ÷ remaining) and itsown comment says a boundary crossed between the last telegram and
cycle()'s clock is what itsbounded retry exists for.
2. Isolation — 3 × PASS on this commit's tree, purged,
:ro,python3 -B:3. The decisive one — reproduced on
65cddf3, i.e. WITHOUT this commit. A tree checked out atthe previous head (
git archive 65cddf3,app/store.py= 29601 B — the pre-fix file), startedso its wall-clock section straddles the next boundary:
test_peakguard.pyfails at a quarter boundary on the branch head this PR was rejected at, withnone 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'smaintenance-migration postcondition, and no fixture in
test_peakguard.pyhandsmigrate_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 aninjectable 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.pyabout 75 s before a quarterboundary.