# SAFETY-56 — what the gate cannot tell apart, said in the sentence **Text only. No behaviour changes in this card**, and §5 is the measurement that says so: the same stimuli through the tree before and after produce a **byte-identical** trace of every verdict, every `reading()`, every `inputs_ok` and every readiness row's `(ok, warn)`. --- ## 1. The defect 0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that healthy hardware has failed. **The log line**, as emitted on the reference site on 2026-09-21: > SAFETY-37: inverter-a battery-power register **is not following its own > command** - STALE_READING: -3 unchanged for 36 s while the standing command > moved 800 W to +1600 W - **the reading is not following**, so it is reported > as MISSING rather than as a measurement … Treating it as MISSING, which > commands 0 W. **The readiness row**, at the same moment: > NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power` > **is missing or not numeric** That entity was present, numeric and updating. **SAFETY-37's conjunction is correct and is not touched by this card** — `unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register genuinely stuck against a real command is exactly what it should catch, and a pack that cannot comply is a state the site must stop in too. What was wrong is what the sentences **concluded** from it. ## 2. Two worlds satisfy the conjunction | | the register | the pack | |---|---|---| | **DEAD** | has stopped reporting reality | is doing what it was told | | **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor | A refused pack holds still under any setpoint, so an honest register publishes the same unchanged number a dead one does. **Nothing `freshness.py` is handed separates them.** All three sentences asserted DEAD. ## 3. The live episode was REFUSED, and it is measured, not argued Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`, whose bytes and decoder are already graded by `test_freshness.py` part 10a: | | | |---|---| | the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 | | the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge | | pack SoC throughout | **11 %** | | first **charge** command after the restart | 11:05:31 at **-400 W** | | the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window | **A register that answers a reversed command inside one obligation window had not stopped reporting.** What would not move was the pack. The sentence the operator read named the one thing in the loop that was telling the truth. ⚠️ What this does **not** establish: that no register is ever intermittently wrong. It excludes a register that had simply stopped; it cannot exclude one that was wrong and then right. That is the point — the new sentence claims neither. ## 4. The fix, and why it is three sentences and not one helper Each message states **what was observed** and names **both** explanations. | where | before | after | |---|---|---| | `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* | | `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* | | `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* | **Three sentences, three wordings, no shared helper.** The first two are a log line about a **behaviour**; the third is a banner about a named **entity**, and the two answer different questions ("what did the register do" against "can I trust this entity"). A helper serving both would have to drop whichever half the other caller does not need, and the half it dropped would be the operator's next step. The log line names the pair **once**, inside the `%s` it already interpolates, rather than in both of its own halves. **No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number) and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and asserts the sentence does not move one byte. **The readiness row does not branch.** `inv.batt_fresh.stale` is on the same object at the same moment, and the row deliberately does not ask it: the same loop renders the grid and SoC rows, which have no gate, and a branch per row would be a second place where "why is this None" is decided. The gate already has a surface — `stale_note()` rides `liveness_notes()` a few rows below with the whole STALE_READING sentence in it — so the operator's discriminator is *"is that row there?"*, and part 11d asserts that row is **present in REFUSED and absent in ABSENT**. ## 5. Behaviour is byte-identical, measured `tools/safety56_behaviour_trace.py` prints two documents from one stimulus set. The `behaviour` half contains **no sentence**: only `stale`, `reading()`, `releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against `origin/release/1.0` **archived out of git** and against this branch: | | | |---|---| | the committed live capture | **2390 reads**, 7 verdict edges | | a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases | | six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` | | **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** | ⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour unchanged" unless the `sentences` halves **differ**. This card's first invariance run was exactly that mistake — it archived a branch whose edits were not committed yet, so `before` and `after` were the same code and the identity was perfect and worthless. Both of the tool's refusals are **driven**, not asserted: | | | |---|---| | the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** | | `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** | | this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** | ## 6. The mutation battery Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`, graded by `tools/mutation_run.sh` in the CI image. | | mutation | Δ bytes | named reds | |---|---|---|---| | M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** | | M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** | | M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** | | M4 | *register* and *source* transposed between the two explanations | **0** | **1** | | M5 | the second explanation deleted, the first kept | -216 | **1** | | M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** | | M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** | | M8 | the row's second cause deleted | -309 | **1** | | M9 | `label` and `entity` transposed in the row | **0** | **3** | | M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** | | M11 | the accusation literals replaced by one that matches nothing | 0 | **1** | ⚠️ **M9's first run was a defect in this card's own check, and it is the one worth reading.** The row was fetched with `[...][0]`. Transposing `label` and `entity` makes the row unfindable, so the run died on an `IndexError` — a red with **no name attached**, with every check after that line **ungraded**. That is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of raising, and a precondition grades it: **0 named reds before, 3 after.** ⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is True, and so is one over three mistyped literals. The positive control runs the same predicate against the three sentences **as 0.10.1 shipped them** and requires it to find an accusation in every one, and every literal to match at least one of them (habit 7: vary one thing from an accepted baseline). ## 7. What part 11 does not grade * It does not show the live episode was a BMS refusal beyond doubt — see §3. * It does not grade English. A sentence that named both explanations and then said something useless about them would pass. * The `no entity configured` branch beside the rewritten row is untouched and ungraded: a blank entity really is the only thing it can be. * The **grid-power and battery-SoC rows** go through the same rewritten line and have no freshness gate today, so for them the second cause is a hypothesis about this controller rather than a live possibility. That is weaker than the battery-power case and is the price of not branching per row. A gate on either signal — TEL-08 would be one — makes it exact. * **SAFETY-58 is not touched** (the first read after a long rest trips before a healthy register could answer), and neither is the 710/790 disagreement between `test_freshness.py` and the changelog. Both are separate cards; the second has a **fourth site** at `DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written off the PR table alone would miss.