checks / unit (pull_request) Successful in 4m47s
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
300 lines
18 KiB
Markdown
300 lines
18 KiB
Markdown
# 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`:
|
|
|
|
* `lost` is *the store took the write and dropped it* — a storage fault, and it
|
|
has a harmless-default case (a write of `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 anything in it fails `ok` on 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:
|
|
|
|
1. **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.
|
|
2. **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.
|
|
3. **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.
|