MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
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:
glenn schrooyen
2026-09-04 16:18:34 +02:00
co-authored by Claude Opus 5
parent 1aef19e995
commit 65cddf35f1
6 changed files with 580 additions and 49 deletions
+29
View File
@@ -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
@@ -8,6 +8,11 @@ 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`.
---
@@ -72,6 +77,69 @@ 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
@@ -180,3 +248,52 @@ 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.
+31 -2
View File
@@ -135,12 +135,19 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. Six instances in two weeks; three
It is not hypothetical and it is not rare. Seven instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, and the sixth by review again, in the one
battery, which is the cheaper place, and one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
COMMITTED EVIDENCE ARTIFACT four documents cite.
⚠️ **AND IT HAS NOW APPEARED IN FIVE CONSECUTIVE CARDS.** The seventh instance,
MAINT-05's, is the one that says why: the defect is not carelessness about
fixtures, it is that **a check and the thing it checks were built from the same
constant**. Ask of every difference-claim 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.
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
appends**; the other five were invisible to it and would have passed however
@@ -167,6 +174,28 @@ COMMITTED EVIDENCE ARTIFACT four documents cite.
than against its behaviour. It now drives the shipped `run_control()` on a
two-inverter config, **under a deadline**, because the mutation's real symptom
is that the controller enters its loop and never returns.
* **MAINT-05** shipped `Controller.__init__` calling
`migrate_maint_keys(store, self.inverters[0].name, ...)` under a comment
saying *"it targets `self.inverters[0].name` and NOT `UNIT_A` ... writing
`UNIT_A` here would deposit an install's whole maintenance history under a
name that is not on the roster at all"*. `inverter.UNIT_A` **is** the string
`"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 value
in every check that existed, and swapping them left **all 19 files green**
while losing a real install's whole maintenance history. Two-pack fixtures are
now `["garage", "shed"]`, each behind a precondition check (habit 1) that the
first name is not `UNIT_A`.
⚠️ **AND THE POSTCONDITION HAD THE SAME SHAPE AS THE FIXTURE.**
`store._verify_moves()` read back the keys it had just written, under the
namespace it had just been handed — so it verified clean, and the caller
stamped the schema marker, on a migration nothing would ever read. *A check
that reads back the namespace it was handed cannot detect being handed the
wrong one*: it compared a thing against itself, exactly as DEV-02's fixture
compared `(-5000, 5000)` against `(-5000, 5000)`. The habit-2 fix is the same
one — grade against something the thing under test did not supply, here the
ROSTER the machines read.
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
reaching outside the suite. The probe is the committed evidence that
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
+17 -4
View File
@@ -276,11 +276,24 @@ class Controller:
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
# belonged to whichever pack the one FSM was driving, which is the first
# roster entry, and a declared `inverters:` roster names its own entries
# (`inverter.build_roster()` says so). Writing `UNIT_A` here would
# deposit an install's whole maintenance history under a name that is
# not on the roster at all.
# (`inverter.build_roster()` says so, and its fallback for a nameless
# entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the
# default there). Writing `UNIT_A` here would deposit an install's whole
# maintenance history under a name that is not on the roster at all.
#
# ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH
# IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack
# fixture in the suite named its first entry `inverter-a`, so
# `self.inverters[0].name` and `UNIT_A` were the same string in every
# check that existed and swapping them left all 19 files green - while
# on a roster of `["garage", "shed"]` it silently loses the history and
# stamps the marker, so the next start skips the migration too.
# `migrate_maint_keys()` now grades the namespace it wrote against the
# names the machines read; test_maintenance.py sections 9 and 11 drive
# a `["garage", "shed"]` roster, which is what makes the mutation red
# instead of invisible.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, len(self.inverters))
store, self.inverters[0].name, [inv.name for inv in self.inverters])
_LOG.info("maintenance history: %s", self.maint_migration.explain())
if not self.maint_migration.ok:
# Not a disarmed guard - history, not a latch - so it does not
+91 -10
View File
@@ -211,13 +211,23 @@ class MigrationReport:
# to shout: an alarm that fires on the harmless case is an alarm an
# operator learns to dismiss before the real one arrives.
self.lost = []
# ⚠️ A DIFFERENT FAILURE FROM `lost`, AND IT HAS TO BE, BECAUSE THE
# READ-BACK CANNOT SEE IT. `lost` is "the store took the write and
# dropped it". This is "the store took the write, kept it perfectly, and
# NOTHING WILL EVER READ IT" - the migration was handed a unit name that
# is not on the roster, so every machine looks under a different key and
# finds nothing. There is no harmless-default version of this the way
# there is for `lost`: a write nobody reads is not information, whatever
# its value, so anything in here fails `ok` on its own.
self.misdirected = []
# Why nothing ran, when nothing ran. Empty means "already at schema".
self.skipped = ""
@property
def ok(self) -> bool:
"""Nothing that CARRIED STATE failed to land."""
return not self.lost
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
MACHINES LOOK."""
return not self.lost and not self.misdirected
def explain(self) -> str:
if not self.ran:
@@ -238,6 +248,8 @@ class MigrationReport:
out += " - PROBLEMS: " + "; ".join(self.problems)
if self.lost:
out += " - STATE LOST: " + "; ".join(self.lost)
if self.misdirected:
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
return out
@@ -329,7 +341,7 @@ def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
rep.moved[target] = value
def _verify_moves(store, rep: MigrationReport) -> None:
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
This is the difference between these functions and a two-line copy: the
@@ -339,6 +351,34 @@ def _verify_moves(store, rep: MigrationReport) -> None:
same `unit_key()` those readers read through. A `False` here is a guard that
is off, or a month of coulomb-count history gone, on an install whose state
file says otherwise.
⚠️ AND `roster` IS THE OTHER HALF, WHICH THE READ-BACK ON ITS OWN CANNOT BE.
A POSTCONDITION THAT READS BACK THE NAMESPACE IT WAS HANDED CANNOT DETECT
BEING HANDED THE WRONG ONE. That is not a hypothetical: MAINT-05 shipped
with `migrate_maint_keys()` taking a bare `unit` string, and a call site that
passed `inverter.UNIT_A` instead of the first roster entry's name migrated an
install's whole maintenance history into `unit/inverter-a/...` on a roster of
`["garage", "shed"]`, read all four keys back under that same name, was
SATISFIED, and stamped the schema marker - so the next start skipped the
migration too and both packs came up believing they had never been
recalibrated. Every symptom of a clean migration, and the history gone.
`build_roster()` names a declared roster's entries from the config and falls
back to a POSITIONAL `inverter-N`, so `inverter-a` is not even the default
there; there is no roster on which that write could have been read.
So the namespace is graded against the one the MACHINES read - the roster -
rather than against the argument this function was handed, which is the
thing under test. A unit that is not on the roster is `misdirected`, which
fails `ok`, which means the caller does NOT stamp the version marker and the
next start runs the migration again. A re-run is recoverable, by rule 1; a
silent skip is the state store.py's header says must never happen.
`roster=None` means "not graded", and the one caller that passes it is
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
own docstring gives - the `Inverter` objects restore their latch in their
constructors, so the keys have to be in place before there is a roster to
check against. That gap is real and is recorded as a running check rather
than left to a comment: see test_maintenance.py section 13.
"""
for target, value in rep.moved.items():
got = getattr(store, "data", {}).get(target, "<<missing>>")
@@ -351,8 +391,24 @@ def _verify_moves(store, rep: MigrationReport) -> None:
if value != DEFAULTS.get(legacy):
rep.lost.append(note)
if roster is None:
return
names = [str(n) for n in roster]
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
# through the same spelling the writer used rather than re-deriving it.
for target in sorted(rep.moved):
parts = target.split("/")
unit = parts[1] if len(parts) > 2 else ""
if unit in names:
continue
rep.verified = False
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
f"on the roster {names!r} - nothing reads that key")
rep.problems.append(note)
rep.misdirected.append(note)
def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
"""Move the flat maintenance history into the FIRST pack's namespace.
MAINT-05. Until this card there was one `Maintenance` and one history, and
@@ -363,14 +419,32 @@ def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
what `Controller.maint_unit` named and what `validate.py` rule 6 graded
against.
⚠️ `n_units <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the flat
keys ARE that pack's namespace (§6.2's identity, the same one
⚠️ `len(roster) <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the
flat keys ARE that pack's namespace (§6.2's identity, the same one
`arbiter.actuator_for()` leans on), `Maintenance` is constructed to read
them directly, and a migration would add four keys plus a marker to a store
that must come out byte-identical. The migration happens on the day the
roster grows, which is the day the flat spelling stops being able to answer
"which pack".
⚠️ `roster` IS REQUIRED, AND IT REPLACED AN `n_units: int`. Two reasons, and
the second is the one MAINT-05 was rejected over.
The small one: `n_units` was `len(self.inverters)` at the only call site, so
the roster carries it - `len(roster)` - and a count passed beside the names
is a second opinion about how many packs there are.
The load-bearing one: THE NAMES ARE THE POSTCONDITION. `unit` says which
pack the flat keys belonged to and that is genuinely a decision - the first
roster entry, because that is the pack the one FSM was driving - but a
decision the caller can get WRONG, and until this signature existed nothing
could tell. `_verify_moves()` read back the namespace it was handed, so a
migration into a name no machine reads verified clean and stamped the
marker. Handing the roster in means the read-back is graded against the
keys the MACHINES read, and a `unit` that is not among these names comes
back `misdirected`: not `ok`, marker NOT written, migration runs again next
start. See `_verify_moves()` for the install this was measured on.
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
and rule 2 (the flat keys are left in the file), `_verify_moves()` is the
@@ -390,8 +464,9 @@ def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
"""
rep = MigrationReport()
data = getattr(store, "data", {})
names = [str(n) for n in roster]
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
if n_units <= 1:
if len(names) <= 1:
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
"history and are read in place, so there is nothing to "
"move and the store layout does not change")
@@ -404,13 +479,19 @@ def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
rep.ran = True
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
_verify_moves(store, rep)
_verify_moves(store, rep, roster=names)
if rep.ok:
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
else:
_LOG.error("per-pack maintenance-history migration LOST STATE and has "
"not been marked done: %s", rep.explain())
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
# `migrate_unit_keys()` carries: the one thing worse than a failed
# migration is a failed migration a later version skips. Rule 1 makes
# the re-run a no-op where the keys did land, so refusing the marker
# costs one repeated pass and buys the difference between a migration
# that can still be fixed and one the store says already happened.
_LOG.error("per-pack maintenance-history migration did NOT land and "
"has not been marked done: %s", rep.explain())
return rep
+295 -33
View File
@@ -30,6 +30,7 @@ from app.maintenance import (
CHARGE, DRAIN, HOLD, IDLE, MAINT_OPTION_DEFAULTS, POWER_MOVING, MaintConfig,
Maintenance, UnitHistory, arbitrate,
)
from app.inverter import UNIT_A
from app.main import Controller
from app.store import (
DEFAULTS, MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION, MAINT_UNIT_KEYS,
@@ -586,6 +587,30 @@ check("...and B's deferral produced no claim on the tick it was deferred: a "
print()
print("9. the store migration: no lost history, and the three rules hold (AC5)")
# ⚠️ THE ROSTER IS ["garage", "shed"], AND THE FIRST NAME IS THE WHOLE POINT OF
# THIS SECTION'S FIX ROUND. Every check here used to name its packs
# "inverter-a" / "inverter-b" - and "inverter-a" is the string
# `inverter.UNIT_A` holds, so `self.inverters[0].name` and `UNIT_A` were the
# SAME VALUE in every fixture in this file. main.py's migration call site could
# be changed from the first to the second with all 19 suites still green:
# MUTATION.md's third defect exactly, a check whose DATA cannot reach the
# failure it names.
#
# A declared `inverters:` roster names its own entries (`build_roster()`), and
# the fallback for a NAMELESS entry is a POSITIONAL `inverter-1` - so
# `inverter-a` is not the default for a declared roster either. It is a name
# nothing on this site reads, which is what makes migrating into it silent.
ROSTER = ["garage", "shed"]
check("PRECONDITION, WRITTEN AS ITS OWN CHECK. The first pack on this "
f"section's roster is NOT `UNIT_A`: {ROSTER[0]!r} != {UNIT_A!r}, and "
f"{UNIT_A!r} is not on it at all. 'the first roster entry' and "
"'inverter-a' are therefore DIFFERENT strings in every check below - and "
"if a later edit makes them equal again this goes red rather than the "
"claims going quietly vacuous",
ROSTER[0] != UNIT_A and UNIT_A not in ROSTER)
# A real pre-MAINT-05 single-pack store: a month of history under the flat keys,
# with SAFETY-02's own migration already done (schema_version 1).
def flat_store(**over):
@@ -599,20 +624,28 @@ def flat_store(**over):
_m1 = flat_store()
_rep1 = migrate_maint_keys(_m1, "inverter-a", 2)
_rep1 = migrate_maint_keys(_m1, ROSTER[0], ROSTER)
print(f" a single-pack store growing to two: {_rep1.explain()}")
print(f" keys after migration: "
f"{sorted(k for k in _m1.data if k.startswith('unit/'))}")
check("every flat maintenance key lands in the FIRST pack's namespace, because "
"the flat keys can only ever have belonged to the pack the one FSM was "
"driving",
_rep1.ran and _rep1.ok and _rep1.verified
and all(_m1.data.get(unit_key("inverter-a", k)) == _m1.data[k]
_rep1.ran and _rep1.ok and _rep1.verified and not _rep1.misdirected
and all(_m1.data.get(unit_key(ROSTER[0], k)) == _m1.data[k]
for k in MAINT_UNIT_KEYS))
check("...and the namespace is THE ROSTER'S FIRST NAME, not `UNIT_A` - which is "
"a claim this file could not make until its packs stopped being called "
"`inverter-a`. Not one key was written under `unit/inverter-a/`, on a "
"store where that is the name a wrong call site would have used",
not any(k.startswith(unit_key(UNIT_A, "")) for k in _m1.data)
and unit_key(ROSTER[0], "last_completed") in _m1.data)
check("...and the VALUES are the history, not defaults wearing its name - a "
"migration that lands `last_completed: None` has lost the month it was "
"moving and would recalibrate a pack that does not need it",
_m1.data.get(unit_key("inverter-a", "last_completed"))
_m1.data.get(unit_key(ROSTER[0], "last_completed"))
== "2026-08-07T12:00:00+00:00"
and _m1.data.get(unit_key("inverter-a", "phase")) == CHARGE)
and _m1.data.get(unit_key(ROSTER[0], "phase")) == CHARGE)
check("RULE 2: the flat keys are LEFT IN THE FILE, so an operator who rolls the "
"add-on back to a single-FSM version finds the history it had",
all(k in _m1.data for k in MAINT_UNIT_KEYS)
@@ -623,31 +656,31 @@ check("RULE 3: the marker is written and the store is now at the per-pack "
check("...and the pack that has just arrived gets NOTHING, which is the right "
"answer rather than an omission: absent means never recalibrated, means "
"due, and a brand-new pack should be",
not any(k.startswith(unit_key("inverter-b", "")) for k in _m1.data)
and Maintenance(cfg, _m1, unit="inverter-b").due(_t))
not any(k.startswith(unit_key(ROSTER[1], "")) for k in _m1.data)
and Maintenance(cfg, _m1, unit=ROSTER[1]).due(_t))
# Rule 1, and it is the rule that makes re-running safe.
_m2 = flat_store(**{unit_key("inverter-a", "last_completed"):
_m2 = flat_store(**{unit_key(ROSTER[0], "last_completed"):
"2026-09-01T09:00:00+00:00"})
_rep2 = migrate_maint_keys(_m2, "inverter-a", 2)
_rep2 = migrate_maint_keys(_m2, ROSTER[0], ROSTER)
check("RULE 1: an EXISTING namespaced value is never overwritten by the flat "
"one. A store with both spellings has been written by a version that knew "
"about the namespace, and preferring the flat one would roll a pack's "
"history back to whatever it was on migration day",
_m2.data.get(unit_key("inverter-a", "last_completed"))
_m2.data.get(unit_key(ROSTER[0], "last_completed"))
== "2026-09-01T09:00:00+00:00"
and unit_key("inverter-a", "last_completed") in _rep2.kept)
and unit_key(ROSTER[0], "last_completed") in _rep2.kept)
check("...so running the migration TWICE is a no-op, which is what makes a "
"crash between the copies and the marker safe",
migrate_maint_keys(_m1, "inverter-a", 2).ran is False)
migrate_maint_keys(_m1, ROSTER[0], ROSTER).ran is False)
# The store a multi-inverter version wrote. AC: not touched.
_m3 = MemStore({MAINT_SCHEMA_KEY: MAINT_SCHEMA_VERSION,
unit_key("inverter-a", "last_completed"): "2026-09-01T09:00:00+00:00",
unit_key("inverter-b", "last_completed"): "2026-08-20T09:00:00+00:00",
unit_key(ROSTER[0], "last_completed"): "2026-09-01T09:00:00+00:00",
unit_key(ROSTER[1], "last_completed"): "2026-08-20T09:00:00+00:00",
"last_completed": "2026-09-01T09:00:00+00:00"})
_before3 = dict(_m3.data)
_rep3 = migrate_maint_keys(_m3, "inverter-a", 2)
_rep3 = migrate_maint_keys(_m3, ROSTER[0], ROSTER)
print(f" a store already written by a multi-inverter version: {_rep3.explain()}")
check("a store already at the per-pack schema is NOT TOUCHED - not one key "
"written - and says so",
@@ -671,7 +704,7 @@ class LosesTheHistory(MemStore):
_m4 = LosesTheHistory(dict(flat_store().data))
_rep4 = migrate_maint_keys(_m4, "inverter-a", 2)
_rep4 = migrate_maint_keys(_m4, ROSTER[0], ROSTER)
print(f" a store that silently drops the migrated keys: {_rep4.explain()}")
check("⚠️ THE MIGRATION REPORTS THAT IT FAILED, because it READS BACK what it "
"wrote. A month of coulomb-count history under a key nothing reads is a "
@@ -686,7 +719,92 @@ check("...while a store whose only failed write was a DEFAULT is not reported "
"'absent' and 'never recalibrated' are the same statement - an alarm that "
"fires on the harmless case is one an operator learns to dismiss",
migrate_maint_keys(LosesTheHistory({"schema_version": 1}),
"inverter-a", 2).ok is True)
ROSTER[0], ROSTER).ok is True)
# --------------------------------------------------------------------------- #
print()
print("9b. THE OTHER HALF OF THE POSTCONDITION: a migration into a namespace "
"the machines do not read (AC5)")
# ⚠️ WHY THIS EXISTS, IN ONE SENTENCE: A POSTCONDITION THAT READS BACK THE
# NAMESPACE IT WAS HANDED CANNOT DETECT BEING HANDED THE WRONG ONE.
#
# `_verify_moves()` shipped reading back `rep.moved` - the keys it had just
# written - so a migration handed a `unit` that is on no roster wrote four keys,
# read all four back equal, 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. The
# next start then sees the marker and SKIPS the migration, which is the one
# state store.py's header says must never happen: a failed migration a later
# version treats as done.
#
# It is not a hypothetical failure and it is not a hypothetical call site.
# main.py passes `self.inverters[0].name`; `inverter.UNIT_A` is one token away,
# reads correctly, and was indistinguishable from it in every fixture in this
# file until the roster above stopped being called `inverter-a`.
_m5 = flat_store()
_rep5 = migrate_maint_keys(_m5, UNIT_A, ROSTER)
print(f" handed {UNIT_A!r} on a roster of {ROSTER}: {_rep5.explain()}")
print(f" keys written: {sorted(k for k in _m5.data if k.startswith('unit/'))}"
f"; marker {_m5.data.get(MAINT_SCHEMA_KEY, '<<absent>>')!r}")
check("⚠️ THE MIGRATION REFUSES: a namespace that is not on the roster is not a "
"namespace anything reads, so the four keys landing perfectly is not the "
"question. `ok` is False and `verified` is False even though every single "
"read-back matched, which is the difference this postcondition adds",
_rep5.ran and not _rep5.ok and not _rep5.verified and _rep5.misdirected
and all(_m5.data.get(unit_key(UNIT_A, k)) is not None
for k in MAINT_UNIT_KEYS))
check("...and it is reported as a DIFFERENT failure from a dropped write, "
"because the remedy is different: `lost` is empty and `misdirected` is "
"not. A store that dropped the write has a storage fault; this store did "
"exactly as it was told, by a caller that was wrong",
not _rep5.lost and len(_rep5.misdirected) == len(MAINT_UNIT_KEYS))
check("...and the refusal NAMES the namespace it used AND the roster it was "
"graded against, rather than merely saying something went wrong - the "
"operator's next question is 'which name, and which one should it have "
"been', and a bare `not ok` answers neither",
"WRONG NAMESPACE" in _rep5.explain()
and unit_key(UNIT_A, "last_completed") in _rep5.explain()
and repr(ROSTER) in _rep5.explain())
check("⚠️ AND THE MARKER IS NOT STAMPED, WHICH IS THE WHOLE REMEDY. A stamped "
"marker makes the next start skip a migration that never reached a "
"readable key; an unstamped one costs a repeated pass that rule 1 makes a "
"no-op. Recoverable beats silent",
MAINT_SCHEMA_KEY not in _m5.data)
_rep5b = migrate_maint_keys(_m5, ROSTER[0], ROSTER)
print(f" the same store, re-run with the roster's own first name: "
f"{_rep5b.explain()}")
check("...and 'recoverable' is measured, not asserted: the SAME store re-run "
"with the roster's own first name migrates cleanly, and the pack reads "
"back the month of history it started with. Rule 2 left the flat keys in "
"the file, which is what there was to recover FROM",
_rep5b.ran and _rep5b.ok
and _m5.data.get(unit_key(ROSTER[0], "last_completed"))
== "2026-08-07T12:00:00+00:00"
and _m5.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION)
check("...and the misdirected keys are LEFT WHERE THEY LANDED rather than "
"deleted: this function moves history, and a migration that starts "
"removing keys it did not put there is a second way to lose a month",
_m5.data.get(unit_key(UNIT_A, "last_completed"))
== "2026-08-07T12:00:00+00:00")
# The counterfactual, so this is not a check that passes on a validator that
# refuses everything: the SAME store, the SAME call, one argument different.
_m6 = flat_store()
_rep6 = migrate_maint_keys(_m6, ROSTER[0], ROSTER)
check("...and the counterfactual: one argument different - the roster's own "
"first name instead of `UNIT_A` - and the identical call is accepted and "
"stamped. So the refusal above is about the NAME, not about this fixture "
"being refused whatever it is handed",
_rep6.ran and _rep6.ok and not _rep6.misdirected
and _m6.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION)
check("...and a roster of ONE still returns before any of this, so the "
"namespace postcondition cannot reach a single-pack install: nothing is "
"moved, nothing is graded, nothing is stamped",
migrate_maint_keys(MemStore({"phase": DRAIN}), UNIT_A, [UNIT_A]).ran
is False)
# --------------------------------------------------------------------------- #
@@ -720,7 +838,8 @@ check("...and the flat keys are the ones that carried the cycle, so 'no new "
== t0 + timedelta(hours=22, minutes=5))
check("...and `migrate_maint_keys()` refuses to run at all on a one-pack "
"roster, which is why the layout above stays put",
migrate_maint_keys(MemStore({"phase": DRAIN}), "inverter-a", 1).ran is False)
migrate_maint_keys(MemStore({"phase": DRAIN}), UNIT_A, [UNIT_A]).ran
is False)
# ⚠️ THE REASON STRINGS, PINNED AS LITERALS. The card asks for byte-identical
# behaviour AND reason strings at N=1, and the only way a check can see a
@@ -1004,31 +1123,89 @@ check("...and a real cycle on it writes no MAINTENANCE key that did not exist "
and all(k in _c1store.data for k in MAINT_UNIT_KEYS)
and MAINT_SCHEMA_KEY not in _c1store.data)
# --- two inverters: A's history is namespaced AND mirrored flat -------------
# --- two inverters: the first pack's history is namespaced AND mirrored flat -
# ⚠️ THE ROSTER HERE IS ["garage", "shed"] AND THAT IS THE FIX ROUND. Run on
# `TWO_OPTS`, whose entries are named `inverter-a` / `inverter-b`, this whole
# block is satisfied by a Controller that migrates into `UNIT_A` instead of into
# `self.inverters[0].name` - the two strings are equal there, so the check could
# not reach the failure it names (MUTATION.md, third defect). On an operator's
# real roster they are not equal, and `inverter-a` is not even what a nameless
# entry falls back to (`build_roster()` numbers those positionally). The names
# below are the reviewer's: a house with a garage pack and a shed pack.
NAMED_OPTS = {k: v for k, v in TWO_OPTS.items() if k != "inverters"}
NAMED_OPTS["inverters"] = [
dict(name="garage", setpoint_entity="number.sp_a",
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
max_w=A_RATING),
# ⚠️ 3000 W IS THE MARKED HYPOTHESIS AGAIN - inverter B does not exist.
dict(name="shed", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=B_RATING_HYPOTHESIS),
]
_C2_NAMES = [e["name"] for e in NAMED_OPTS["inverters"]]
check("PRECONDITION for the migration checks below: this roster's first entry "
f"is {_C2_NAMES[0]!r}, which is NOT `UNIT_A` ({UNIT_A!r}), 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",
_C2_NAMES[0] != UNIT_A and UNIT_A not in _C2_NAMES)
# ⚠️ THE DATE IS RELATIVE TO `_now`, NOT A LITERAL. A pack's history is only
# observable through `due()` if the interval has not run out on its own: an
# absolute date drifts towards `interval_days` as this file ages and the day it
# crosses, "history preserved" and "history lost" stop being different answers -
# which is the same degenerate-fixture defect one level up.
_C2_LAST = _now - timedelta(days=3)
_c2store = MemStore({"schema_version": 1,
"last_completed": "2026-08-07T12:00:00+00:00"})
_c2, _ = controller(TWO_OPTS, store=_c2store)
print(f" two inverters -> namespaces "
"last_completed": _C2_LAST.isoformat()})
_c2, _ = controller(NAMED_OPTS, store=_c2store)
print(f" roster {_C2_NAMES} -> namespaces "
f"{[m.unit for m in _c2.maints]}, mirrors "
f"{[m.store.mirror_flat for m in _c2.maints]}")
print(f" flat last_completed {_C2_LAST:%Y-%m-%d} (3 d ago, interval "
f"{cfg.interval_days} d) -> migration: {_c2.maint_migration.explain()}")
print(f" namespaced keys now: "
f"{sorted(k for k in _c2store.data if k.startswith('unit/') and not k.endswith(('sign_tripped', 'sign_reason')))}")
check("a two-inverter Controller builds one machine per roster entry, each in "
"its own namespace, and migrates the flat history into the FIRST pack's",
[m.unit for m in _c2.maints] == ["inverter-a", "inverter-b"]
and _c2.maints[0].store.get_time("last_completed")
== datetime(2026, 8, 7, 12, 0, tzinfo=timezone.utc)
"its own namespace, and migrates the flat history into the FIRST ROSTER "
"ENTRY'S - `garage`, the name the config gave it, not a constant",
[m.unit for m in _c2.maints] == _C2_NAMES
and _c2.maints[0].store.get_time("last_completed") == _C2_LAST
and _c2.maints[1].store.get_time("last_completed") is None)
check("⚠️ 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",
not any(k.startswith(unit_key(UNIT_A, "")) for k in _c2store.data)
and _c2store.data.get(unit_key(_C2_NAMES[0], "last_completed"))
== _C2_LAST.isoformat())
check("⚠️ 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 "
f"{cfg.interval_days} 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",
_c2.maints[0].store.get_time("last_completed") is not None
and not _c2.maints[0].due(_now))
check("...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",
_c2.maint_migration.ok and _c2.maint_migration.verified
and not _c2.maint_migration.misdirected
and _c2store.data.get(MAINT_SCHEMA_KEY) == MAINT_SCHEMA_VERSION)
check("...and ONLY the first pack mirrors back to the flat keys, because the "
"flat spelling means pack A on every install that has one - a mirror from "
"B would overwrite A's rollback view with B's phase",
"flat spelling means the first roster entry on every install that has "
"one - a mirror from the second would overwrite its rollback view",
[m.store.mirror_flat for m in _c2.maints] == [True, False])
_c2.maints[0].force_start(_now)
_c2.maints[1].store.set("phase", HOLD)
check("...and the mirror is LIVE: pack A's new phase reaches the flat key, so a "
"rollback finds a current history rather than one dated migration day",
_c2store.data.get(unit_key("inverter-a", "phase")) == DRAIN
check("...and the mirror is LIVE: the first pack's new phase reaches the flat "
"key, so a rollback finds a current history rather than one dated "
"migration day",
_c2store.data.get(unit_key(_C2_NAMES[0], "phase")) == DRAIN
and _c2store.data["phase"] == DRAIN)
check("...while pack B's write reaches only pack B's namespace",
_c2store.data.get(unit_key("inverter-b", "phase")) == HOLD
check("...while the second pack's write reaches only its own namespace",
_c2store.data.get(unit_key(_C2_NAMES[1], "phase")) == HOLD
and _c2store.data["phase"] == DRAIN)
@@ -1081,6 +1258,91 @@ check("...while the SAME site with the house importing more than the drain "
"'maintenance does not work at two inverters'",
_cB.allocation.refused is None and _cB.inverters[1].target == 2000.0)
# --------------------------------------------------------------------------- #
print()
print("13. RECORDED, NOT FIXED: SAFETY-02's migration has the same "
"wrong-namespace hole, and it is a LATCH")
# ⚠️ FOUND WHILE FIXING THIS CARD'S OWN INSTANCE, AND WRITTEN DOWN AS A RUNNING
# CHECK RATHER THAN A COMMENT SO IT CANNOT BE FORGOTTEN OR QUIETLY "FIXED" BY AN
# UNRELATED EDIT. It is the SAME defect one module over, and it costs more.
#
# WHAT IT IS. `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 the pre-DEV-02 flat `sign_tripped: true` therefore lands under
# `unit/inverter-a/sign_tripped`, which no unit on this site reads.
#
# WHY THE EXISTING GUARD DOES NOT CATCH IT, WHICH IS THE PART WORTH PINNING.
# `_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. The guard is defeated by the very key the
# defect wrote.
#
# 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 IT IS A DISARMED SAFETY GUARD, NOT LOST HISTORY. Read the checks below
# as the SPECIFICATION OF A DEFECT: every one of them is asserting the WRONG
# behaviour, on purpose, and the card that fixes it should expect them to go
# red and should replace them.
#
# ⚠️ NO `schema_version` IN THIS STORE, AND THAT IS THE PRE-DEV-02 SHAPE
# ITSELF: an install that has never run a version with namespaced keys, which is
# what makes `migrate_unit_keys()` run at all.
_lat_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
_lat, _ = controller(NAMED_OPTS, store=_lat_store)
print(f" pre-DEV-02 flat latch + declared roster {_C2_NAMES} -> "
f"{_lat.store_migration.explain()}")
print(f" latch keys in the store: "
f"{sorted(k for k in _lat_store.data if k.endswith('sign_tripped'))}")
print(f" roster came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _lat.inverters]}")
check("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. This is the same "
"wrong-namespace write `migrate_maint_keys()` now refuses, in the "
"migration that runs before there is a roster to grade against",
_lat_store.data.get(unit_key(UNIT_A, "sign_tripped")) is True
and unit_key(_C2_NAMES[0], "sign_tripped") not in _lat_store.data)
check("⚠️ 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",
not any(i.sign_wd.tripped for i in _lat.inverters)
and _lat_store.data["sign_tripped"] is True)
check("...and `_grade_store_migration()` is DEFEATED BY THE KEY THE DEFECT "
"WROTE, which is why this needs a card rather than a tweak: its "
"'is anything namespaced latched' test is satisfied by the misdirected "
"value itself, so it returns instead of re-arming",
any(k.startswith("unit/") and k.endswith("/sign_tripped") and v
for k, v in _lat_store.data.items())
and not any(i.sign_wd.tripped for i in _lat.inverters))
# The counterfactual, and it is the accepted baseline habit 7 asks for: the
# same store, the same migration, one thing different - the roster shape.
_flat_lat_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
_flat_lat, _ = controller(FLAT_OPTS, store=_flat_lat_store)
print(f" the same store on the FLAT config -> roster "
f"{[(i.name, i.sign_wd.tripped) for i in _flat_lat.inverters]}")
_flat_lat_ok = (all(i.sign_wd.tripped for i in _flat_lat.inverters)
and _flat_lat_store.data.get(unit_key(UNIT_A, "sign_tripped"))
is True)
check("...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. So "
"this is a defect of the DECLARED roster, not of the migration as such, "
"and that is the counterfactual that says which half is broken",
_flat_lat_ok)
print()
print()
if fails: