DEV-02: merge release/1.0, and close the two review blockers
checks / unit (pull_request) Successful in 4m15s

PR #43 was rejected on two findings and had gone unmergeable behind SAFETY-28
and the ALLOC-01 fix. This is the merge commit; the conflict resolution and both
fixes are below, because a merge that also changes behaviour has to say so.

THE MERGE, AND WHAT IT COST
===========================
Two conflicts in main.py, both where SAFETY-28 and this card touched the same
method:

  * `write_setpoint()` split into a site entry point plus a per-unit
    `_write_unit()` here, while SAFETY-28 put its record gate in the old
    single-unit body. `write_refusal()` now takes the INVERTER it is about.
    Asking it with `self.unit` - as release/1.0 does - would authorise A's share
    on B's wire, and would also trip test_inverter.py part 1's AST scan, which
    goes red on any read of a Controller N=1 view inside Controller.
  * the SAFETY-28 gate sits above the clamp and below the non-finite coercion,
    exactly where release/1.0 put it, and is now asked about `inv`.

Fallout the merge produced in the checks, re-aimed rather than relaxed:

  * every fixture that calls `write_setpoint()` DIRECTLY - the clamp probe, the
    hung-heartbeat experiment and its serialised counterfactual, the split
    deadline probe - now arms a real allocation record first. test_inverter.py
    grows an `arm()` built through the shipped `resolve()`, the same shape
    test_clamp.py's is: the per-unit envelopes are filed as real `supervised`
    claims and the site target is their sum, so the water-fill has exactly one
    answer and the fixture cannot quietly become its own allocator.
  * test_clamp.py's two-unit failsafe fixture assigned `c.units`, which DEV-02
    made a derived property with no setter. It appends a real `Inverter`.

BLOCKER 1 - AC 4's ARBITER-BOUND EVIDENCE WAS VACUOUS
=====================================================
test_peakguard.py claimed "each unit's `supervised` bound is ITS OWN `max_w` -
give inverter B a different rating and the claim filed on inverter B moves with
it", and compared B's bound against `_b2.max_w`. `second_inverter()` DEFAULTED
B's rating to `LIVE_MAX_W`, which is A's. IT COMPARED (-5000, 5000) AGAINST
(-5000, 5000): a number against itself, unable to observe the failure it named.

Reproduced: filing the per-unit `device`/`supervised` claims from
`self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(..., inv)`
alone left ALL 19 FILES GREEN. At a roster of two with A=5000/B=3000 that makes
inverter B's planned envelope (-5000, 5000) instead of (-3000, 3000) - a 2000 W
over-command the arbiter believes is supervised. It is a DIFFERENT defect from
the actuator variant the static routing pin catches, and only the actuator
variant was in the mutation table.

  * `second_inverter()` gives B 3000 W - a hypothesis, marked as one - for every
    fixture in the file, so no check there can compare B against A by accident.
  * the claim is asserted against LITERAL numbers rather than against an
    expression that can read the same object twice, for `supervised` AND for
    `device` (whose `dev` had the same defect), plus the envelope the ARBITER
    RESOLVED for B - a claim filed right and resolved wrong is the same defect
    one layer down.
  * a PRECONDITION check fails if the two ratings are ever made equal again.
  * the BOUND mutation is in the table, beside the actuator one, and was
    observed RED (3 checks in test_peakguard.py) via tools/mutation_run.sh.

⚠️ THIRD INSTANCE THIS SESSION of a check whose DATA cannot reach the failure it
names - ARB-03's call-site scan saw 3 of 8 appends, ALLOC-01's replay generator
saw 0 escapes where a widened one saw 39,991. MUTATION.md gains a section for it
beside the stale-bytecode and masking ones, and the rule is written at the line:
ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST BE RUN ONCE WITH X AND Y ACTUALLY
MADE DIFFERENT.

