SAFETY-44: the latch migration takes the roster, and the guard stops being defeated by the key the defect wrote
checks / unit (pull_request) Successful in 6m56s

⚠️ A DISARMED SAFETY-02 GUARD ON AN INSTALL WHOSE STATE FILE SAYS IT IS
PROTECTED. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` - a
hardcoded constant - while its sibling `migrate_maint_keys(store, unit, roster)`
takes the roster and refuses a write no machine reads. On a store that ran
pre-DEV-02 flat and declares `inverters: [garage, shed]`, the latch moved to
`unit/inverter-a/sign_tripped`, `_verify_moves()` read it back under the name it
had been HANDED, was satisfied, and stamped the marker. Not one inverter came up
latched, and the next start skipped the migration too.

That is the MAINT-05 defect verbatim, still live in the function whose own
docstring describes it, and `_grade_store_migration()` did not catch it: its
"is anything namespaced latched" test was satisfied BY THE MISDIRECTED VALUE
ITSELF.

⚠️ THE REASON IT WAS LEFT WAS A HALF-TRUTH. "There is no roster yet - the
`Inverter`s restore their latch in their constructors." The KEYS must be written
before the `Inverter`s read them; the NAMES do not come from the `Inverter`s.
`roster_names(opts)` reads them off the options before anything is constructed -
which is exactly what it already existed to do for `MqttPublisher` - and returns
`[UNIT_A]` on the flat shape, so today's upgrade path is byte-identical.

TWO EDITS, AND THEY DO DIFFERENT JOBS. Measured per leg by reverting each one:

                          SAME-STEP leg          STAGED leg
  both edits (shipped)    clean                  latch armed, litter reported
  guard reverted          latch armed            ⚠️ DISARMED
  call site reverted      latch armed, litter    latch armed, litter

So the GUARD carries the safety and the CALL SITE carries the hygiene and the
withheld marker. The table is in test_rehearse.py part 5 because a reader who
assumed the roster-aware migration re-arms the latch would delete the guard as
redundant in a tidy-up and take the STAGED column with it - and STAGED is what
"upgrade now, declare the roster later" produces, which is the path an operator
actually takes. Only the guard reaches an install already stamped by 0.9.0.

⚠️ AND "OFF THE ROSTER" ALONE WAS TOO BLUNT, WHICH test_inverter.py CAUGHT. The
first draft treated every off-roster latch as litter. In DEV-02's restart case
the store carries `unit/inverter-b/sign_tripped = True` from B's own trip while
the roster being graded holds only A - and the blunt rule re-armed a HEALTHY A
off the flat key B's trip had mirrored. DEV-02's defect rebuilt inside its own
guard, which is what the paragraph I was overruling had been right to fear.

Narrowed to `UNIT_A`, which is the only name the broken migration ever wrote -
`roster_names()` falls back to a POSITIONAL `inverter-N`, so `inverter-a` is not
a name this code hands to anyone else. A latch stranded under any other name is
a real unit out of today's config and still counts as protection. Residual,
stated in the code rather than left to be found: a site whose own first unit is
NAMED `inverter-a`, latched, then taken out of the roster gets re-armed on
`inverters[0]` - a healthy unit held at 0 W until an operator clears it. The
conservative direction, against a site DRIVING on a file that says it is latched.

⭐ AND FIXING THE CODE EXPOSED A FALSE POSITIVE IN THE HARNESS. With the latch
now landing under `garage`, `rehearse_roster.py` reported VANISHED STORE KEY for
`unit/inverter-a/sign_tripped` three lines above the same value sitting under
`unit/garage/sign_tripped`. A key that moved to a namespace a machine reads has
not vanished - and an alarm that fires on the harmless case is one an operator
learns to dismiss before the real one arrives, which is CONTROL-C's own
argument. The test is not "the name appears somewhere": same bare key, same
value, under a key in `machine_read`, the set built by driving a real start. A
latch that genuinely went missing has no such key and is still a finding, which
M3 (delete the flat rollback view) still measures red.

Test parts replaced deliberately, as both of them asked to be:
  - test_rehearse.py part 5 asserted the defect "as REQUIREMENTS so that a fix
    turns them red". It now asserts the fix, with 5b and 5c as negative controls
    - one per edit - and 5b doubles as the leg-splitter's positive control,
    since reverting the guard is the build where the two legs DISAGREE.
  - test_maintenance.py section 13 was headed "RECORDED, NOT FIXED" and said the
    fixing card "should expect them to go red and should replace them". Replaced,
    plus two cases it could not have had: the already-stamped store, and the
    `inverter-b` negative control for the narrowing above.

28/28 files pass. Not run: nothing - this is host-side only, no rig, no hardware.

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-08 15:36:37 +02:00
co-authored by Claude Opus 5
parent 46c022c926
commit 5e382f52f6
5 changed files with 372 additions and 146 deletions
+91 -48
View File
@@ -34,7 +34,7 @@ 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,
migrate_maint_keys, unit_key,
SCHEMA_KEY, SCHEMA_VERSION, migrate_maint_keys, unit_key,
)
fails = []
@@ -1411,39 +1411,33 @@ check("...while the SAME site with the house importing more than the drain "
# --------------------------------------------------------------------------- #
print()
print("13. RECORDED, NOT FIXED: SAFETY-02's migration has the same "
"wrong-namespace hole, and it is a LATCH")
print("13. FIXED (SAFETY-44): SAFETY-02's migration takes the roster too, and "
"the guard is no longer defeated by the key the defect wrote")
# ⚠️ 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.
# ⚠️ THIS SECTION USED TO BE THE SPECIFICATION OF A DEFECT. It was written while
# fixing MAINT-05's own instance, as a RUNNING check rather than a comment, with
# the instruction that "the card that fixes it should expect them to go red and
# should replace them". SAFETY-44 is that card and this is that replacement.
#
# 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.
# WHAT IT WAS. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` -
# a hardcoded constant, where the maintenance migration already passed the
# roster's own first name. On a DECLARED roster the pre-DEV-02 flat
# `sign_tripped: true` landed under `unit/inverter-a/sign_tripped`, which no
# unit on the site reads, and `_verify_moves()` read it back under the name it
# had been handed and was satisfied.
#
# 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.
# AND THE GUARD DID NOT CATCH IT, WHICH WAS THE PART WORTH PINNING.
# `_grade_store_migration()` re-arms when the flat key says tripped and NOTHING
# NAMESPACED IS LATCHED - and the misdirected write IS namespaced and IS
# latched. The guard was 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.
# ⚠️ THE REASON IT WAS LEFT - "there is no roster to name yet, because each
# `Inverter` restores its latch in its constructor" - WAS THE HALF-TRUTH THAT
# KEPT IT ALIVE. The keys must be written before the `Inverter`s read them; the
# NAMES do not come from the `Inverter`s. `roster_names(opts)` reads them off
# the options before anything is constructed, which is what it already existed
# to do for `MqttPublisher`. The factoring this section said the fix would need
# had been done a card earlier.
#
# ⚠️ 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
@@ -1457,25 +1451,74 @@ 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)
check("⭐ the flat pre-DEV-02 latch is migrated into the ROSTER'S OWN first "
"namespace and NOT into `unit/inverter-a/` - the same wrong-namespace "
"write `migrate_maint_keys()` refuses, now refused in the migration that "
"runs before the `Inverter` objects exist",
_lat_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True
and unit_key(UNIT_A, "sign_tripped") not in _lat_store.data)
check("⭐ AND AN INVERTER ON THE ROSTER COMES UP LATCHED, which is the whole "
"card: a store whose file reads `sign_tripped: true` no longer produces "
"a site that drives",
_lat.inverters[0].sign_wd.tripped
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))
check("...and the latch that came back carries the REASON, not just the "
"boolean - an operator meeting a FAILSAFE banner with no sentence under "
"it has been told the site stopped and not why",
"SIGN_DISAGREEMENT" in _lat.inverters[0].sign_wd.reason)
check("⚠️ ...and the OTHER units on the roster are NOT latched by it. The flat "
"key is one site-level boolean and re-arming every unit off it would be "
"DEV-02's defect in the opposite direction - a healthy pack parked "
"because its sibling once disagreed",
not any(i.sign_wd.tripped for i in _lat.inverters[1:]))
# ⚠️ AND THE OTHER PATH, WHICH THE MIGRATION FIX ALONE DOES NOT REACH: a store
# ALREADY STAMPED by the shipped version. `migrate_unit_keys()` returns at its
# `from_version >= SCHEMA_VERSION` check and never moves anything, so the
# stranded `unit/inverter-a/sign_tripped` just sits there. This is the STAGED
# shape - "upgrade now, declare the roster later" - and it is the one an
# operator actually produces. Only the GUARD repairs it.
_staged_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02",
unit_key(UNIT_A, "sign_tripped"): True,
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: measured pre-DEV-02",
SCHEMA_KEY: SCHEMA_VERSION})
_staged, _ = controller(NAMED_OPTS, store=_staged_store)
print(f" already-stamped store + declared roster -> ran="
f"{_staged.store_migration.ran}, came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _staged.inverters]}")
check("⚠️ PRECONDITION: the migration really does NOT run on this store - the "
"marker is already there - so anything below is the guard's doing and "
"not the migration's",
not _staged.store_migration.ran)
check("⭐ and the roster's first unit STILL comes up latched: the guard no "
"longer counts a latch stranded under `UNIT_A`, which is not on this "
"roster, as protection an operator has",
_staged.inverters[0].sign_wd.tripped)
check("...and the re-armed latch is written where a machine reads it, so the "
"NEXT start finds it without needing the guard again",
_staged_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
# ⚠️ THE NEGATIVE CONTROL FOR THAT NARROWING, because "off the roster" on its
# own was too blunt and test_inverter.py's DEV-02 restart case caught it: a
# latch stranded under a name that is NOT `UNIT_A` is a real unit out of
# today's config, and it MUST still count as protection.
_other_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: B disagreed, mirrored flat",
unit_key("inverter-b", "sign_tripped"): True,
unit_key("inverter-b", "sign_reason"): "SIGN_DISAGREEMENT: B disagreed",
SCHEMA_KEY: SCHEMA_VERSION})
_other, _ = controller(NAMED_OPTS, store=_other_store)
print(f" stranded latch under `inverter-b` -> came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _other.inverters]}")
check("⚠️ a latch stranded under a name that is not `UNIT_A` is NOT re-armed "
"onto the roster: it belongs to a unit the operator has taken out of "
"the config, the flat key is its own mirror, and re-arming a healthy "
"sibling off it is the defect DEV-02 removed",
not any(i.sign_wd.tripped for i in _other.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,