BLOCKER. `auto` was persisted and `sign_wd.tripped` was not, so reconstructing
the Controller against the same store - which is what a container restart does -
brought it back ARMED, into the same fault, unattended: tripped=False, banner
back to NOT READY, and it resumed commanding -1000 then -2000 W before
re-tripping 180 s later. "Restart the add-on" is the first thing an operator
looking at a FAILSAFE banner and a 0 W inverter will try, and under a supervisor
that restarts a crashed add-on it is a loop of three-minute wrong-direction
bursts. DOCS.md's "it does not clear itself" was false across a restart.
`sign_tripped` and `sign_reason` are Store keys now, beside the maintenance
state and for the reason store.py's own docstring gives. Written the moment the
trip happens - reason first, flag second, so a crash between the two can never
persist a latch with no explanation - restored in Controller.__init__, cleared
only by the operator's start. The regression is covered: reverting the restore
fails seven checks in test_watchdog.py.
Review follow-ups, no behaviour change:
- The arming gate is tested against the REAL arbiter. It is the decision the
ticket turns on and it was asserted in three comment blocks and verified by
nothing - every case passed `following` in as a literal. Twelve claim
configurations now derive it from resolve(), including the redirect case
`winner == "loop"` alone would have got wrong and the `lo` limit that raises
magnitude without flipping sign.
- The MQTT `status: "failsafe"` value is tested. The check that claimed to cover
it restated the line above it and never touched the payload.
- The worked example described a config that does not ship: at the defaults
maintenance_charge_w 2500 against max_w 2000 is an arbiter contradiction and
0 W, not a -2500 W charge. Rewritten at 1500 W. The contradiction is a real
pre-existing bug - a default cheap-window maintenance charge silently never
happens - filed separately and deliberately not fixed here.
- Forward hazard recorded at the gate: `winner == "loop"` disarms the watchdog
under any higher-priority SET, and a peak-shaving strategy at P_PEAK IS
grid-following in sign, so it would blind this for every peak event.
- disagrees() is public so the replay harness stops carrying its own copy of the
predicate it grades.
- held_s takes the clock instead of reading one.
- The two limits are now each stated in both places rather than one each.
- The no-false-positive argument is tied to control.py's freeze permitting the
command to cross zero; under the shrink-only encoding control.py records as
rejected, this would false-trip on a load step.
- Measured figures corrected: 93.4 % / 4212 of 17280, per-cluster
0.3/0.9/10.5/0.0, and the near-circularity of those figures is now stated
rather than leaned on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa