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
18 KiB
MAINT-05 — one recalibration per pack, one at a time, and what the card left open
Status: DECIDED 2026-09-04. There is one Maintenance per roster entry,
each with its own schedule, phase and history. The site holds exactly one
maintenance slot: at most one pack may be in a power-moving phase, the slot is
derived from the packs' own stored phases rather than persisted separately, and
when several packs are due in the same hour it goes to the one that has gone
longest without a completed cycle. The schedule is not staggered by
construction — the interlock defers, and the stagger emerges from it.
Fix round 2026-09-04. The store migration is now graded against the roster rather than against the unit name it was handed — see §2's "The read-back postcondition is graded against the ROSTER" — and the same hole in SAFETY-02's latch migration is recorded in §7.
Filed with MAINT-05. Card 6a9a9e8503872905f298d80a.
0. What the card asked to be decided, and the answers
| the card's open question | the decision |
|---|---|
| §2 the arbitration | one pack at a time; the other stays under the loop. Nothing preempts a cycle in flight. |
| §2 who wins when several are due | the longest-waiting pack, last_completed oldest first, inf for never-recalibrated. Roster order breaks a numeric tie and nothing else. |
| §4 staggering | not by construction. The interlock defers the second pack; the stagger is what comes out of it. §3 below. |
AC 7 rule 6's maint_unit comment |
no, the first entry's cap is no longer right. It grades against the weakest cap on the roster now, like rule 5. §4 below. |
| the store layout at N=1 | flat, unchanged. The namespace appears the day the roster grows. §2 below. |
1. Why the slot is derived and not persisted
The holder is "whichever pack's stored phase is not idle" — a fact already on
disk, written by the packs themselves in the per-pack history this card
introduces. A maint_slot_holder key would be a second opinion about the same
thing, and the two would disagree the first time the add-on was killed between
writing the phase and writing the holder. Derived, a controller that comes back
up mid-drain reads the drain and re-derives the holder from it; there is no
recovery path to get wrong because there is no second fact.
CHARGE is in POWER_MOVING even though the control loop owns the setpoint
in that phase, and that is the decision most likely to be "simplified" later.
Pack A charging from the grid at 2000 W while pack B drains 2000 W to export is
4 kW going round in a circle with the meter reading zero and both packs
believing they are behaving — the circulating power SAFETY-31 exists to
detect, arriving as something this module can simply not do. HOLD is in the
tuple for the other half of the sentence: it moves nothing, and it owns the
actuator, so a second cycle starting under it would still be two
recalibrations at once.
2. Why the store layout at a roster of one does not change
The maintenance keys are flat — phase, phase_started, last_completed,
last_start_attempt — on every install since the add-on shipped. At one pack
there is nothing for a namespace to distinguish that pack from: §6.2's
identity, the same one arbiter.actuator_for() leans on when it routes every
claim to the site actuator at N=1. So Maintenance is constructed with
unit=None there, reads and writes the flat keys directly, and
migrate_maint_keys() returns without writing anything. A one-inverter state
file comes out of this card byte-identical, which is AC 6 and which
test_maintenance.py part 10 asserts by driving a whole cycle and comparing the
key set.
The migration has its own marker, and that is not tidiness
SCHEMA_VERSION / schema_version is DEV-02's, and it is monotone: every
install runs that migration exactly once. This one runs only where the roster
has more than one pack, so a single counter could not express "migrated on
this site, deliberately not migrated on that one" without lying to the next
version that reads it — a one-inverter store would have to be pinned at the old
number forever, and a genuine future v2 migration could then never be told apart
from a site that simply never grew. maint_schema_version answers one question
and answers it honestly.
The three rules are shared in code, not restated: _move_flat_keys() is
rule 1 (never overwrite an existing namespaced value) and rule 2 (the flat keys
stay in the file), _verify_moves() is the read-back postcondition, and the
marker is written last. A second transcription of a migration is a second
opinion about what "it landed" means.
The read-back postcondition is graded against the ROSTER (fix round)
A postcondition that reads back the namespace it was handed cannot detect being handed the wrong one. That sentence is the whole finding, and this card shipped it broken.
_verify_moves() iterated rep.moved — the keys it had just written — and
asked whether each read back equal. On a store where the write lands, it always
does. So a migration handed a unit that is on no roster wrote four keys, read
four keys back, set verified = True, and the caller stamped
maint_schema_version. Every symptom of a clean migration, and an install's
whole maintenance history parked under a name nothing will ever look up. And
because the marker was stamped, the next start skipped the migration too —
which is precisely the state store.py's header says must never happen: a
failed migration a later version treats as done.
Reproduced on the reviewer's roster, a house with a garage pack and a shed pack,
carrying a flat last_completed of 2026-08-07:
unit the migration was handed |
key written | pack 0 reads back | due now? | |
|---|---|---|---|---|
| correct | self.inverters[0].name = garage |
unit/garage/last_completed |
2026-08-07 |
False |
| wrong | inverter.UNIT_A = inverter-a |
unit/inverter-a/last_completed |
None |
True |
build_roster() names a declared roster's entries from the config, and its
fallback for a nameless entry is a positional inverter-1 — so inverter-a
is not the name of anything on a declared roster, not even by default.
The remedy, and why this one. migrate_maint_keys() now 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 passed beside the names is a
second opinion about how many packs there are). _verify_moves() grades the
namespace it wrote against those names, and a unit that is not among them is
reported as misdirected — a different failure from lost:
lostis the store took the write and dropped it — a storage fault, and it has a harmless-default case (a write oflast_completed: Noneloses nothing, because "absent" and "never recalibrated" are the same statement).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.
And ok gates the marker, so a misdirected migration is not stamped and the
next start runs it again. That is the decision the fix round was asked to
record, and the argument for it 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. test_maintenance.py 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.
Why the fixtures had to change, and this is the part that got the card
rejected. Nothing in the repository could see whether any of the above was
true, because inverter.UNIT_A is the string "inverter-a" and every
two-pack fixture in test_maintenance.py named its first roster entry
inverter-a. self.inverters[0].name and UNIT_A were therefore the same
value in every check that existed, and swapping one for the other at the call
site left all 19 suites green. MUTATION.md's third defect exactly — a check
whose data cannot reach the failure it names — and the fifth consecutive card
to carry it. Parts 9 and 11 now run on ["garage", "shed"], each behind a
precondition check asserting that the first name is not UNIT_A, so an edit
that makes them equal again goes red instead of making the claims vacuous.
And the flat keys are kept LIVE, which is the half DEV-02 had to add later
Rule 2 originally said the flat keys are left in place and go stale. DEV-02
amended it, for the sign latch, to "left in place is not enough" — and the
same sentence holds for history. A fossil dated the day the second inverter was
declared would hand a rolled-back single-FSM add-on a last_completed months
old (it recalibrates a pack that does not need it) or a stale phase with a
phase_started old enough to abort on the 12 h timeout the moment it comes up.
So UnitHistory mirrors the first pack's writes back onto the flat keys for
as long as the roster has more than one entry. Only the first pack: the flat
spelling means inverters[0] on every install that has one, and a mirror from
pack B would overwrite pack A's rollback view with pack B's phase.
3. Staggering: the interlock defers, and the stagger is the residue
Two packs on one monthly schedule are due in the same hour every month. The
options were a stagger by construction — offset each pack's first cycle by
interval_days / N — or deferral. Deferral, for three reasons:
- A constructed stagger cannot remove the need for the interlock. A forced start, a commissioning cycle, an abort-and-retry, or a pack added mid-month desynchronises any static offset immediately, so the interlock has to be there anyway — and then the stagger is a second mechanism that must also be correct, for a case the first one already handles.
- A deferral is observable and a stagger is not. The deferred pack says, in the action log and on the status page, that it is waiting, which pack it is waiting for, and that it will run at the next start hour. A silent offset moves an operator's configured start day and explains nothing.
- The stagger comes out of the deferral for free and then persists. Pack A completes on day 1 and is next due on day 29; pack B, deferred, completes on day 2 and is next due on day 30. One contested month separates them by one cycle length and they stay separated.
Starvation was the risk and it is closed at two points. A deferral does
not stamp last_start_attempt — that key is the post-abort cooldown, and at
90 minutes inside a 60-minute start hour, stamping it would lock the deferred
pack out of exactly the hour it is waiting for. And the winner is the
longest-waiting pack rather than the first in the roster, so a pack that keeps
aborting cannot monopolise the slot month after month while its sibling drifts —
which is this card's own defect wearing a different costume.
The deferral must not read as a fault. §12's 12 h and 36 h phase timeouts
mean "the battery is not following commands". A deferred pack stays IDLE
with phase_started untouched, so no timeout is reachable by waiting, and the
line it logs says WAITING, names the pack it waits for and says it will run
next — none of which any failure path in maintenance.py says.
4. Rule 6 (the card calls it rule 8): the first entry's cap is no longer right
validate.py's rule 6 comment said, in as many words, that
maintenance_charge_w was graded against inverters[0]'s cap because there was
exactly one Maintenance and it could only ever recalibrate the first pack —
and that taking min() would be an over-refusal, because it would refuse "a
5000 W pack being recalibrated beside a 2000 W sibling the cycle never
touches". It closed: "when maint_unit stops being inverters[0], this line
moves with it."
It has stopped. Every pack is recalibrated on its own schedule, so the sibling the cycle never touches does not exist and the over-refusal argument has no subject. The shaping claim is filed on whichever pack is charging, so the cap that can contradict it is any cap on the roster and the one that contradicts it first is the smallest.
This refuses configurations that used to start, and that is the point rather
than a side effect: max_w 5000 on pack A beside 2000 on pack B with
maintenance_charge_w 3000 was correct while only pack A was ever recalibrated,
and is now a cheap-window charge asking the arbiter for hi = -3000 against a
supervised lo = -2000 — lo > hi, arbiter clause 3, 0 W, on the pack nobody
was watching. Refused when it is written, which is rule 6's whole job. At a
roster of one min() over one cap is that cap: same comparison, same message,
same boundary.
5. The claim stays per-unit, and nothing here files a site claim
arbiter.CLAIM_SCOPE has classified maintenance as SCOPE_UNIT since it was
written. Two FSMs mean two claims on two actuators, which the allocator consumes
as per-unit intervals. ARCH-01 is explicit that this must never become a
site claim, because per-inverter constraints filed site-wide are the
cross-inverter lo > hi contradiction at arbiter.py:114. The only line this
card changed there is which roster entry the claim is filed on: the one whose
state machine produced the result, rather than inverters[0].
6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
Measured while wiring this card, and pre-existing on release/1.0 at
2d025ee — the same drain filed on inverters[0] produces the identical
refusal with the two unit names swapped.
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 than that, 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 refusal is
correct and fails safe: the whole site goes to 0 W with an ARBITER: REFUSED
line in the action log.
The consequence an operator would see is a misdiagnosis. The pack is held at 0 W while its state machine believes it is draining, so after 12 h the DRAIN timeout fires and reports "drain stuck" — §12's words for a battery that is not following commands. The battery is fine; the site refused to allocate.
Why it is not fixed here. The fix is to change what the site loop's target
means while a unit is pinned — regulate the meter using only the unpinned
packs — which is loop and allocator territory and changes the integrator's
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
pinned as a running check (test_maintenance.py part 12) rather than left as a
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
its own.
7. FOUND AND NOT FIXED: migrate_unit_keys() has the same hole, and it is a LATCH
Found while fixing §2's instance. It is the same defect one module over and it costs a disarmed safety guard rather than a lost history.
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 name yet 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 on that site reads.
And the guard that exists for exactly this is defeated by the key the defect
wrote. _grade_store_migration() re-arms the latch when the flat key says
tripped and nothing namespaced is latched. The misdirected write is
namespaced and is latched, so namespaced_latched is True, the guard
returns, and no inverter on the roster comes up tripped — on an install whose
/data/state.json still reads sign_tripped: true. That is DEV-02's own
sentence for the worst case: the state file says protected, the banner is green,
and the controller resumes commanding into the fault that tripped it.
Measured, on the same ["garage", "shed"] roster:
pre-DEV-02 flat latch + declared roster ['garage', 'shed'] ->
migrated 2 flat key(s): 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)]
the same store on the FLAT config -> [('inverter-a', True)]
The last line is the counterfactual: on the flat shape, where UNIT_A really is
the roster's only name, the same store migrates and comes up latched. So this is
a defect of the declared roster, not of the migration as such.
Why it is 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 mutation battery
over test_inverter.py and test_watchdog.py. MAINT-05 moves the maintenance
history and nothing about the sign latch, and a silent expansion into a safety
latch is how a rejected PR becomes a broken one.
It is pinned as a running check (test_maintenance.py part 13) rather than left
as a comment. ⚠️ 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.