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
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:
co-authored by
Claude Opus 5
parent
46c022c926
commit
5e382f52f6
@@ -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 "")
|
||||
|
||||
@@ -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>>")
|
||||
|
||||
Reference in New Issue
Block a user