MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
checks / unit (pull_request) Successful in 4m47s
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
1aef19e995
commit
65cddf35f1
@@ -46,6 +46,35 @@ two power-moving phases on one grid connection pulling against each other.
|
||||
assumed: the state file keeps the flat keys and gains none, the migration does
|
||||
not run, the four cycle events are the shipped strings character for
|
||||
character, and the status banner is the bare phase with no pack name.
|
||||
* **THE MIGRATION IS GRADED AGAINST THE ROSTER, NOT AGAINST THE NAME IT WAS
|
||||
HANDED** (fix round). `migrate_maint_keys()` takes the roster rather than a
|
||||
pack count and `_verify_moves()` checks the namespace it wrote against the
|
||||
names the machines actually read. A postcondition that reads back the
|
||||
namespace it was given cannot detect being given the wrong one: handed
|
||||
`inverter.UNIT_A` on a roster of `["garage", "shed"]`, the first version of
|
||||
this card wrote all four keys under `unit/inverter-a/...`, read all four back
|
||||
equal, reported **verified**, and stamped `maint_schema_version` — so both
|
||||
packs came up believing they had never been recalibrated and the next start
|
||||
skipped the migration too. That is now `misdirected`: not `ok`, the marker is
|
||||
**not** written, and the next start re-runs and recovers (rule 1 makes the
|
||||
re-run a no-op). `build_roster()` numbers a nameless roster entry
|
||||
`inverter-1`, so `inverter-a` is not even the fallback for a declared roster.
|
||||
Every two-pack fixture that could see this now names its first pack
|
||||
`garage`; with them all called `inverter-a`, `self.inverters[0].name` and
|
||||
`UNIT_A` were the same string and the swap left all 19 suites green.
|
||||
* ⚠️ **FOUND, RECORDED, NOT FIXED (SECOND ONE).** `migrate_unit_keys()` —
|
||||
SAFETY-02's latch migration — has the identical hole and it costs a **guard**
|
||||
rather than a history. It runs before `build_roster()` (each `Inverter`
|
||||
restores its latch in its constructor), so it is handed the constant `UNIT_A`
|
||||
and there is 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 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. Fixing it means
|
||||
naming the migration's target from the config before any `Inverter` exists,
|
||||
which is `inverter.py` and SAFETY-02's semantics, with its own battery.
|
||||
Measured and pinned as a running check (`test_maintenance.py` part 13), whose
|
||||
assertions state the WRONG behaviour on purpose.
|
||||
* ⚠️ **FOUND, RECORDED, NOT FIXED.** A maintenance drain **pins** its pack while
|
||||
the loop's target is a **site** claim, so when the site target is below the
|
||||
pin the only split asks the sibling to charge against the drain and
|
||||
|
||||
Reference in New Issue
Block a user