SAFETY-44: decide per key, because one shared flag let an unrelated latch veto the repair
checks / unit (pull_request) Successful in 6m58s

⚠️ REVIEW DEFEATED THIS CARD'S OWN FIX WITH ONE ADDED KEY, and the project's own
instrument agreed on the unmodified tree. The guard classified each key and then
wrote ONE site-wide `namespaced_latched` for BOTH "on the roster" and
"off-roster but not UNIT_A", so a single latched key belonging to a unit the
operator had removed from `inverters:` vetoed the repair of the misdirected
`inverter-a` latch beside it. Measured, roster ['garage','shed'], store stamped
so the migration fix cannot reach it:

  sign_tripped + unit/inverter-a/sign_tripped     -> [('garage', True),  ...]
  ...AND unit/old-shed/sign_tripped               -> ⚠️ [('garage', False), ...]

A site DRIVING on a state file that reads `sign_tripped: true` - the sentence in
this card's own first paragraph - and `tools/rehearse_roster.py` printed
`LATCH CAME UP DISARMED` on it, exit 1, against shipped code. It matters more
than a normal hole because this guard is, as main.py says, the ONLY repair path
for an install already stamped by 0.9.0.

⚠️ AND IT IS INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md ADDED TWO
DAYS AGO. The two fixtures written for this card each hold ONE arrangement -
`_staged_store` has only `inverter-a`, `_other_store` only `inverter-b` - so the
flag was never written twice with opposite intents in a single store. A per-key
check whose keys have never disagreed WITHIN ONE STORE is a check over whichever
key came last. Three variables now, one per case, and a fixture holding BOTH.
Four cases driven: stranded-only repairs, +1 unrelated repairs, +2 repairs,
off-roster-only does NOT (which is DEV-02's control, still green).

⭐ AND THE MOVED-NOT-LOST RULE HID A GENUINE LATCH LOSS. The match was bare key
plus value over the whole of `machine_read`, with no identity - and
`DEFAULTS["sign_tripped"] is False`, so the ONLY non-default value a latch can
hold is `True`. Any surviving latched roster unit therefore suppressed any other
unit's vanished latch. Review measured a build that drops `unit/shed/sign_*`
while `unit/garage/sign_*` survives, both latched by one site event so the
reasons matched too:

    none in LEG SAME-STEP: nothing vanished, nothing stranded ...
    NO FINDINGS in the rehearsed transitions            exit 0

A roster unit's persisted SAFETY-02 latch deleted, transition reported clean.
The legitimate case is a RENAME - the key left `inverter-a`, which is not on
this roster, and arrived under `garage`, which is - so the vanished key's own
unit must be OFF the roster. A key whose unit is still on the roster has not
been renamed, it has been lost, and no amount of the same value elsewhere makes
that untrue.

⚠️ AND THE BOUND I CITED FOR IT DID NOT EXIST. Both the comment and the last
commit message said "what M3 (delete the flat rollback view) measures". M3 is
not that mutant - it drops `sign_reason` from `LEGACY_UNIT_KEYS` - and the one I
meant, M9, vanishes only FLAT keys, so neither can satisfy the `unit/` gate the
rule sits behind. NEITHER EXERCISED THE BRANCH; it had no negative control at
all, in a card whose subject is checks that cannot reach the failure they name.
A citation is the worst carrier for that, because it reads as having been
checked. M12 is written for it and driven: a roster unit that stops restoring
its latch is exit 1, and the suppression does not swallow it.

`misdirected` IS UNREACHABLE FROM THE CALL SITE, and the claim that it was a
benefit is withdrawn rather than restated. `unit` is `roster_names(opts)[0]` and
`build_roster()` names its first entry from the same list, so `unit` is a member
of `roster` by construction; review measured it - dropping the `roster` argument
inside `migrate_unit_keys()` survives 28/28. The argument stays for the reason
`_verify_moves()` gives for its own `roster=None` branch, it is what a NEW
caller gets - and a guard nothing exercises is a guard nobody knows works, so it
is now exercised at the function's own level: a wrong `unit` comes back
misdirected, the marker is not stamped, and the same call with a roster name
stamps it. Dead `if _roster_names else UNIT_A` removed; `roster_names()` never
returns [].

THE OPERATOR MESSAGE IS ASSERTED. Review deleted it whole (`if stranded:` ->
`if False:`) and 28/28 stayed green - `stranded` had no other consumer, so the
list existed only to print a line nothing checked, and that line is the entire
operator-facing half of the repair. Now: exactly one SAFETY-44 line at ERROR,
naming the stranded key and the roster it was measured against.

A wrong line citation removed: the call-site comment said "line 315's comment"
and "two hundred lines below", and both were wrong (315 is the CFG-03 defaults
note; the comment meant is ~54 lines down). Cite the thing, not its coordinates.

⚠️ AND WHAT 5b/5c ACTUALLY PIN IS RECORDED, because review is owed the
distinction: `mutant_tree()` asserts its target exists, so EDITING the guarded
line makes the file red at the ANCHOR before any behaviour is measured - which
looks identical from the exit code and proves much less. It happened twice while
writing this commit. The behavioural coverage lives in test_maintenance.py
section 13 and test_inverter.py, which build stores rather than strings.

Fixture strings no longer say "measured" - they are synthetic, and the repo's
other fixture carries an explicit SYNTHETIC header.

28/28 files pass.

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 17:08:41 +02:00
co-authored by Claude Opus 5
parent 5e382f52f6
commit d1b7e3f113
4 changed files with 259 additions and 25 deletions
+74 -12
View File
@@ -249,17 +249,35 @@ class Controller:
)
# 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_A)`, and the constant was wrong in exactly the way the
# `maint_migration` call below says the SAME constant would be wrong -
# ⚠️ this comment used to cite "line 315" and "two hundred lines below",
# and BOTH numbers were wrong (315 is the CFG-03 defaults note; the
# comment meant is ~54 lines down, and line numbers move). Cite the
# thing, not its coordinates. 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.
# upgrade path every install runs today is byte-identical.
#
# ⚠️ AND `misdirected` IS UNREACHABLE FROM **THIS** CALL SITE, WHICH THE
# COMMIT MESSAGE ORIGINALLY CLAIMED AS A BENEFIT. `unit` is
# `roster_names(opts)[0]` and `build_roster()` names its entries from
# the same `roster_names()` list, so `unit` is a member of `roster` by
# construction and the refusal can never fire here. Review measured it:
# dropping the `roster` argument from the `_verify_moves()` call inside
# `migrate_unit_keys()` survives 28/28.
#
# THE ARGUMENT STAYS ANYWAY, and the reason is the one `_verify_moves()`
# already gives for keeping its own `roster=None` branch: it is what a
# NEW caller gets, and the grading it enables is measured at the
# function's own level in test_maintenance.py rather than through here.
# What is NOT true is that this call site buys the refusal, so that
# claim is gone rather than restated.
_roster_names = roster_names(opts)
self.store_migration = migrate_unit_keys(
store, _roster_names[0] if _roster_names else UNIT_A, _roster_names)
store, _roster_names[0], _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
@@ -812,8 +830,45 @@ class Controller:
# 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.
# ⚠️ AND THE THREE CASES ARE THREE VARIABLES, NOT ONE SHARED FLAG.
# THIS IS WHERE THE FIRST VERSION OF THIS FIX WAS DEFEATED, BY ONE KEY.
# It wrote `namespaced_latched = True` for BOTH "on the roster" and "off
# the roster but not UNIT_A", so a single latched key belonging to a
# unit the operator had removed from `inverters:` vetoed the repair of
# the misdirected `inverter-a` latch sitting beside it. Measured on the
# shipped build, roster ['garage', 'shed'], store stamped so the
# migration cannot reach it:
#
# sign_tripped + unit/inverter-a/sign_tripped
# -> [('garage', True), ('shed', False)] repaired
# ...AND unit/old-shed/sign_tripped
# -> [('garage', False), ('shed', False)] ⚠️ DRIVING
#
# A site DRIVING on a state file that reads `sign_tripped: true`, which
# is the sentence this whole card exists to make false - and
# `tools/rehearse_roster.py` said so, on the unmodified tree, exit 1.
#
# ⚠️ IT IS ALSO INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md
# ADDED TWO DAYS AGO. The two fixtures written for this card each hold
# ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store`
# has only `inverter-b` - and the flag is written twice with opposite
# intents only when BOTH are present. A per-key check whose keys have
# never disagreed WITHIN ONE STORE is a check over whichever key came
# last. The fixture below now holds both.
#
# THE THREE CASES, AND WHY THE ANSWER DIFFERS FOR EACH:
# on the roster a machine reads it: real protection, stop.
# off-roster, `UNIT_A` the broken migration's fingerprint - this
# IS the flat latch, misdirected. Repair.
# off-roster, other name a real unit out of today's config. Its
# latch is unread too, but it is NOT the flat
# key's misdirection, and re-arming a healthy
# sibling off it is DEV-02's defect. Stop -
# UNLESS a stranded `UNIT_A` latch is also
# present, because then the flat key IS
# accounted for and the repair is the point.
on_roster = {inv.name for inv in self.inverters}
stranded, namespaced_latched = [], False
stranded, on_roster_latched, other_off_roster = [], False, False
for k, v in data.items():
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
continue
@@ -822,12 +877,19 @@ class Controller:
# `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:
if unit in on_roster:
on_roster_latched = True
elif unit == UNIT_A:
stranded.append(k)
if namespaced_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
else:
other_off_roster = True
if on_roster_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
if other_off_roster and not stranded:
# A removed unit's own latch, and no misdirected one. The flat key
# is that unit's mirror, so it carries no new information and
# re-arming `inverters[0]` from it would latch a healthy pack.
return
if stranded:
_LOG.error(