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
+83 -15
View File
@@ -247,7 +247,19 @@ class Controller:
hold_s=float(opts.get("delivery_hold_s",
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
# SAFETY-44. THE NAMES THE MACHINES READ, HANDED TO THE MIGRATION THAT
# WRITES FOR THEM. This line used to read `migrate_unit_keys(store,
# UNIT_A)`, and the constant was wrong in exactly the way line 315's
# comment says the SAME constant would be wrong two hundred lines below
# it: on a declared roster the flat latch landed under
# `unit/inverter-a/`, which is on no roster, was read back under the
# name it was handed, and stamped the marker. `roster_names()` is the
# naming rule itself and returns `[UNIT_A]` on the flat shape, so the
# upgrade path every install runs today is byte-identical - and a
# declared roster now gets `misdirected`, no marker, and a retry.
_roster_names = roster_names(opts)
self.store_migration = migrate_unit_keys(
store, _roster_names[0] if _roster_names else UNIT_A, _roster_names)
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
@@ -751,23 +763,79 @@ class Controller:
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET", AND IT IS ASKED OF
# THE STORE RATHER THAN OF THE ROSTER. At a roster of two a trip on B
# leaves A untripped, so re-arming A off a flat key that B's own trip
# mirrored would latch a healthy inverter on every restart - the
# flat-key defect this card removed, coming back through its own guard.
# And the STORE is the right place to ask: it holds every
# `unit/*/sign_tripped` this install has ever written, including one
# belonging to a unit that is not in TODAY's roster (a roster is
# configuration and it changes), whereas the roster only knows what is
# configured right now. The flat key carries new information only when
# nothing namespaced is latched at all.
namespaced_latched = any(
bool(v) for k, v in data.items()
if k.startswith("unit/") and k.endswith("/sign_tripped"))
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET **THAT A MACHINE
# READS**", AND THE LAST CLAUSE IS SAFETY-44's SECOND HALF. At a roster
# of two a trip on B leaves A untripped, so re-arming A off a flat key
# that B's own trip mirrored would latch a healthy inverter on every
# restart - the flat-key defect DEV-02 removed, coming back through its
# own guard. That much stands, and it is why the question is asked of
# the STORE's keys rather than of the roster's objects.
#
# ⚠️ BUT IT USED TO ACCEPT **ANY** `unit/*/sign_tripped`, and the
# paragraph here defended that: the store "holds every one this install
# has ever written, including one belonging to a unit that is not in
# TODAY's roster ... whereas the roster only knows what is configured
# right now". THE PREMISE IS TRUE AND IT ARGUES THE WRONG WAY. A latch
# under a name no unit answers to is not protection an operator has - it
# is a key nothing reads, which is the definition of the state this
# method exists to repair. Counting it as protection made the guard
# return early on precisely the install that needed it, and that is not
# hypothetical: it is where the migration above USED to put the latch
# on every declared roster, so the guard was blinded by its own upgrade.
#
# THE MIGRATION FIX DOES NOT REACH THOSE INSTALLS. A store stamped by
# the shipped version already has the marker, so `migrate_unit_keys()`
# returns at the version check and never runs again. This guard is the
# ONLY repair path for a site that upgraded before this card, and it
# re-arms onto `inverters[0]` - which lands the latch back under a name
# the roster reads.
# ⚠️ AND "OFF THE ROSTER" IS NOT ENOUGH ON ITS OWN - IT HAS TO BE THE
# MIGRATION'S OWN FINGERPRINT, WHICH IS `UNIT_A`. The first draft of
# this treated EVERY off-roster latch as litter, and test_inverter.py's
# DEV-02 restart case caught it: there 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 that B's trip had mirrored - DEV-02's
# defect rebuilt inside its own guard, which is exactly what the
# paragraph above was right to be afraid of.
#
# `UNIT_A` is the discriminator because it 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 that is
# out of today's config - a roster is configuration and it changes -
# and it still counts as protection.
#
# ⚠️ THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a site whose
# own first unit is NAMED `inverter-a`, latched, and then taken out of
# the roster gets re-armed on `inverters[0]`. That is a healthy unit
# held at 0 W until an operator clears it, which is the conservative
# direction; SAFETY-44's is a site DRIVING on a state file that says it
# is latched.
on_roster = {inv.name for inv in self.inverters}
stranded, namespaced_latched = [], False
for k, v in data.items():
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
continue
if not bool(v):
continue
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1 -
# split through the writer's own spelling rather than re-deriving.
unit = k.split("/")[1]
if unit in on_roster or unit != UNIT_A:
namespaced_latched = True
else:
stranded.append(k)
if namespaced_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
if stranded:
_LOG.error(
"SAFETY-44: this store carries a LATCHED sign-disagreement "
"failsafe under %s, and that unit is NOT on today's roster "
"%s - so no machine reads it and the site came up DRIVING on "
"a state file that says it is latched. Re-arming below.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
+40 -8
View File
@@ -253,7 +253,7 @@ class MigrationReport:
return out
def migrate_unit_keys(store, unit: str) -> MigrationReport:
def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
`unit` is the FIRST inverter in the roster, which on every install that can
@@ -261,6 +261,24 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
version that knew about more than one inverter is already at
SCHEMA_VERSION and is not touched.
⚠️ `roster` IS THE NAMES THE MACHINES READ, AND IT IS SAFETY-44. This
function shipped WITHOUT it, taking a hardcoded `inverter.UNIT_A` at its one
call site - which is, exactly, the MAINT-05 defect `_verify_moves()` spends
a paragraph describing, still live in the sibling that paragraph was written
beside. On a store that ran pre-DEV-02 flat and declares
`inverters: [garage, shed]`, the latch moved to `unit/inverter-a/
sign_tripped`, was read back under that same name, was SATISFIED, and
stamped the marker - so the next start skipped the migration too and NO
inverter came up latched on an install whose state file says `sign_tripped:
true`. A disarmed SAFETY-02 guard, every symptom of a clean migration.
`roster_names(opts)` answers this BEFORE any `Inverter` exists - it is
defined to, for `MqttPublisher`, which is constructed before the roster for
the same reason - so the "there is no roster yet" that kept this argument
out was never true. On the flat shape it returns `[UNIT_A]`, so the upgrade
every install runs today passes `unit == roster[0] == UNIT_A` and is
byte-identical.
THE THREE RULES, each one a case that would otherwise be a wrong answer:
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
@@ -302,7 +320,7 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
rep.ran = True
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
_verify_moves(store, rep)
_verify_moves(store, rep, roster)
if rep.ok:
store.set(SCHEMA_KEY, SCHEMA_VERSION)
@@ -383,12 +401,26 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
itself. `test_maintenance.py` section 9c calls this twice with the SAME
wrong `unit` over one store, which is the shape nothing in the suite had.
`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.
`roster=None` still means "not graded", and SAFETY-44 is what it cost. This
paragraph used to end here, saying that the one caller passing it was
`migrate_unit_keys()`, "which runs BEFORE `build_roster()` ... so the keys
have to be in place before there is a roster to check against", and calling
that gap real. THE FIRST HALF IS TRUE AND THE CONCLUSION IS NOT. The keys do
have to be written before the `Inverter`s restore from them - but the NAMES
do not come from the `Inverter`s. `roster_names(opts)` reads them straight
off the options, before anything is constructed, which is the whole reason
it exists (`MqttPublisher` is built before the roster too). So the argument
that could have graded that write was available at the call site the entire
time, and the sibling function three screens down had already been fixed by
passing it.
⚠️ SO NO CALLER PASSES `None` TODAY, and this branch is kept for one reason:
it is what a NEW caller gets by DEFAULT. A future migration that forgets the
roster gets the ungraded read-back and stamps its marker over a namespace no
machine reads - silently, exactly as this one did. That is the shape of both
defects, and the gap is recorded as a running check rather than left to a
comment: test_maintenance.py section 13, and test_rehearse.py part 5, which
drives it end-to-end through the harness an operator actually runs.
"""
for target, value in rep.moved.items():
got = getattr(store, "data", {}).get(target, "<<missing>>")