Two blocking findings from review, both in prose, both corrected at every site. 1. "trips on tick 7" was not what the fixture prints. It prints 11, five runs out of five, and the developer's own report says 11. ⚠️ But the deeper problem is that the number should never have been quoted: the tick is counted on the WALL CLOCK inside cycle() (hold_s 2 s, ticks 0.20 s apart), so it is load-dependent - 7 is what a box slow enough to stretch each tick to ~0.29 s would print - and nothing pins it. The DECISIONS transcript now says 11 because it is a transcript of a run; the CHANGELOG stops quoting a tick at all and states the checkable thing instead: it trips without the gate and never with it, which part 3 grades. 2. ⚠️ SURVIVOR 2's SIZE CLAIM WAS FALSE FOR ONE OF THE TWO CONSUMERS THIS CARD EXISTS TO PROTECT. It read "one contributing at most 250 W to a loop whose reporting floor is 700 W" at three sites. That 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 in review: a unit frozen at -1200 W beside a healthy +900 W sibling yields circulating_w = 1800 W against the 700 W floor, 2.6x OVER, with no gate armed. So the scoping is TRUE for the per-unit sign check and FALSE for circulation. Corrected to say exactly that, and the circulation consumer's exposure below MOVE_W is now recorded as a KNOWN GAP rather than a covered one. It survived because part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling (circulating_w([A_FROZEN_W, 0.0]) == 0.0) - neither grades the size sentence. No behaviour change. Full suite: 22 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
22 KiB
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_updateddoes 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:
- 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;
- the energy integral since the reading last changed;
- 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".