# SAFETY-37 — the freshness policy, and the one sentence in the card that had to be rewritten **Status: DECIDED 2026-09-06.** A per-unit reading that has stopped following its own command is reported to its consumers as **`None`**, never as a held number. The verdict is a **conjunction**, not an age threshold, and this document records why an age threshold is not available at this layer — which is the one place the card's wording could not be implemented as written. Filed with SAFETY-37. Card `6a9a7eda7932387c3acbd700`, scope widened by the architect pass of 2026-09-06 to carry the single policy for [[TEL-06]] and [[TEL-08]] as well. Every figure below was measured or read out of this repository on **2026-09-06** and is re-derived by `test_freshness.py` on every run. Where a number is carried forward from an earlier measurement, the section and the date it was taken are given at the number. --- ## 0. The three faces, and which one this card built | where | the defect | this card | |---|---|---| | **Layer 1** — `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` | the ingest-age veto tests the **VALUE** of `sensor.goodwe_p1_sample_age`, not its arrival. A dead publisher freezes it at a healthy number and the veto can never fire | **not touched.** It is firmware, behind a flash. §5 says what it owes | | **The add-on** — `app/hass.py:69-82` | returns a held value as a value. Nothing separates "read 40 %" from "still says 40 % because nothing updated it" | **built here** | | **The evidence** — [[HW-01]] | SoC held 40 % for 6 h 25 m while the pack drained to its floor, every detector reading healthy | the case the policy is answerable to | The two consumers the card names — SAFETY-30's per-unit sign check and SAFETY-31's circulating-power sensor — both read `Inverter.batt` (register `0x0518`) and **neither had a freshness gate of any kind**. Both already do the right thing with a `None`: `watchdog.SignWatchdog.evaluate` **clears** its window on a `None` measurement, and `circulation.circulating_w` returns `None` for a roster with a missing term, which clears that window too. **Neither could ever receive one.** The gate is the piece that was missing *between* the shipped read and the shipped checks, which is why the change to each consumer is zero lines. --- ## 1. ⚠️ The sentence that had to be rewritten, and why saying so is the finding The card says: *"A pack legitimately resting at 40 % and a dead sensor reading 40 % are the same number; **only arrival separates them**."* **For a signal this add-on polls out of Home Assistant's state cache, arrival does not separate them, and cannot.** `read_inputs()` runs about once a second (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), and while the entity exists Home Assistant answers `200` with a number **whether or not the publisher behind it is alive**. So both members of the pair arrive, identically, for ever. `test_freshness.py` part 1a builds an arrival-age gate and grades it: the age since arrival is `0.0 s` at every instant of **both** runs. A threshold on it is a threshold that can never fire — which is TEL-06's own defect rebuilt one layer up. The other half of the card's rule survives intact and is honoured absolutely: > ⚠️ Home Assistant's `last_updated` does **not** move when a value is rewritten > unchanged, so it cannot be the source of truth. Neither does `last_changed`, and this project measured on the ENV-01 rig (70 s of a frozen meter) and independently against the live house that `last_reported` does not advance on a repeat on either serialiser either — `p1.HomeWizardLocalSource`'s docstring records both. **`app/freshness.py` reads no Home Assistant timestamp at all.** Every clock in the verdict is `time.monotonic()` inside this process, stamped at the instant an arrival carried a different number. That is what "arrival-stamped" can mean here, and it is graded (part 1a asserts the module's body contains no `last_updated`). **So the criterion was rewritten rather than approximated:** *a reading is stale when it has not moved through a change it was obliged to follow.* The obligation, not the age, is what separates the pair. --- ## 2. The verdict ``` STALE ⟺ unchanged for ≥ 35.0 s AND |standing command now| ≥ 250 W AND |standing command now − standing command when the reading last changed| ≥ 250 W ``` Latched: **only an arrival carrying a different number clears it.** `0x0518` is the inverter reporting **itself** — r = 0.998 with battery power against r = 0.09 with the real meter (`goodwe/NOTES.md` §3.3) — and it tracks its own command: command and actual agreeing within 4 W once settled (§5.10, measured 2026-08-22), the register dithering ±1 W while holding (§5.11, same day). A unit told to move 250 W and still answering the same integer watt is not tracking. ### 2.1 Why a bare "unchanged for N seconds" threshold is not available **Measured, not feared.** Replaying the committed 30-day capture of this house's own 1 W-resolution power entity — `scenarios/p1-net-30d-2026-08/`, 485,841 samples straight out of Home Assistant's recorder, where a row exists only when the state string **changed**, so the gap between two rows *is* the unchanged duration every consumer saw: | | | |---|---| | longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) | | runs ≥ 35 s | 10 of 485,840 (0.0021 %) | | runs ≥ 17 s | 386 of 485,840 (0.0794 %) | A real power figure at 1 W resolution held **one value for four and a half minutes with nothing whatever wrong**. So an unchanged threshold free of false positives must be over 263 s — and 263 s is **3.5× too slow** to protect a consumer that trips at 75 s. There is no admissible threshold. The conjunction is not a refinement; without it the card has no implementable verdict. ⚠️ **That is the METER's register, not `0x0518`, and it is used as a surrogate and named as one.** No 30-day capture of the inverter's own power exists — it is not in the recorder export. The single thing this measurement is used for is the statement above: that the legitimate unchanged duration of a power entity in this house is *minutes*. It is not used to set the bound. ⚠️ **And every one of those long runs happened under an unmoving load** — the worst is 263 s of a steady −899 W export, a house doing one thing. That is why the conjunction keys on command travel: none of them arms it. ### 2.2 Why the magnitude term exists — a surviving mutation put it there Deleting `abs(standing_w) < move_w` from `_grade()` left **every check in the first draft green**, because in all of them a command that had *travelled* 250 W had also *ended up* above 250 W. The case that separates them is a site being brought to **rest**: the write path is serialised, so a register can reach its new value on the same read at which the *old* command is still standing — `standing_at_change` then captures 1300 W, the site is commanded to 0 W, and the travel term alone reads 1300 W of unfollowed command for as long as the site rests. It would call a perfectly truthful +5 W reading dead. The magnitude term is what says *a site at rest is not owed movement*, and part 4 now grades it. **The hole that term leaves cannot mislead either consumer**, which is why it is the right trade: a unit commanded under 250 W is one `watchdog.disagrees()` is `False` for whatever the measurement says, and a frozen term beside a live unit that is itself resting nets to **zero** circulating power rather than to a false alarm. Both halves are asserted, not argued. --- ## 3. The bounds — one per signal, each with its derivation ⚠️ **One constant for both signals would be useless on SoC or trigger-happy on power**, which is the third clause of the policy. `freshness.BOUNDS` carries them as data with a `wired` column, and `test_freshness.py` part 7 walks the AST of every shipped module to check that column tells the truth. ### 3.1 `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED** **Floor, 17.0 s** — the fastest a healthy register can be *obliged* to have answered. Three terms, each read out of this repository: | | | source | |---|---|---| | 6.0 s | the plant | `goodwe/NOTES.md` §5.9, measured 2026-08-22 with the poll temporarily at 1 s: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s | | 10.0 s | the publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` on the `modbus_controller` carrying both `0x0518` and `0x050E` | | 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` | **Ceiling, 75.0 s** — the tighter of the two consumers, both read out of the shipped `config.yaml`: `circulation_hold_s` **75** (line 512) and `unit_sign_disagree_s` **85** (line 451). A gate slower than its consumer hands over the frozen number and explains afterwards. **Chosen: 35.0 s.** The admissible band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]` contains four integers, and **the band is what is derived — 35 is a choice inside it**, deliberately not 30 (that is `validate.FIRMWARE_INGEST_VETO_S`, and would read as a borrowed constant nobody re-derived) and not 60 (round). Part 6 computes both ends **from `config.yaml`** and goes red if an operator default moves: drop `circulation_hold_s` to 60 and this fails. **`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity preference. The gate must arm everywhere a stale reading could mislead a consumer; the earliest either consumer speaks is `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that `watchdog.disagrees()` is `False` whatever the measurement says. Taking the ceiling of the admissible range minimises spurious arming. It is pinned to `config.yaml` by part 6, not to a literal. ### 3.2 `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED** SoC **cannot use the conjunction above.** A pack resting at its floor holds one percent for hours with nothing wrong — the card's own example — and the standing command says nothing about it, because 0 W is exactly what a rested pack is given. The obliged movement for SoC is **energy, not command travel**: one percent of pack A is ≈ **153.6 Wh** (15.36 kWh nominal, `goodwe/NOTES.md` §1.1b's table — ⚠️ **NOMINAL and FROM DATASHEET**, not a measured usable capacity), so at pack A's 5000 W ceiling one percent takes at least **110 s**, and at a realistic 1000 W about **553 s**. So the bound is *"SoC unchanged while ∫|battery power| dt since its last change exceeded K × 153.6 Wh"*, and **K cannot be chosen from anything written down today**. Wiring it with a guessed K would be a gate that fires on a rested pack, which is the hazard, not the fix. `Freshness` takes its obligation term from the caller precisely so an energy integral can be handed in where command travel is handed in for `batt` — TEL-08 needs no second mechanism. --- ## 4. What changes on a running install | | before | after | |---|---|---| | healthy register, N=1 | value reaches consumers | **identical** — 60 reads of a dithering register under a reversing command, asserted in part 5 | | healthy register, N≥2 | value reaches consumers | identical | | **frozen register, N=1** | reads as a healthy measurement, for ever | **site stops**: `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding | | **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`, both windows **clear** | ⚠️ **The N=1 row is a behaviour change on the roster that is running in a house today, and it is named rather than hidden.** It is also the point of the card: HW-01 was a one-inverter site. The direction it fails in is the safe one — a false verdict commands 0 W and keeps commanding (§11.1 rule 1), behind the existing `stale_input_s` hold, and clears itself on the register's next move. The counterfactual is run, not argued. `test_freshness.py` part 3 drives real `cycle()` calls at N=2 with A's register frozen at −1200 W and B's live at +900 W, the command travel produced by the shipped `compute()` and allocator: ``` standing on A: [0, 445, 890, 1335, 1335, ...] (the shipped control law) gated : sign trip tick=None circulating_w=[1800, 1800, 1800, None, None, ...] counterfact : sign trip tick=11 circulating_w=[1800, 1800, 1800, 1800, 1800, ...] ``` --- ## 5. What TEL-06 and TEL-08 still owe **[[TEL-06]] — layer 1, Blocked, and nothing in this card can reach it.** The ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`. This policy lives in the add-on, on the other side of a flash, and cannot help. What TEL-06 owes is the same mechanism **inside the firmware**: a `millis()` stamp taken when the setpoint/age entity is *written*, and a veto on `millis() − last_write_ms` rather than on the published number. It is narrowed to that one substitution — the policy question ("arrival, not value") is answered here; the remaining work is a firmware stamp and a flash, which is a supervised human step. **[[TEL-08]] — stale SoC.** It owes exactly three things, and no mechanism: 1. a **measured** usable capacity for pack A (or a defensible bound on it) — the 153.6 Wh per percent above is nominal and from the datasheet; 2. the **energy integral** since the reading last changed; 3. the constant **K** — how many percent-equivalents of throughput must pass before an unmoved SoC is a fault. It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not restate the policy. The row is already in the table with `wired=False` and the reason, and part 7 goes red the day that column stops being true. **⚠️ Neither of them, and not this card, closes HW-01's whole class.** See §6. --- ## 6. Known survivors, with their size Graded in `test_freshness.py` part 9, so they are residuals rather than surprises. | # | kind | what survives | size | |---|---|---|---| | 1 | **false positive** | an inverter physically unable to follow — saturating, thermally derating, or a BMS refusing a charge — is declared stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere | | 2 | **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | ⚠️ scoped for ONE consumer, NOT both, and the earlier claim here was wrong. TRUE for the per-unit sign check: no such check can speak about that unit (`unit_sign_disagree_w` = 250 W). **FALSE for circulation.** "Contributing at most 250 W" bounds the COMMAND; once the register is frozen its REPORTED value is unrelated to the command, which is exactly HW-01's shape - commanded ~0 W, register stuck on the last large number. Measured: a unit frozen at −1200 W beside a healthy +900 W sibling yields `circulating_w` = **1800 W against the 700 W floor, 2.6× over**, with no gate armed. Found in review; part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling, so neither graded the sentence | | 3 | **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock at all | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's arrival stamp inside the firmware is the only layer that could | Recorded because it looks like a survivor and is not: a publisher failure that freezes **both** registers is caught on both units independently, on each unit's own command travel and each unit's own number. --- ## 7. Mutation record — 2026-09-06 `tools/mutation_run.sh`'s rule, honoured on every run: purge first (count printed, `0` remaining), tree mounted `:ro`, `python3 -B`, fingerprint the tree, **and fingerprint again after the revert**. Clean tree `app/freshness.py` `sha256:f40fa3e8819014ce` / 20,065 B, `app/inverter.py` `7e760c6e8cdfa5b7` / 30,778 B, `app/main.py` `7d7ff4ba0697ea64` / 238,535 B. Each mutant graded by `test_freshness.py` + `test_inverter.py`. | # | mutation | file | Δ B | result | |---|---|---|---|---| | M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything | | M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks | | M2 | drop the command-travel term | freshness | −59 | red | | M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — §2.2. This is the finding | | M4 | latch never holds | freshness | +13 | red, 3 named checks | | M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red | | M6 | bound 10 s (under the physical floor) | freshness | **+0** | red | | M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin | | M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks | | M9 | reset the unchanged clock on every arrival (value-stamped) | freshness | −43 | red | | M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it | | M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** | | M11b | the same defect, written parseably | freshness | +105 | red | | M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red | | M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too | | M14 | one `Freshness` shared by every unit | inverter | +29 | red | | M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red — see below | ⚠️ **M5, M6 and M7 are `+0 B`, which MUTATION.md names as the risky delta.** All three are **reds**, which is the direction stale bytecode cannot fabricate, and every run purged with the count printed and graded a `:ro` tree that physically cannot hold a cache. ⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into the middle of an `if` body, so the module raises `IndentationError` at line 312 and both files go red without a single check being evaluated. Verified by `ast.parse` rather than assumed. **M11b** is the same defect written so it parses, and it is a real red. ### The two survivors, and what each one taught **M3, the magnitude term** — see §2.2. Two terms of a conjunction that move together in every fixture are one term as far as the checks are concerned. Recorded as `MUTATION.md` **habit 17**, twelfth instance of "a check whose DATA cannot reach the failure it names". **M15, the latch cleared by a failed read** — found only because the sweep went looking for what the checks would NOT see. No fixture had ever injected a read failure *while latched*, and it matters: a stale verdict stops the site, the site is then commanded 0 W, and one unreachable-Home-Assistant read would drop the latch into a state where the conjunction is no longer armed — so the held −1200 W would come back to the consumers on the very next successful read. Part 4 now grades exactly that and M15 is red. ### And one process defect worth more than the ledger One mutation's container run overran a two-minute command budget and the wrapper was killed **between the edit and the revert**, leaving `app/freshness.py` mutated on disk. Nothing announced it; the next mutation's harness cheerfully fingerprinted and graded the mutant *plus* a second mutation. It was caught by comparing the tree fingerprint against the recorded clean one, restored, and verified back to byte and `sha256` before anything else ran. Same defect as stale bytecode, different cause. Recorded as `MUTATION.md` **habit 18**: *fingerprint after the revert, not only before the run.* ### A third, in this file's own fixture M2 originally reported "red" with **no named check behind it**. The mutant makes the FRESH run stale, so `test_freshness.py`'s own diagnostic `print(f"… {sorted(set(_fresh_read))}")` raised `TypeError` comparing `None` with a float **before check one ran**. MUTATION.md's reporting rule is *"which suite went red, and on which named check"*, and a print that can crash first destroys exactly that. Sorted with `key=str` now; M2 reports its check. ### Re-verified at the committed head The 16-row sweep above graded `test_freshness.py` as it stood when each mutation ran; the two closures (M3's part-4 check, M15's failed-read check), the `key=str` fix and the wall-clock hardening all landed afterwards. So six mutations were re-run against **the exact tree that is committed** — `test_freshness.py sha256:a62f3e1f1ac856cf`, 44,913 B: | # | result at head | |---|---| | M0 control | **green** | | M2 drop the command-travel term | red, on part 1's "FRESH AND UNCHANGED is not stale" | | M3 drop the magnitude term | red, on part 4's magnitude check | | M8 wiring removed | red, on three named checks in parts 2 and 3 | | M11b `standing_at_change` drifts | red, on three named checks in part 1 | | M15 latch cleared by a failed read | red, on part 4's failed-read check | The other ten are unaffected by those edits: each is red on a check in parts 1, 2, 4, 6, 7 or 9 that none of them touches, and a mutation red on one check cannot be made green by adding another. ### The wall clock, which part 3 cannot avoid and therefore measures The freshness verdict runs on an injected monotonic clock and is deterministic. The sign watchdog inside `cycle()` cannot be injected, so part 3 runs against the real one — which is CI-06's failure mode exactly (`test_peakguard.py` answered two checks with the wall clock, flaked at ~20 %, and cost a wrong merge diagnosis). So the margin is measured and asserted rather than assumed: gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin) If a loaded machine ever narrows that, the check fails **naming the number**, which is the difference between "the box was slow" and "the code is broken".