BLOCKER 2 - THE LATCH WAS SILENTLY DISARMED BY THE ROLLBACK ROUND TRIP
======================================================================
store.py's rule 2 keeps the flat keys "so a rollback still finds its latch". The
operator path that rule contemplates is a ROUND TRIP, and this card lost the
latch on it:

  1. install migrates          -> schema_version=1, unit/inverter-a/... = False
  2. operator DOWNGRADES; SAFETY-02 trips inside the downgrade window and the
     old add-on writes only the flat `sign_tripped: true`
  3. operator upgrades again   -> `rep.ran` is False, so `_grade_store_migration()`
     returned early, and `Inverter.__init__` read the namespaced key: False

Observed: the controller came up with `sign_wd.tripped = False` while
/data/state.json said `sign_tripped: true`. That is store.py's own header
sentence - "the file still says sign_tripped: true, the banner is green, and the
controller resumes driving the wrong way" - rebuilt by the version marker. A
firmware version change with a rollback path was performed on this site on
2026-09-02, so this is not hypothetical.

Three changes, each graded by a mutation observed RED:

  * THE OPERATOR'S CLEAR CLEARS BOTH SPELLINGS. That is what removes the
    "re-arm forever" objection the `rep.ran` gate existed to answer: with the
    fossil cleared, `flat True + namespaced False` is unambiguously a
    downgrade-window latch.
  * A TRIP MIRRORS THE FLAT PAIR. Rule 2 only ever covered the latch present AT
    MIGRATION TIME - nothing flat was written for a trip taken afterwards, so an
    operator who downgraded found `sign_tripped: false` and came up DRIVING.
    store.py claimed the staleness could only fail toward a SPURIOUS latch. It
    could not; that sentence is corrected rather than left standing.
  * THE GUARD RUNS ON EVERY START, and asks the STORE - not the roster - whether
    anything namespaced is latched. A mirrored trip on B must not re-arm a
    healthy A, and the store is the right place to ask because it holds every
    `unit/*/sign_tripped` this install has written, including one belonging to a
    unit that is not in today's roster.

Graded by the three-step scenario itself, run end to end in test_inverter.py
part 2, with all three preconditions asserted - including that the catching
start did NOT migrate anything, which is what used to skip the guard. Part 2's
idempotence check now PRESSES the button instead of hand-writing the store,
because the state it used to construct is not one the shipped clear path can
produce. Part 3 grades the mirror and the clear on a real roster of two.

test_watchdog.py's "the flat key is NOT written" assertion is re-aimed rather
than deleted: its argument was right about the READ and wrong about the WRITE.
Nothing at runtime reads that key, and the one thing that does ignores it
whenever any namespaced latch is set - which a sibling's trip always leaves.
What its absence actually cost was the rollback.

HANDOVER.md and DOCS.md said the flat key "is never read again", which was the
instruction an operator clearing a trip by hand would follow. Both corrected: it
is a maintained rollback view, it IS read on every start, and clearing a trip by
hand means clearing both spellings.

MUTATIONS THIS ROUND, each applied on a purged tree via tools/mutation_run.sh
and observed RED:
  * per-unit `device`/`supervised` BOUNDS from `self.inverters[0]`, actuator
    left correct                                   -> test_peakguard.py 10g
  * `_grade_store_migration()` gated on `rep.ran`   -> test_inverter.py part 2
  * the operator's clear leaving the flat keys set  -> test_inverter.py part 2/3
  * a trip not mirroring the flat pair              -> test_inverter.py part 3
  * the guard asking only `self.inverters[0]`       -> test_inverter.py part 3

