SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
checks / unit (pull_request) Successful in 7m58s

SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.

REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:

  10:51:24.7  0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
              the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
  10:51:29.7  the command reaches +1060 W - 800 W of travel
  10:52:00    the conjunction arms. Inverter.batt becomes None
  10:52:21.7  the site commands 0 W, on the wire
  ...         0x0518 does not change value for 850.0 s. The setpoint does not
              change value for 790.0 s. The house exports 2.4 kW throughout
  ~11:04      a human restarts the add-on. That is what ends it

AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.

THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.

THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:

  the verdict is withdrawn when the obligation that produced it has been gone
  for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.

WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.

Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.

main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.

Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-21 12:33:56 +02:00
co-authored by Claude Opus 5
parent 6f5e8e378a
commit 43c144b8d9
10 changed files with 1921 additions and 37 deletions
@@ -0,0 +1,380 @@
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
(content-identical to `release/1.0` @ `8a57361`).
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
**passive** RS-485 listener with no address on the bus, and it was read only.
---
## 0. The five things a reader should take away
1. **The live episode is reproduced from the wire, not modelled.** The capture
is committed, the decoder is committed, and the box that produced it was
running the defect — so the `0 W` in the trace *is* the add-on's own
`inputs missing → command 0 W`. §2.
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
already holds, and it is not the bug.** `_grade()` has required
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
the stuck state is at **0 W**; they are different moments. §3.
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
when the **obligation that produced it is no longer standing** — SAFETY-13's
shape one module over. The 17.0 s is the obligation window that was already
derived in the file, taken at the **floor** of its band, and which half is
safe is the **opposite** of SAFETY-13's. §4.
4. **It costs 52 % exposure on a permanently dead register, and that number is
in the suite so it cannot be forgotten.** It is payable only because one
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
**No detection SAFETY-37 had is given back.** §5.
5. **The battery had been rescuing this code by accident.** 93 trips in the
committed 16-day capture; median wait for the BMS keep-alive pulse that
cleared them **143 s**, worst **5 h 57 m**. §6.
---
## 1. What was there, in the terms the code uses
`freshness.py`, `Freshness.submit()` and `_grade()`:
```python
if self.changed_mono is None or value != self.held:
...
self.stale = False # the only place the latch is cleared
def _grade(self, standing_w, now_mono):
if self.stale or self.changed_mono is None:
return # sets the latch; never clears it
```
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
missing")` and keeps filing it.
The reason the latch exists is written in the class docstring and it is a good
one: without it the verdict oscillates at about 1 Hz, because declaring the
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
which hands the held number straight back.
**The defect is that the only thing that ends it is an arrival carrying a
different number — and the state's own output is what stops the register being
asked for one.** The log line promised the opposite, in the same sentence that
announced the 0 W:
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
> register reports a different value.*
---
## 2. Reproducing it, from the wire
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
rather than trusted: 298 commands at or under −1000 W are each answered one
obligation window later by a negative register reading, and not one positive.
| capture time | on the wire |
|---|---|
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
| …850.0 s | `0x0518` does not change value |
| …790.0 s | the setpoint does not change value |
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
| 11:05:34.7 | `0x0518` = **−137 W** |
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
this is a reproduction and not a simulation: the box was running 0.10.0, so
every command in the trace is the shipped code's own. The only event inside the
window the file cannot account for is the human restart.
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
(`test_freshness.py` part 10b):
| | stale at | clear at | parked |
|---|---|---|---|
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
only ever called where the register moves, and the window pair is the
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
asserts that by driving part 1's own fresh/stale pair through both bounds and
comparing the verdict streams.
The replay also found a **one-read trip at 10:48:14** that clears one second
later. That is a pre-existing false positive and it is now recorded as
survivor 5 — §7.
---
## 3. What the card's option 2 actually buys, which is nothing here
The card offered *"do not arm the gate when the command being graded is 0 W"*
and warned that it might hold already. **It does, and it has since SAFETY-37**:
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
`test_freshness.py` part 4 has a named check on that term, added because a
mutation removing it survived.
The warning in the card is the right one and it is worth writing down: **the
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
where the conjunction is entirely correct — a command travelled 800 W and the
register did not move for 35 s. The site then went to **0 W** *because of the
verdict*. An entry-side rule cannot reach a state that is entered legitimately
and then persists. **The defect is the exit, and nothing else in the conjunction
is touched by this card.**
Option 3 — probing with a small non-zero command — was not taken and does not
need to be. It writes to a real actuator to test an assumption, and §4's rule
does not need a probe: the site's *normal* control resuming is the test, and it
is gated behind the same `inputs_ok` everything else is.
---
## 4. The decision: which verdicts get an exit, and what the bound is
> The stale verdict holds as before, **unless** the obligation that produced it
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
> longer than the register would have needed to answer it. On a release the
> latch clears and a **new grading window** opens at that instant.
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
established the house answer to "a latch with no exit" and its answer was *not*
a plain time bound but a **predicate plus a time bound**, where the predicate
says whether the state can end on its own. Here the predicate is that **the
question is no longer being asked.** A register under a 0 W command is obliged
to be nowhere, so a verdict about whether it is following cannot be tested, and
a verdict that cannot be tested may not stand for ever.
### 4.1 Why the window has to re-open, and what happens if it does not
`_grade()` compares the register's unchanged time against `unchanged_s` and the
command's travel against the command standing when the window opened. If a
release cleared the latch **without** re-opening the window, the first read at
which the resumed command travels 250 W would re-trip immediately — the
register would never get the 17 s in which it could answer, and the live
episode's recovery (a charge the pack **accepts**) would be killed inside two
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
+82 B) takes four named checks red.
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
because it is the same question — *does this register follow the commands issued
from now on?* — and there is no principled shorter answer to it. It is also the
number the exposure argument in §5 rests on, so it is not free to move.
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
The quantity needed is *has the register had its chance to answer the command
standing now?*, and `freshness.py` already derives it as the floor of
`BATT_UNCHANGED_S`'s band:
```
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
1.0 s this add-on run_control()'s await asyncio.sleep(1)
```
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
| | the question | the safe half | why |
|---|---|---|---|
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
the new 0 W command by itself, which clears the latch on evidence rather than on
a timer. That is graded behaviourally rather than asserted: a register that
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
still 0, and by **timer** at `release_s = 5.0`.
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
six distinct release instants, so no check in the file can confuse two of them.
### 4.3 Two clocks, and the operator reads the register's
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
release they go on reporting that it has not moved for 850 s, because it has
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
until the first release. Collapsing them either lies to the operator or defeats
the re-grading, and both directions are mutated red.
The stale sentence's `unchanged for N s` slot carries the **register's** number;
a re-trip appends a clause naming the window and the grade count, so the two
durations cannot be read as one number that moved (habit 14).
---
## 5. What it costs, measured
Driven against a register frozen for good, 1201 reads, shipped defaults
(`test_freshness.py` part 10c):
| | parked | the held number reaches a consumer | releases |
|---|---|---|---|
| before | 1166 s | **35 reads (2.9 %)** | 0 |
| after | 571 s | **630 reads (52.5 %)** | 17 |
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
It is payable because of one inequality, and it is the inequality
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
SAFETY-37:
```
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
```
Every unbroken run of exposure is **exactly one window**, so neither consumer
can reach a verdict inside one, and the re-trip that ends each run hands them a
`None`, which **clears** their windows rather than accumulating into them.
**And nothing SAFETY-37 detects is given back**, which is the half that makes
this a trade rather than a regression: a permanent `None` clears those same two
windows for ever as well, so on the shipped-before module those detectors are
*equally* unable to speak about the unit. What the held number does reach for
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
answers a large unexplained divergence by **freezing the output**, the
conservative direction and SAFETY-13's business — and the command itself is
derived from the **P1 meter**, never from `batt`.
### 5.1 The narrowing this does NOT take, named so the next card can
The live episode's register refused a **discharge** into an 11 % pack and then
followed a **charge** three seconds later. A refusal in one direction is not
evidence about the other, and a gate that knew the pack's floor could re-grade
on the first command that crosses it instead of on a timer — which would shrink
the 52 % above. That needs SoC, which this module deliberately does not take
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
It is **not needed to give the state an exit**, and folding it in here would
make the exit depend on a second measurement being right.
---
## 6. The measurement that says the old exit was luck
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
thing that could clear any of them was the register carrying a different number,
and that took:
| | |
|---|---|
| minimum | 0.6 s |
| **median** | **143.4 s** |
| maximum | **21440.1 s** (5 h 57 m) |
| at or over the live episode's 710 s | **17 of 93** |
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
Over a fifth of the trips are cleared in under 20 s by something nothing in this
add-on asked for.
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
setpoint column is an **ungated** controller's. That makes each trip an honest
register-versus-command divergence off the real bus, and it makes the post-trip
command column something a gated box would **not** have written. The figure
taken from it is therefore the only one that does not depend on the command at
all — how long this register goes without changing value — and it **under**-states
the wait, because a site parked at 0 W gives the register even less reason to
move than the one recorded here.
---
## 7. Survivors, and the two things the battery found
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
invocation and the tree is fingerprinted **after the revert** as well as before;
every revert restored the recorded sha256 and byte count.
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
PR body. Two of them are worth keeping here.
### 7.1 The equivalent mutant, argued rather than rounded off
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
survived the whole battery. **It is not a missing fixture and no fixture can
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
only when `|standing| < move_w`, so at most one of them can do anything on any
read, whichever runs first.
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
a comment is a claim, and this one had not been run.** The comment now states
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
1300 W — instead of pinning an order that is not observable.
### 7.2 The sibling that survived, which is habit 11 earning its place
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
below both 35 and 250. They separate only in the band between them, and a site
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
that asserts the 100 W case releases and the 300 W case does not.
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
The first form of the `graded_from` mutation was
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
the tree fingerprint showed the edit really was on disk and really did run.
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
has nothing to do with the property under test. Rewritten as an explicit
`is None` test (+82 B) it is red on four named checks.
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
purge, a fingerprint and a byte delta all say the edit is present; none of them
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
for it.
---
## 8. What this does NOT fix, said plainly
**It does not tell a BMS refusal apart from a dead register**, and it does not
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
would need.
**It does not make the diagnosis right.** The line the operator reads still says
*"battery-power register is not following its own command"*, which is wrong in a
different way when the pack is simply empty. That wording is **SAFETY-56's**,
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
two cards editing one sentence is how one of them silently loses. Only the
**escape clause** was rewritten, because this card is what made the old one
false, and part 10h has a named check asserting the diagnosis half is still
there so that rewrite has to be a deliberate act.
**Survivor 5 is older than this card and is not closed by it.** After a long
rest the unchanged clock is already past 35 s, so the **first** read carrying a
travelled command trips the gate — before the 17.0 s in which a healthy register
could have answered it. The live capture contains one (10:48:14, cleared one
second later). Its size is one `stale_input_s` hold, during which `cycle()`
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
means measuring the unchanged clock from when the **obligation** appeared rather
than from the register's last move, which re-derives the 35.0 s bound and is a
different card.
**Nothing was measured on two inverters.** Inverter B does not physically exist
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
copies of A's behaviour with different numbers passed in.
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
requires the newest heading to be a version. Moving it, and deciding whether a
version is cut, is a PKG card's.