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
+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: