Text only. No behaviour change - same conjunction, same limits, same MISSING, same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390 capture reads, a 66-cell swept grid, and six driven controller worlds produce the byte-identical document `6030175e...` on origin/release/1.0 and on this branch, while the sentences differ. THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the register has stopped reporting reality, or the SOURCE cannot comply with the command - and nothing `freshness.py` is handed separates them. Three sentences asserted the first. On the live 2026-09-21 episode it was the second: the pack read 11 % for the whole 850 s hold, every command in that hold was a discharge, and the register answered the first CHARGE command three seconds later. The readiness row was worse still, because it asserted a false fact about a NAMED entity that was present, numeric and updating - so the operator opened Developer Tools, found it working, and had every reason to conclude the diagnostics were broken. THE FIX. Each message states what was OBSERVED and names BOTH explanations. Three wordings, no shared helper: two are a log line about a behaviour, the third is a banner about an entity, and a helper serving both would drop whichever half the other caller does not need. SoC stays STATED, NOT WIRED - part 11b drives the two worlds at 50 % and 11 % and the sentence does not move one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()` already rides `liveness_notes()` a few rows below, so the discriminator is "is that row there?", and part 11d asserts it is present in one world and absent in the other. THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground truth written down rather than derived from the published stream, asserts they are really two (40 of 40 reads differ) while the published stream is bit-identical, and only then grades the sentences. Eleven mutations, all red: each of the three sentences reverted (3, 2 and 2 named reds), the two explanations transposed at ZERO bytes (1), the second explanation deleted (1), the admission of ambiguity replaced by a ranking (1), all three reverted at once (5), and two aimed at this card's own check - collapsing the world pair (2) and emptying the accusation list, which a positive control against the three sentences as 0.10.1 shipped them catches (1). AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE. Transposing `label` and `entity` in the row made it unfindable and the run died on an IndexError - a red with no name and every later check ungraded, FCST-06's shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3 after. And the first invariance run compared a tree with itself, because it archived a branch whose edits were not committed yet; the tool now REFUSES to report "behaviour unchanged" unless the sentences halves differ, and both of its refusals are driven. SAFETY-57's part 10h check was the handoff and is inverted rather than deleted: it required the accusation to still be present so that removing it had to be a deliberate act. This is that act. Its escape clause is untouched. Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
20 KiB
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
- 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 Win the trace is the add-on's owninputs missing → command 0 W. §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_Wsince SAFETY-37. The trip happened at +1060 W and the stuck state is at 0 W; they are different moments. §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.
- 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.
- 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():
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 mktemped 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.
⚠️ SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR. SAFETY-56 is the deliberate act: all three sentences now name both explanations and accuse neither, and part 10h's check is inverted — it requires the accusation to be gone. Nothing else in this section moved; the escape clause above is still this card's and is untouched by it. See
DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md, andtest_freshness.pypart 11 for the graded pair of worlds.
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.