19/19 files green on the merged tree. No hardware was touched.

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-03 23:37:23 +02:00
co-authored by Claude Opus 5
14 changed files with 1379 additions and 120 deletions
+250 -31
View File
@@ -219,11 +219,13 @@ class Controller:
# the add-on - and the controller then comes back armed, into the fault,
# with nobody involved.
#
# ⚠️ AND THE MIGRATION IS GRADED HERE RATHER THAN TRUSTED. A latch that
# silently failed to move into its namespace is a DISARMED SAFETY GUARD
# on an install whose state file still says `sign_tripped: true`. So the
# pre-DEV-02 flat value is compared against what unit A actually came up
# with, and a disagreement is not a log line - it re-arms the latch by
# ⚠️ AND THE LATCH IS GRADED HERE RATHER THAN TRUSTED, ON EVERY START. A
# latch that silently failed to move into its namespace - or that was
# taken by an OLDER add-on while this install was rolled back, which is
# the round trip store.py's rule 2 exists for - is a DISARMED SAFETY
# GUARD on an install whose state file still says `sign_tripped: true`.
# So the flat value is compared against what the roster actually came up
# with, and a disagreement is not a log line: it re-arms the latch by
# hand and shouts, because the fail-safe direction for a latch is ON.
self._grade_store_migration(store)
for inv in self.inverters:
@@ -361,6 +363,25 @@ class Controller:
# the cached envelope this card exists to forbid, wearing a different
# hat.
self.allocation = None
# SAFETY-28. THE TICK THE RECORD ABOVE BELONGS TO, and the counter that
# mints it. It is incremented ONCE PER RESOLUTION - not once per loop
# iteration - and that distinction is load-bearing in both directions:
#
# * a NEW resolution makes the previous record stale IMMEDIATELY, so a
# record cannot be replayed into a later tick after the envelopes it
# describes have moved. That is the replay half of the gate.
# * a cycle that produced NO resolution does not bump it. The "inputs
# missing, not yet stale" hold above returns before the allocator
# runs precisely so that a single missed poll does not zero the
# inverter, and the heartbeat re-affirms `self.target` through the
# write path while it does. Bumping here would make that heartbeat
# unauthorised and drop the site to 0 W on the first dropped poll -
# turning a hold into the outage it exists to prevent.
#
# So the record is SUPERSEDED, never expired: it stands until the
# allocator produces its successor, and nothing else has moved in
# between that it does not still describe.
self.alloc_tick = 0
# What the last cycle's allocator dropped, so the log line above is
# edge-triggered. Starts as the empty tuple - nothing has been dropped
# yet and nothing has been said about it.
@@ -413,25 +434,50 @@ class Controller:
worse than never having persisted the latch, because now there is
evidence that says it is fine.
So the pre-DEV-02 flat value is compared against what unit A ACTUALLY
came up with, and a disagreement is not reported and survived - the
latch is re-armed by hand, from the flat value, and the store is written
so the next start does not need this branch. The fail-safe direction for
a latch is ON: a spurious trip costs a 0 W hold an operator clears with
one button, and a missing one costs whatever SAFETY-02 was watching for.
So the pre-DEV-02 flat value is compared against what the roster
ACTUALLY came up with, and a disagreement is not reported and survived -
the latch is re-armed by hand, from the flat value, and the store is
written so the next start does not need this branch. The fail-safe
direction for a latch is ON: a spurious trip costs a 0 W hold an
operator clears with one button, and a missing one costs whatever
SAFETY-02 was watching for.
⚠️ AND IT RUNS ON EVERY START, NOT ONLY ON THE ONE THAT MIGRATES. It
used to return early on `not rep.ran`, for a real reason: while nothing
cleared the flat key, an install that had ever tripped carried
`sign_tripped: true` in its file forever, and a guard reading it would
have re-armed the failsafe on every restart for the rest of the
install's life.
THAT GATE SWALLOWED THE EXACT ROUND TRIP store.py's RULE 2 EXISTS FOR,
and it is not hypothetical - a firmware version change with a rollback
path was performed on this site on 2026-09-02:
1. the install migrates -> `schema_version = 1`,
`unit/inverter-a/sign_tripped = False`
2. the operator DOWNGRADES, and SAFETY-02 trips inside the downgrade
window. The old code knows only the flat key, so the file gains
`sign_tripped: true` and nothing namespaced
3. the operator upgrades again. `rep.ran` is False - the store is
already at schema 1 - so BOTH the migration and this guard are
skipped, `Inverter.__init__` reads the namespaced key, and the
controller comes up with `sign_wd.tripped = False` while
/data/state.json says `sign_tripped: true`
Which is this module's own sentence, rebuilt by the version marker: the
file still says tripped, the banner is green, and the controller resumes
driving the wrong way.
The gate is gone because the reason for it is gone. `handle_action()`
now clears the flat keys alongside the namespaced ones when the operator
clears the latch, and the trip mirrors them, so the two spellings can
disagree in exactly one direction: FLAT SAYS TRIPPED AND NO UNIT CAME UP
TRIPPED means the flat key was written by a version that did not know
about namespaces - a downgrade-window trip - or a migration that lost
the latch. Both want the same answer, which is the one below.
"""
rep = self.store_migration
# ⚠️ ONLY ON THE START THAT ACTUALLY MIGRATES, AND THAT IS NOT A
# SHORTCUT. Once the store is marked migrated the NAMESPACED value is
# the live latch and the flat key is a fossil that nothing writes any
# more - so an operator who clears the failsafe leaves `sign_tripped:
# true` sitting in the file forever. A guard that went on comparing
# against it would re-arm the failsafe on every restart, for the rest of
# the install's life, on a fault that was dealt with weeks ago. That is
# rule 1 of `migrate_unit_keys()` read from the other end.
if not rep.ran:
return
if not rep.ok:
if rep.ran and not rep.ok:
_LOG.error("SAFETY-02: the per-inverter store migration did not "
"verify (%s). The latch is being re-derived from the "
"legacy keys.", rep.explain())
@@ -439,9 +485,24 @@ class Controller:
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
first = self.inverters[0]
if first.sign_wd.tripped:
# ⚠️ 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"))
if namespaced_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
first.sign_wd.tripped = True
@@ -451,12 +512,14 @@ class Controller:
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"SAFETY-02 MIGRATION GUARD: this store carries a LATCHED "
"sign-disagreement failsafe under the pre-DEV-02 flat key, and %s "
"came up UNLATCHED. That is a disarmed safety guard on an install "
"whose state file says it is protected, so the latch has been "
"re-armed from the legacy value: %s. %s is held at 0 W until "
"control is started from the UI.",
"SAFETY-02 LATCH GUARD: this store carries a LATCHED "
"sign-disagreement failsafe under the pre-DEV-02 flat key and NO "
"inverter came up latched. Either the per-inverter migration lost "
"it, or it was written by an older add-on while this install was "
"rolled back. Both are a disarmed safety guard on an install whose "
"state file says it is protected, so the latch has been re-armed "
"on %s from the legacy value: %s. %s is held at 0 W until control "
"is started from the UI.",
first.name, first.sign_wd.reason, first.name)
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
+ first.sign_wd.reason)
@@ -767,6 +830,77 @@ class Controller:
inv.soc = await self.hass.number(inv.soc_entity)
inv.batt = await self.hass.number(inv.batt_entity, inv.batt_invert)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it may.
--- SAFETY-28: THE WRITE PATH IS GATED ON THE ALLOCATION RECORD --------
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. Until this method that non-goal was enforced by
`len(validate.ACTUATOR_WRITE_KEYS) == 1` - there is one option key, so a
second writer is not expressible. True, and a statement about the SHAPE
OF THE OPTIONS SCHEMA rather than about the writes. Add CFG-04's
per-inverter config and there are two real writers with that length
still 1: the check stays green while the property it protects is gone.
That is this repository's named failure mode - a check that observes
something other than what it claims to observe - so the enforcement
moved here, onto the writes themselves.
The rule is: A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR
PLACED IT, ON THIS TICK, FOR THIS UNIT. A second writer with arithmetic
of its own cannot satisfy that - its number is not in any record - so it
has to file a Claim and go through `resolve()`, which is the one
allocator. "Exactly one allocator per shared resource" stops being a
convention somebody can forget and becomes a thing the write path
checks. `Allocation.authorises()` holds the per-record half of the
argument.
⚠️ 0 W IS ALWAYS AUTHORISED, AND THAT IS THE ANSWER TO "WHAT IF THE
ALLOCATOR IS THE THING THAT FAILED". This is not a convenience exit; it
is the one value whose correctness does not depend on a split existing:
* The hazard the non-goal names is N-times loop gain from BROADCAST -
handing every inverter the same target so each supplies it in full
(see allocator.py's opening argument, and TEST-19's measured
`N=2 gain 1.0` limit cycle). At S = 0 the broadcast and the correct
allocation COINCIDE: a_i = 1 for every unit gives every unit 0 W,
and 0 W is exactly its share. There is no over-command to make.
* It is the fail-toward-inaction direction §11.1 rule 1 already
names, and it is what layer 2 commands. `cycle()`'s two refusal
branches replace the resolution with a 0 W failsafe and then write
it through here like any other cycle; `shutdown()` and the
operator's stop button do the same. Every one of those runs while
the allocator has REFUSED - `Allocation.ok` is False and there are
no shares - so a gate that asked the allocator's permission to
command zero would deadlock the failsafe behind the very component
whose failure the failsafe exists to answer.
A gate that can block the safety write is worse than the hole it closes,
so it cannot block it: the test for zero is first and reads nothing.
⚠️ AND IT IS `value == 0.0`, THE REQUESTED NUMBER - not a `failsafe=True`
argument. A flag is an assertion by the caller, which is precisely what
a second writer would pass; the value is a fact about what is going to
be written. -0.0 == 0.0 in Python, and a nan never reaches here (the
caller has already turned it into 0.0), so the predicate is total.
⚠️ DEV-02: THE UNIT IS AN ARGUMENT, NOT THE CONTROLLER'S N=1 VIEW OF
IT. The record holds one share PER UNIT, so "did the allocator place
this number" is a question about a NAMED actuator. Asking it with the
first inverter's name at a roster of two would authorise A's share on
B's wire and refuse B's own - the site/unit confusion this card
removes, wearing the gate's name. `_write_unit()` always passes the
unit it is about; the default is for the N=1 callers and REFUSES at
N>=2 (see `_only()`) rather than answering for the first inverter.
"""
if value == 0.0:
return None
inv = self._only() if inv is None else inv
rec = self.allocation
if rec is None:
return ("no allocation record exists yet - nothing has authorised "
f"{value:+.0f} W on {inv.name}")
return rec.authorises(inv.name, value, self.alloc_tick)
async def write_setpoint(self, value: float, *, force: bool = False,
unit=None) -> None:
"""Put `value` on ONE inverter, or - with `unit=None` - on all of them.
@@ -832,6 +966,46 @@ class Controller:
"0 W and continuing to command. This is a bug upstream, "
"not a limit.", value)
value = 0.0
# --- SAFETY-28: AND IT IS REFUSED HERE, NOT LOGGED SOMEWHERE ----------
# ⚠️ ABOVE THE CLAMP AND BELOW THE NON-FINITE COERCION, and both halves
# of that placement are decisions.
#
# Below the coercion, because a nan is loss of input rather than an
# unauthorised write: rule 1 has already turned it into 0 W, which this
# gate then authorises unconditionally, so a transport bug does not get
# a second, differently-worded refusal.
#
# Above the clamp, because the question is WHO CHOSE THIS NUMBER and the
# answer is only meaningful about the number that was asked for. The
# clamp then narrows what was authorised, always toward safety, and it
# stays the last thing between a bug and the hardware. Gating the
# CLAMPED value would be worse than useless: on every cycle the clamp
# engages, the emitted watts differ from the allocator's share by
# construction, so the gate would refuse exactly the cycles SAFETY-03
# exists for.
#
# The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in
# place of the number nothing stands behind. Returning instead would
# leave the inverter latched on its last command with the ESP32's
# heartbeat still being fed - holding an unauthorised setpoint quietly,
# which is the outcome this gate exists to prevent.
#
# ⚠️ AND IT IS ASKED ABOUT `inv`, NOT ABOUT THE SITE. The record holds
# one share per unit, so the question "did the single allocator place
# this number" is only answerable about a named actuator; DEV-02 made
# this method per-unit, and passing the first inverter here would
# authorise A's share on B's wire.
refusal = self.write_refusal(value, inv)
if refusal is not None:
self.log_event(
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W")
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W to %s: %s. "
"Commanding 0 W instead. Every setpoint must be one the "
"allocator placed on THIS tick, for THIS unit - see "
"Controller.write_refusal().", value, inv.name, refusal)
value = 0.0
# ⚠️ THIS UNIT'S OWN THREE NUMBERS. `inv.max_w` is the supervised cap on
# what may be written to THIS setpoint entity and `inv.dev_min/dev_max`
# are what THIS device reported about itself - which is the whole of "the
@@ -1002,6 +1176,20 @@ class Controller:
# pressed start, and one press cleared both. See store.py.
inv.store_set("sign_reason", inv.sign_wd.reason)
inv.store_set("sign_tripped", True)
# ⚠️ AND THE LEGACY FLAT KEYS ARE MIRRORED, WHICH IS THE OTHER
# HALF OF store.py's RULE 2. That rule keeps the flat keys "so a
# rollback still finds its latch", and it only ever covered the
# latch that existed AT MIGRATION TIME: a trip that happens
# AFTER the migration wrote nothing flat, so an operator who
# downgraded would find `sign_tripped: false` and come up
# DRIVING, into the fault. store.py used to claim the staleness
# could only fail toward a spurious latch; it could not, and
# this is what makes that sentence true. The pair is
# site-level on purpose - the version being rolled back TO
# drives exactly one actuator - and it is cleared by the same
# operator press that clears the namespaced ones.
self.store.set("sign_reason", inv.sign_wd.reason)
self.store.set("sign_tripped", True)
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason)
_LOG.error("SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on "
"that inverter and holding it until control is "
@@ -1423,7 +1611,12 @@ class Controller:
# de-assert when control is stopped.
self.loop_clamped = (False, "")
resolution = resolve(claims, units=self.units, live=self.live_units())
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
resolution = resolve(claims, units=self.units, live=self.live_units(),
tick=self.alloc_tick)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -2589,6 +2782,32 @@ class Controller:
inv.store_set("sign_reason", "")
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}")
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
# This is what makes `flat True + namespaced False` MEAN
# something, and it is the reason `_grade_store_migration()` can
# run on every start instead of only on the start that migrates.
#
# Without it the flat key is a fossil that says `true` forever
# on any install that ever tripped, so a guard comparing against
# it would re-arm the failsafe on every restart for the rest of
# the install's life - which is why that guard used to be gated
# on `rep.ran`, and why the gate then swallowed the rollback
# ROUND TRIP: migrate, downgrade, trip during the downgrade
# window (the old code writes only the flat key), upgrade again
# -> `rep.ran` is False, the guard never runs, and the
# controller comes up UNLATCHED while /data/state.json says
# `sign_tripped: true`. That is store.py's own header sentence,
# rebuilt by the version marker.
#
# Cleared here, the two spellings can only disagree one way: the
# flat key is newer than the namespaced one, which means it was
# written by a version that did not know about namespaces - i.e.
# a trip inside a downgrade window. Unconditional rather than
# inside the loop because an operator pressing START is
# declaring the whole site clear, and the fossil belongs to no
# unit in particular.
self.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
if not self.auto:
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
# Zeroing `self.target` alone would leave each unit's last share